Auditing vendored dependencies for tampering Jump to heading
Vendored code is the part of a repository nobody reads. It arrives in one large commit, it is skimmed during review, and from then on it is background. That makes it the ideal place for a change to go unnoticed β whether a hostile modification or, far more commonly, a well-meaning local patch applied during an incident and never mentioned again. Both are invisible for the same reason: nothing records what the tree is supposed to contain. This recipe supplies that record, completing the vendoring side of Submodule & Dependency Integrity.
When to use this approach Jump to heading
- Your repository contains third-party code copied in rather than referenced by a pin.
- A dependency upgrade once mysteriously reintroduced a bug you had fixed β the signature of a lost local patch.
- You need to state, for an audit or a customer, that included code matches its declared upstream.
- The dependency has no upstream signatures, so commit verification cannot be used and a content digest is the available substitute.
- You converted a submodule to a subtree and want back the assurance the gitlink used to provide.
Step 1 β Inventory what is vendored and where it came from Jump to heading
# Candidate directories β the usual names, plus anything your project uses
for d in vendor third_party thirdparty external deps; do
[ -d "$d" ] && du -sh "$d" && git ls-files "$d" | wc -l
done
# Which of them already declare an origin?
find vendor third_party -maxdepth 2 -iname 'UPSTREAM*' -o -maxdepth 2 -iname 'PROVENANCE*' 2>/dev/null What changed: nothing β but the second command usually returns nothing at all, which is the finding.
For each directory, reconstruct provenance from history while it is still reconstructible:
# The commit that introduced the vendored tree usually names the version
git log --oneline --diff-filter=A -- vendor/libfoo | tail -3
git log --format='%B' --diff-filter=A -- vendor/libfoo | tail -20 Step 2 β Record a reproducible digest Jump to heading
The digest must be derived from something stable across machines. Gitβs index listing is exactly that: mode, object id and path for every tracked file.
# One value covering content, permissions, additions and deletions
digest() {
git ls-files -s "$1" | grep -v '\.tree-digest$' | LC_ALL=C sort | sha256sum | cut -d' ' -f1
}
digest vendor/libfoo > vendor/libfoo/.tree-digest
cat > vendor/libfoo/UPSTREAM <<'EOF'
url: https://example.com/upstream/libfoo
tag: v3.2.1
commit: b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1
patches: none
EOF
git add vendor/libfoo/.tree-digest vendor/libfoo/UPSTREAM
git commit -m "deps: record libfoo provenance and tree digest" What changed: the tree now carries a statement of what it should be, alongside a value that can prove it.
# Re-deriving it immediately must reproduce the recorded value
[ "$(digest vendor/libfoo)" = "$(cat vendor/libfoo/.tree-digest)" ] && echo "digest reproduces" Two details make this reproducible rather than nearly-reproducible: excluding the digest file from its own input, and forcing a byte-ordered sort with LC_ALL=C. Skip the second and the value changes between a developerβs locale and the CI runnerβs, producing a failure that looks like tampering and is not.
Step 3 β Compare the tree against upstream Jump to heading
The digest catches drift after vendoring. To establish that the vendored copy matched upstream in the first place, compare directly.
# Fetch the declared upstream commit into a scratch location
url=$(awk -F': *' '/^url:/{print $2}' vendor/libfoo/UPSTREAM)
sha=$(awk -F': *' '/^commit:/{print $2}' vendor/libfoo/UPSTREAM)
git clone --no-checkout "$url" /tmp/libfoo-upstream
git -C /tmp/libfoo-upstream checkout "$sha"
# Compare, ignoring the metadata files we added ourselves
diff -r --exclude=.git --exclude=UPSTREAM --exclude=.tree-digest \
vendor/libfoo /tmp/libfoo-upstream What changed: nothing in the repository β but you now know whether the vendored tree is a faithful copy of what it claims to be.
# An exit status of 0 means byte-identical
echo "diff exit: $?" # 0 = clean, 1 = differences to explain If the comparison shows differences you cannot account for, treat it as a security finding rather than a paperwork problem: check who introduced them and when with git log -p -- vendor/libfoo, and rotate anything the code had access to if the change is unexplained.
Step 4 β Run the audit in CI Jump to heading
#!/bin/sh
# ci/audit-vendored.sh β fail the build on undeclared drift
set -eu
status=0
for up in $(git ls-files '*/UPSTREAM'); do
dir=$(dirname "$up")
recorded_file="$dir/.tree-digest"
[ -f "$recorded_file" ] || { echo "$dir: no recorded digest" >&2; status=1; continue; }
current=$(git ls-files -s "$dir" | grep -v '\.tree-digest$' | LC_ALL=C sort | sha256sum | cut -d' ' -f1)
recorded=$(cat "$recorded_file")
if [ "$current" != "$recorded" ]; then
echo "" >&2
echo " $dir has drifted from its recorded state." >&2
echo " recorded: $recorded" >&2
echo " current: $current" >&2
echo " If this change is intended, add it to $dir/patches/ and update the digest:" >&2
echo " git ls-files -s $dir | grep -v .tree-digest | LC_ALL=C sort | sha256sum | cut -d' ' -f1 > $recorded_file" >&2
echo "" >&2
status=1
fi
done
exit "$status" # Verify both outcomes before relying on it
sh ci/audit-vendored.sh && echo "clean"
echo "// stray edit" >> vendor/libfoo/src/a.c
sh ci/audit-vendored.sh || echo "drift correctly detected"
git checkout -- vendor/libfoo/src/a.c SAFETY WARNING β do not let a drift failure be fixed by regenerating the digest without reading the diff. That converts the check into a formality: the person who runs the regeneration command is the only one who ever sees what changed. Require the diff in the pull request description, and require the digest update to be its own commit so a reviewer can see it happening.
Step 5 β Formalise legitimate local patches Jump to heading
# Capture the local change as a reviewable patch, then restore the pristine tree
mkdir -p vendor/libfoo/patches
git diff -- vendor/libfoo/src > vendor/libfoo/patches/0001-reject-empty-frames.patch
git checkout -- vendor/libfoo/src
# Declare it, and re-record the digest for the pristine tree plus the patch file
sed -i 's|^patches: .*|patches: 0001-reject-empty-frames.patch (upstream issue #482, drop after v3.4)|' vendor/libfoo/UPSTREAM
git ls-files -s vendor/libfoo | grep -v '\.tree-digest$' | LC_ALL=C sort | sha256sum | cut -d' ' -f1 > vendor/libfoo/.tree-digest
git add vendor/libfoo && git commit -m "deps: declare local libfoo patch and refresh digest" The drop after v3.4 note is the part that makes this maintainable. A declared patch with an exit condition gets retired; one without becomes permanent by default, and permanent local patches are how a vendored dependency quietly forks.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Is a Git diff not already enough to see changes to vendored code? Jump to heading
It shows a change at the moment it is made, to whoever is reading that pull request. It says nothing a year later about whether the tree still matches upstream, and reviewers routinely skim large vendored diffs. A digest converts a question that requires archaeology into one command that answers yes or no.
What digest should I use for a directory? Jump to heading
Hash Gitβs own index listing for the path β git ls-files -s covers mode, object id and path for every tracked file, so it detects content changes, permission changes, additions and deletions in one value. Hashing file contents directly with find and sort works too but is easy to get wrong across platforms, because directory ordering and metadata differ.
How do I audit a dependency whose upstream is gone? Jump to heading
You cannot compare it to anything, so the digest becomes the only integrity anchor you have β freeze it and treat any change as requiring explicit review. This is also the strongest possible argument for mirroring dependencies while their upstream still exists, because an unverifiable dependency is one you are trusting on faith indefinitely.
Related Jump to heading
- Submodule & Dependency Integrity β the parent guide, including when to prefer a pin over a copy.
- Converting a Submodule to a Subtree β the conversion that makes this audit necessary, and how to keep provenance through it.
- Commit Verification Gates β the stronger check available when upstream signs its releases.