Submodule & Dependency Integrity Jump to heading

Signing your own commits establishes who wrote the code in your repository. It says nothing about the code your repository includes β€” the submodule pinned three years ago, the vendored library someone patched during an incident, the dependency directory nobody has looked at since it was added. That gap is where supply-chain problems live, and it is the natural extension of Commit Signing & Git Supply-Chain Security: the same question of provenance, applied to code you did not write.

Git’s answer for included repositories is the submodule, and it is a good one that is widely misunderstood. A submodule entry in the superproject is not a copy of the dependency and not a version range β€” it is a single commit SHA, recorded in the tree, and that immutability is the whole security property. What it does not give you is any assurance that the commit you pinned was trustworthy when you pinned it. Pinning and verifying are different jobs, and both are needed.

Prerequisites Jump to heading

Step 1 β€” Understand What the Pin Actually Guarantees Jump to heading

What a pinned SHA does and does not protectThe superproject records one upstream commit SHA. New upstream commits, including malicious ones, do not affect the build because the pin still names the old SHA. The pin does not help if the pinned commit was itself malicious, or if the upstream server permits a force-push that changes what that SHA resolves to.upstream repository, movinga1b2c3d4malicious commit pushed upstream todayyour build never sees it β€” the pin still says b2superproject gitlinkvendor/libfoo β†’ b2protected: upstream cannot change what you buildan update is a commit in your repository, reviewed like any othernot protected: b2 was already maliciouspinning preserves a choice β€” it does not evaluate itnot protected: upstream permits force-pushverify signatures, or mirror the dependency yourself

The two red boxes are why β€œwe pin our dependencies” is a partial answer. Pinning converts an implicit, continuously-updating trust decision into an explicit, dated one β€” which is a real improvement β€” but the decision still has to be made, and that is Step 3’s job.

Step 2 β€” Add a Submodule With an Explicit, Reviewed Pin Jump to heading

# Add the dependency; Git records the current tip of the named branch
git submodule add --branch v3.2.1 https://example.com/upstream/libfoo.git vendor/libfoo

# Inspect what was actually recorded
cat .gitmodules
git ls-files --stage vendor/libfoo    # mode 160000 = gitlink, followed by the SHA
# The recorded SHA β€” this is the only thing that governs your build
git rev-parse HEAD:vendor/libfoo
git -C vendor/libfoo log --oneline -1

Note that --branch records a preference for future updates, not a floating reference. The build always uses the gitlink SHA. This surprises people who expect a branch name in .gitmodules to mean β€œtrack this branch” β€” it does not, and that is a feature.

Write the provenance into the commit message, because the diff cannot carry it:

git commit -m "deps: vendor libfoo v3.2.1

Upstream: https://example.com/upstream/libfoo
Tag:      v3.2.1
Commit:   b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1
Signed by: [email protected] (verified against allowed_signers)
Reason:   required for the new settlement API"

Step 3 β€” Verify the Pinned Commit Before Trusting It Jump to heading

# Is the pinned commit signed, and by whom?
git -C vendor/libfoo verify-commit HEAD

# For a tagged release, verify the tag object itself
git -C vendor/libfoo verify-tag v3.2.1

# Which key signed it, checked against your trust anchor
git -C vendor/libfoo log -1 --format='%G? %GS %GK'
# G = good signature, U = good but untrusted, N = none, B = bad
# Fail loudly rather than silently accepting an unsigned dependency
status=$(git -C vendor/libfoo log -1 --format='%G?')
[ "$status" = "G" ] || { echo "unverified dependency pin: $status" >&2; exit 1; }

That last snippet belongs in CI, where it runs on every pull request that changes a gitlink. The mechanics of expressing β€œthis signature must be from someone we trust” are the same as for your own commits, and are covered in Commit Verification Gates β€” the only difference is whose keys populate the allowed_signers file.

SAFETY WARNING β€” an unsigned upstream is common and is not automatically disqualifying, but it must be a recorded decision rather than an unnoticed default. If a dependency cannot be verified cryptographically, mirror it into a repository you control, record the exact SHA and a content digest, and review changes on upgrade. Silently depending on an unverifiable third party is the state most supply-chain incidents begin in.

Step 4 β€” Update Deliberately, Never Automatically Jump to heading

Automatic versus deliberate dependency updatesAn automatic remote update moves the pin to whatever the upstream branch tip is, so the review shows only a changed SHA and the real change is invisible. A deliberate update fetches the range, reads what changed, verifies the signature and records the reasoning in the commit message.automatic β€” git submodule update --remotepin moves to branch tipwhatever it is today-Subproject b2c3d4e+Subproject 9a8b7c6reviewer sees two hex stringsand approves it anywaydeliberate β€” fetch, read, verify, then move the pingit -C vendor/x fetchread the upstream logfor the rangeverify-commitsignature good andfrom a trusted keycommit with the rangein the message, so thereview has something to reada realreview
# 1. Fetch upstream without moving the pin
git -C vendor/libfoo fetch origin

# 2. Read exactly what would change
git -C vendor/libfoo log --oneline HEAD..origin/main
git -C vendor/libfoo diff HEAD..origin/main --stat

# 3. Verify the target before adopting it
git -C vendor/libfoo verify-commit origin/main

# 4. Move the pin only after the above
git -C vendor/libfoo checkout <verified-sha>
git add vendor/libfoo

Steps 1 and 2 are what git submodule update --remote skips. The convenience command produces a one-line diff that no reviewer can evaluate β€” which converts dependency review into a rubber stamp.

Step 5 β€” Detect Drift in Vendored Directories Jump to heading

Vendored code has the opposite failure mode: it cannot change under you, but it can be edited by you and forgotten. A recorded digest turns that into a detectable event.

# Record provenance next to the vendored tree
cat > vendor/libfoo/UPSTREAM <<'EOF'
url:    https://example.com/upstream/libfoo
tag:    v3.2.1
commit: b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1
EOF

# Record a content digest of the tree as vendored
git ls-files -s vendor/libfoo | sha256sum > vendor/libfoo/.tree-digest
git add vendor/libfoo/UPSTREAM vendor/libfoo/.tree-digest
# The CI check: re-derive the digest and compare
current=$(git ls-files -s vendor/libfoo | grep -v '.tree-digest' | sha256sum)
recorded=$(cat vendor/libfoo/.tree-digest)
[ "$current" = "$recorded" ] || {
  echo "vendored tree differs from the recorded digest β€” patch it deliberately or restore it" >&2
  exit 1
}
Recorded provenance turns silent drift into a failing checkThe vendored tree ships with an upstream file naming the source and commit, and a digest of the tree as vendored. CI re-derives the digest from the working tree and compares. A match passes silently; a mismatch fails and names the change, so a local patch has to be declared rather than discovered later.vendor/libfoo/src/… (the code)UPSTREAMurl Β· tag Β· commit.tree-digestCI re-derivesgit ls-files -s | sha256sumdigests matchtree is exactly as vendoreddigests differsomeone edited the tree β€”declare it as a patch filean undeclared patch is the thing that silently disappears at the next upgrade

A failing check is not necessarily a problem: local patches to vendored code are sometimes correct. What matters is that they become visible, get reviewed, and are recorded β€” usually as a patch file alongside the vendored tree, so the next upgrade knows what has to be reapplied.

Where the digest should live Jump to heading

Keep the digest inside the vendored directory rather than in a central manifest. A single manifest listing every dependency becomes a merge-conflict magnet the moment two teams update two different dependencies in the same week, and the conflict is between unrelated lines that a resolver has no way to reason about. One file per vendored tree conflicts only when the same tree is touched twice, which is a genuine conflict worth a human decision. The same argument applies to the UPSTREAM file: colocating provenance with the code means a directory move carries its provenance along automatically, and a directory deletion removes it rather than leaving an orphaned manifest entry that outlives the dependency by years.

Configuration Reference Jump to heading

SettingDefaultEffectWhen to change
submodule.recursefalseRecurse into submodules for most commandsSet true when submodules are integral to the build
submodule.<name>.updatecheckoutHow update reconciles the working treeLeave at checkout; merge and rebase hide the pin
submodule.<name>.branchunsetWhich branch --remote followsSet for dependencies you intend to update regularly
diff.submoduleshortHow submodule changes appear in diffslog shows commit subjects, making review possible
status.submoduleSummaryfalseShow submodule changes in git statusEnable so an accidental pin move is noticed
protocol.file.allowuserWhether file:// submodules are permittedLeave restricted; a permissive value has been exploited
fetch.recurseSubmoduleson-demandFetch submodule objects during fetchon-demand is usually right; no for very large dependencies

Two rows there change review quality more than anything else in this guide. diff.submodule=log turns a two-hex-string diff into a list of upstream commit subjects, and status.submoduleSummary=true means a stray pin move is visible before it is committed rather than after it is merged.

Common Failure Modes and Diagnostics Jump to heading

A fresh clone builds without the dependency. Symptom: an empty directory where the submodule should be. Root cause: git clone does not populate submodules by default. Fix: git clone --recurse-submodules, or git submodule update --init --recursive afterwards; document whichever you standardise on.

The pin moves in commits nobody intended. Symptom: unrelated pull requests contain a gitlink change. Root cause: someone ran a command inside the submodule that moved its HEAD, then committed everything with git commit -a. Fix: enable status.submoduleSummary so it is visible, and treat a stray gitlink change as a review blocker.

CI builds a different dependency version from developers. Symptom: works locally, fails in CI or vice versa. Root cause: CI clones the submodule branch tip rather than the pinned SHA. Fix: check out the SHA from the index, and fail the job if it differs from what the superproject records.

The upstream repository disappears. Symptom: clones fail for everyone at once. Root cause: a dependency on a third-party URL with no mirror. Fix: mirror every external submodule into a repository you control, and point .gitmodules at the mirror β€” the pinned SHA is unchanged, so nothing else moves.

A vendored patch is lost during an upgrade. Symptom: a bug you fixed a year ago reappears after a dependency bump. Root cause: a local edit with no record. Fix: the digest check from Step 5, plus keeping local changes as patch files that must be reapplied and re-verified on each upgrade.

Team Rollout Jump to heading

Frequently Asked Questions Jump to heading

Does a submodule pin protect against a compromised upstream? Jump to heading

Partly. The pin names an exact commit, so an attacker who pushes new commits upstream cannot change what you build β€” your superproject still references the old SHA. What the pin does not protect against is the commit having been malicious when you pinned it, or a force-push that replaces the SHA’s content on a server that permits it. Pinning is necessary; verifying what you pinned is what makes it sufficient.

Submodules or vendoring β€” which is safer? Jump to heading

Submodules keep provenance: the SHA states exactly which upstream commit you use, and updating is an explicit, reviewable change. Vendoring keeps availability: the code is in your repository and builds even if upstream disappears, but its provenance is only as good as the process that copied it. The safest arrangement is vendoring with a recorded upstream SHA and an automated drift check, which is the pattern this guide describes.

Why did my submodule change appear as a one-line diff? Jump to heading

Because that is all a submodule is in the superproject: a gitlink entry recording one SHA. A reviewer sees the old and new SHAs and nothing about what changed between them, which is why a submodule bump needs the upstream range in its commit message. Without it, review is theatre β€” nobody can tell a patch release from an unrelated rewrite.

Should CI clone submodules recursively by default? Jump to heading

Only where the build needs them, and always at the pinned SHA rather than a branch tip. A recursive clone that follows branches turns a pinned dependency into a moving one and reintroduces exactly the non-reproducibility submodules exist to prevent. Fetch shallowly at the recorded SHA, and fail the job if the checked-out SHA differs from the one in the index.

How do I know a vendored directory has not been edited locally? Jump to heading

Record the upstream SHA and a content digest next to the vendored tree, then re-derive both in CI and compare. A local edit β€” however well-intentioned β€” changes the digest and fails the check, which is exactly what you want: patches to vendored code should be explicit, reviewed, and documented rather than discovered a year later during an upgrade.