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.

What the digest coversThe index listing contributes a file mode, object id and path for each tracked file. Hashing the sorted listing produces one value that changes if any file's contents change, if a permission changes, or if a file is added or removed.git ls-files -s vendor/libfoo100644 9f2c… 0 vendor/libfoo/src/a.c100644 3ab1… 0 vendor/libfoo/src/b.c100755 7e4d… 0 vendor/libfoo/build.sh…LC_ALL=C sort| sha256sumone value4b7a9e…the value changes when:a file's contents change (object id) Β· a permission changes (mode) Β· a file is added or deleted (a line appears or vanishes)

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
Two different questions, two different checksComparing against upstream answers whether the vendored copy was faithful when it was taken, and requires network access and the upstream repository. Comparing against the recorded digest answers whether the tree has changed since, and runs offline on every build.against upstream"was the copy faithful?"diff -r vendor/x upstream-checkoutneeds the network and the upstream reporun at import, and on each upgradeestablishes the baselineagainst the digest"has it changed since?"sha256 of git ls-files -soffline, deterministic, fastrun on every pull requestholds the baseline

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

Declared patches survive upgrades; inline edits do notAn edit made directly in the vendored tree is overwritten at the next upgrade and the bug it fixed returns. A patch kept as a file next to the tree, listed in the provenance record, is reapplied deliberately at each upgrade and fails loudly if it no longer applies.inline edit β€” invisibleedit src/a.c directlyduring an incidentupgrade replaces the treenothing warns anyonethe bug returns months laterand is diagnosed from scratchdeclared patch β€” survivespatches/0001-*.patchlisted in UPSTREAM,with a reasonupgrade reapplies itor fails loudly if it cannotthe patch is reconsidered each timeand retired when upstream fixes it
# 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.