Git
Git is a distributed version control system that names every file and commit by its hash, which proves content but not who wrote it.
Also known as git-scm, Git SCM
Git, also called git-scm after its project site, is a distributed version control system that stores a project's history as content-addressed objects: every file, directory, and commit is named by a hash of its contents. That makes history tamper-evident. Git authenticates content, not people, and a repository can carry code that runs.
Linus Torvalds made Git's first commit on April 7, 2005, and as of November 2026 the current release is Git 2.56, published in September 2026. The most important fact about Git for supply chain security is that a commit ID fixes the exact code and its entire history, while the author and committer lines inside that commit are free text that Git never checks. Our position: treat Git as an integrity system and nothing more. Identity has to come from signatures and from the server you push to, and a .git directory that did not arrive through git clone should be handled as untrusted code, because Git will run commands from its config.
What is Git?
#Git is a version control system that records snapshots of a project and the history that connects them. Distributed means every clone holds the complete history, so a laptop can commit, branch, diff, and search years of history without a server. Hosts such as GitHub run copies that a team agrees to treat as canonical, but they are copies like any other.
Day-to-day Git has four moving parts:
- Working tree: the files you edit.
- Index: the staging area that
git addfills and the next commit records. - Commits: immutable snapshots, each pointing to its parent commits.
- Refs: branches and tags, which are names that point to a commit and can be moved.
Every Git command, from git log to git merge, operates on those four things. Git's security properties follow from how commits are stored, and its security failures follow from everything that is not a commit.
Content-addressed storage: blobs, trees, and commits
#Git stores code as objects under .git/objects, each named by a hash of its type, size, and content. A blob holds one file's bytes. A tree lists file names, modes, and the IDs of blobs and subtrees. A commit names one tree, its parent commits, an author, a committer, and a message. You can check the naming rule by hand with Git and sha1sum:
$ git init -q greet && cd greet
$ git config user.name 'Ada Dev' && git config user.email ada@example.com
$ printf 'hello\n' > hello.txt
$ git hash-object hello.txt
ce013625030ba8dba906f756967f9e9ca394464a
$ printf 'blob 6\0hello\n' | sha1sum
ce013625030ba8dba906f756967f9e9ca394464a -
$ git add hello.txt
$ GIT_AUTHOR_DATE=2026-11-03T12:00:00Z GIT_COMMITTER_DATE=2026-11-03T12:00:00Z \
git commit -qm 'Add greeting'
$ git cat-file -p HEAD
tree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
author Ada Dev <ada@example.com> 1793707200 +0000
committer Ada Dev <ada@example.com> 1793707200 +0000
Add greeting
$ git cat-file -p 'HEAD^{tree}'
100644 blob ce013625030ba8dba906f756967f9e9ca394464a hello.txt
$ git rev-parse HEAD
18192f43e216b0e6a226bc55a7bb6e35f45a33d8A blob ID is SHA-1 over a short header (blob, the size, a null byte) plus the file's bytes. A tree contains its children's IDs, and a commit contains its tree's ID and its parents' IDs. One commit ID therefore fixes every byte of every file in that commit and in every commit before it. Change one byte anywhere in history and every later ID changes. That property lets thousands of independent clones agree on exactly what a commit contains without trusting one another.
Git still uses SHA-1 by default. The 2017 SHAttered attack produced a real SHA-1 collision, and Git has shipped a collision-detecting SHA-1 implementation as its default since Git 2.13 in May 2017. Repositories created with git init --object-format=sha256 use SHA-256 instead, an option since Git 2.29 in October 2020, but SHA-256 and SHA-1 repositories cannot yet exchange objects. Git's breaking-changes plan makes SHA-256 the default for new repositories in Git 3.0, names forge support as a prerequisite, and as of November 2026 gives no release date.
Does a Git commit hash prove who wrote the code?
#A Git commit hash proves what a commit contains, including its author line, but nothing checks that the author line is true. Git copies author and committer from user.name, user.email, and environment variables, and it accepts any value. Continuing the repository above, we commit as someone else and date the commit nearly two years before its own parent:
$ printf 'hello, world\n' > hello.txt
$ GIT_AUTHOR_NAME='Release Maintainer' GIT_AUTHOR_EMAIL=maintainer@example.org \
GIT_COMMITTER_NAME='Release Maintainer' GIT_COMMITTER_EMAIL=maintainer@example.org \
GIT_AUTHOR_DATE=2025-01-15T09:30:00Z GIT_COMMITTER_DATE=2025-01-15T09:30:00Z \
git commit -qam 'Update greeting'
$ git log --format='%h %an <%ae> %ad %s' --date=short
59acfe2 Release Maintainer <maintainer@example.org> 2025-01-15 Update greeting
18192f4 Ada Dev <ada@example.com> 2026-11-03 Add greeting
$ git verify-commit HEAD; echo "exit=$?"
exit=1
$ git cat-file -p HEAD
tree 82ad2dff9cb502d849a8e74f6a4f8f1291c173fc
parent 18192f43e216b0e6a226bc55a7bb6e35f45a33d8
author Release Maintainer <maintainer@example.org> 1736933400 +0000
committer Release Maintainer <maintainer@example.org> 1736933400 +0000
Update greetingThe figure shows commit 59acfe2 from the session above. Its body has five parts: a tree line pointing to tree 82ad2df, a parent line pointing to commit 18192f4, an author line and a committer line that both say Release Maintainer, and the message "Update greeting". It has no gpgsig header. The commit ID is the SHA-1 of all of those lines. Tree 82ad2df maps hello.txt to blob 4b5fa63, and parent 18192f4 points to its own tree and, through its parents, back to the first commit. The author and committer lines are highlighted because they are hashed into the ID but never checked against any account, key, or identity.
Attackers rely on this. In the Trivy GitHub Actions compromise of March 2026, an attacker force-pushed 75 of the 76 version tags in aquasecurity/trivy-action to new malicious commits. Each new commit copied the author, committer, both timestamps, and full message of the commit its tag used to point to, so git log looked unchanged. The forgeries had two tells. Every original commit carried GitHub's web-flow signature and the forged commits carried none, and each forged commit claimed a 2021 or 2022 date while its parent was dated March 2026.
Other campaigns use the same moves. PolinRider operators force-push rewritten history with anti-dated commits, so a malicious change looks months old on a repository's front page, and our July 2026 count found 162 malicious release artifacts across 108 packages and extensions. In the TanStack compromise, part of the Mini Shai-Hulud campaign, the worm used stolen tokens to commit copies of itself to victims' repositories with the author set to claude@users.noreply.github.com, so the commits resembled an approved coding assistant's.
A force-push replaces what a branch or tag points to. The old commits stay reachable in the reflog of any machine that had them, and GitHub records force pushes in a repository's activity log, but a fresh clone sees only the rewritten history. Review the activity log, not the commit list, when you suspect tampering.
What commit signing adds, and what it does not
#Commit signing adds a gpgsig header holding a signature over the rest of the commit object, which binds the content to a key. Git verifies it with git verify-commit, and GitHub verifies GPG, SSH, and S/MIME signatures and labels commits accordingly. Code signing for commits comes in three practical forms:
| Scheme | Key and trust model | Shown as Verified on GitHub | Main cost | Verdict |
|---|---|---|---|---|
| OpenPGP (gpg.format=openpgp) | Long-lived GPG key, trusted by upload to a forge account or a web of trust | Yes | Key expiry, rotation, and revocation that few developers maintain | Keep if your project already depends on it |
| SSH (gpg.format=ssh, Git 2.34+) | An SSH key, checked against an allowed-signers file locally or a signing key registered on the GitHub account | Yes | A long-lived private key on the laptop, stolen along with it | Default for human contributors |
| Sigstore gitsign (gpg.format=x509) | Short-lived certificate tied to an OIDC identity, recorded in the Rekor transparency log | No; verify with gitsign verify | Depends on Sigstore services; GitHub shows these commits as unverified | Best for bots and CI, where identity is a workflow |
Verdict: sign with SSH keys for people and Sigstore's gitsign for automation, then enforce it on the server with a rule that rejects unsigned commits on protected branches and release tags. The gitsign documentation states the gap plainly: "GitHub doesn't recognize Gitsign signatures as verified at the moment." Turn on GitHub's vigilant mode, so commits that claim to be yours but carry no valid signature are labeled Unverified.
Signed commits are necessary but not sufficient. A signature proves which key signed, not that its owner meant to. PolinRider and Mini Shai-Hulud both ran through compromised accounts and stolen tokens, and malware on a developer's machine can sign with that developer's key. Signing turned the Trivy forgeries from invisible into detectable. It cannot help when the attacker holds the key, which is the usual case in maintainer account takeover.
What is a Git hook?
#A Git hook is an executable program that Git runs at a fixed point in its own workflow, such as pre-commit before a commit is recorded or post-checkout after files are checked out. Hooks live in .git/hooks by default, and the githooks manual notes that Git ignores any hook without the executable bit. Teams use client-side hooks to run linters, block committed secrets, and enforce message formats. Server-side hooks such as pre-receive let a server reject a push.
Git hooks are not copied by git clone, and that omission is a deliberate security boundary. Git's security documentation says: "Because configuration and hooks are not copied using git clone, it is generally safe to clone remote repositories with untrusted content." A clone gets the objects and refs, plus the inactive .sample hooks from your own Git installation's template directory. The boundary holds by design. In practice there are three ways around it.
A tracked directory becomes the hooks directory
#The core.hooksPath setting tells Git to load hooks from another directory, including one inside the working tree. The husky package does this on purpose: its install function runs git config core.hooksPath .husky/_, and projects call it from the npm prepare script, which runs on npm install in a fresh clone. From that point on, files committed under .husky/ run on every commit. We reproduced it with husky 9.1.7 and npm 10.9.9:
$ git clone -q upstream project && cd project
$ cat package.json
{"scripts":{"prepare":"husky"},"devDependencies":{"husky":"9.1.7"}}
$ cat .husky/pre-commit
echo "pre-commit from a tracked file ran" >&2
$ echo b >> notes.txt && git commit -qam 'tweak' # no hook runs
$ npm install --no-audit --no-fund
added 1 package in 432ms
$ git config --get core.hooksPath
.husky/_
$ echo c >> notes.txt && git commit -qam 'tweak again'
pre-commit from a tracked file ranHusky is not malicious, and this is what teams install it for. The point is that the clone boundary now depends on whoever can edit .husky/ in the repository you cloned. npm v12's new default makes dependency install scripts opt-in, and a project's own prepare script is not a dependency script.
Your own template directory is copied into every clone
#git clone does copy hooks from one place: your template directory. The init.templateDir setting names a directory whose hooks/ contents Git copies into every repository you create or clone. The SANDWORM_MODE npm worm, which we found in at least 19 malicious packages in February 2026, abused exactly that. It wrote pre-commit and pre-push hooks into ~/.git-templates/hooks and ran git config --global init.templateDir, so every later clone on the machine started infected. Its pre-commit hook re-added the carrier dependency to package.json, and its pre-push hook sent .npmrc tokens and Git remotes out over DNS.
Since Git 2.54 in April 2026, hooks can also be defined in config files with hook.<name>.command and hook.<name>.event. That makes centrally managed hooks easier, and it adds another place to check for persistence: a few lines in ~/.gitconfig now register a hook for every repository.
Clone itself has had code-execution bugs
#Two Git vulnerabilities let a malicious repository run a hook during the clone. CVE-2024-32002, fixed in May 2024, let a recursive clone on a case-insensitive filesystem that supports symlinks execute just-cloned code during the clone. CVE-2025-48384, fixed on July 8, 2025, used a trailing carriage return in a submodule path to check a submodule out through a symlink into the hooks directory, where its post-checkout hook ran. CISA added CVE-2025-48384 to its Known Exploited Vulnerabilities catalog on August 25, 2025. Keep Git current on developer machines and CI images, and treat git clone --recurse-submodules on untrusted input as the higher-risk path it has been.
The figure shows the clone boundary. A source repository has objects and refs (commits, trees, and blobs) on one side, and .git/config and .git/hooks/ on the other. git clone transfers the objects and refs and does not copy the config or hooks, so the clone has the same history, fresh config, and only the hooks from the user's own templates. Four paths put executable configuration back: core.hooksPath = .husky/_, set by an install script so that tracked files become hooks; init.templateDir = ~/.git-templates, which copies malware's hooks into every new clone; a .git/ directory inside a zip file or tarball, whose core.fsmonitor runs on the next git status; and .vscode/tasks.json or .claude/ files, which are tracked files that editors and coding agents execute.
Is it safe to run Git in a repository you downloaded?
#Running Git inside a downloaded repository is not safe if its .git directory came from someone you do not trust. Git reads .git/config on every command, and several settings name programs to run. Git's documentation is explicit that "it is not safe to run Git commands in a .git directory" when that directory "comes from an untrusted source." git clone protects you by never copying config. A zip file, a tarball, a shared drive, or a repository vendored inside a package copies it intact. We packed a repository whose config sets core.fsmonitor, extracted it, and ran the most harmless command Git has:
$ git init -q ~/project && cd ~/project
$ echo hi > a.txt && git add a.txt && git commit -qm init
$ git config core.fsmonitor 'echo "fsmonitor ran in $PWD" > ~/PWNED; false'
$ cd ~ && tar czf project.tgz project && rm -rf project
$ mkdir download && tar xzf project.tgz -C download
$ cd download/project && git status --short; echo "status exit=$?"
status exit=0
$ cat ~/PWNED
fsmonitor ran in ~/download/project
$ rm ~/PWNED && cd ~ && git clone -q --no-local download/project clean
$ cd clean && git status --short && ls ~/PWNED
ls: cannot access '~/PWNED': No such file or directorygit status printed nothing and exited 0, and the command in core.fsmonitor ran anyway. Re-cloning with git clone --no-local produced a copy without that config and without execution, which is the sanitizing step Git's documentation recommends. The safe.directory check added for CVE-2022-24765 in April 2022 does not help here. It makes Git refuse repositories owned by another user, and an archive you extract is owned by you.
Developers rarely type that git status themselves. Shell prompts that show the current branch run Git in every directory you enter, and editors run Git when a folder opens. Researchers at Sonar showed in 2022 that this made opening a downloaded folder enough to run code in VS Code (CVE-2021-43891), JetBrains IDEs (CVE-2022-24346), and Git's own git-prompt.sh. Coding agents are a newer caller, since inspecting a repository usually starts with git status.
The same pattern now lives outside .git. PolinRider hides loaders behind .vscode/tasks.json entries with "runOn": "folderOpen", and the TanStack-wave worm wrote itself into .claude/settings.json hooks and .vscode/tasks.json. Those are tracked files, so they survive git clone. Git drew its trust boundary around .git, and editors and agents have moved executable config back into the working tree.
Git as a package source
#Package managers fetch code straight from Git repositories, which turns every property above into a dependency property. Go modules and Composer resolve versions from Git tags, and npm, pnpm, and Bun accept Git URLs in package.json. PolinRider exploits the first group. Once an account is taken over and history rewritten, Go and Packagist consumers receive the payload without the attacker ever touching a registry credential.
npm adds an install step on top. A Git dependency is source, so npm clones it and runs its prepare script to build it. That is how the TanStack versions ran their payload from tanstack/router#79ac49e, a parentless commit that was never part of TanStack's history. In the January 2026 PackageGate disclosures, a Git dependency's own .npmrc could point npm at a different git executable and run code even under --ignore-scripts. npm's first response was the --allow-git flag in npm 11.10.0, and npm v12, released July 8, 2026, refuses Git dependencies unless you allow them.
Socket's Git dependency alert fires on the manifest entry itself, so a package that reaches outside its registry is flagged even when the code it points to is gone or changes later. The default security policy sets it to Warn. The alert tells you a package depends on a Git URL. It cannot tell you what that URL will serve tomorrow.
Git verifies content, not identity
#Git solved the problem it was built for. A commit ID still pins an exact tree and its full history across every untrusted copy, and SHA-256 will widen that margin once forges support it. What Git never had is a notion of who. Author lines are free text, signatures are optional and unchecked by default, and refs move at the will of anyone with push access. Branch protection, signed-commit rules, and registry provenance all exist to attach identity to a system that only records content.
We think two changes would matter more than another scanner. Forges should treat a force-push to a default branch or release tag as a security event that consumers can query, not a line in an activity tab. And tools that open repositories, editors and coding agents above all, should adopt Git's own rule: configuration that executes code does not cross a trust boundary just because someone committed it. Until they do, cloning a repository is safe and opening one is not.
