Submodule and Vendored Code Drift
- submodules tracking a branch tip
- malicious submodules exploiting Git
- vendored code invisible to SCA
Scenario
A popular open-source machine learning project includes a data-processing library as a Git submodule. Someone once found pinned submodules tedious to update, so the CI pipeline runs this:
git submodule update --init --remote --recursive
The --remote flag ignores the commit recorded in the parent repository. It fetches the tip of the branch named in .gitmodules instead.
- An attacker steals the library maintainer's credentials and pushes a commit to the library's
mainbranch. - The new code looks for environment variables starting with
AWS_,GCP_orAZURE_and sends them out during the test phase. - The next CI run of the ML project builds that tip and runs the tests. The credentials leave.
- The project's SCA tool only reads
requirements.txt. It never looks inside the submodule, so nothing flags.
A second risk sits next to this one. The project also vendors a C parsing library into third_party/, copied in three years ago and patched by hand since. Nobody knows which upstream version it matches, so no scanner reports its known vulnerabilities.
Real-world precedent: Submodules are also an entry point for Git itself. CVE-2024-32002 (May 2024) let a crafted repository run code during git clone --recursive. CVE-2025-48384 (July 2025) abused carriage returns in submodule paths to write hooks, and CISA added it to the Known Exploited Vulnerabilities catalog in August 2025.
Reconnaissance
Explanation
.gitmodules and CI configuration are public in open-source projects. An attacker reads them to find submodules that float on a branch, and then targets the weakest maintainer in that chain. They also look for third_party/ or vendor/ directories, which often contain old code that scanners ignore.
Insight
A submodule is a dependency with none of the tooling you have for packages. No lockfile, no registry advisories, often no update bot.
Practical
- List every submodule and how CI updates it:
git config --file .gitmodules --get-regexp 'submodule\..*\.(url|branch)'
grep -rn -- '--remote' .github/ Makefile scripts/ 2>/dev/null
- List vendored directories and record where each came from.
- Check the security posture of each submodule's upstream repository.
Tools/Techniques
git submodule status --recursiveto see the pinned commit for each submodule.- OpenSSF Scorecard against each submodule's upstream.
Metrics/Signal
- Number of submodules, and how many CI updates with
--remote. - Number of vendored directories with no recorded upstream version.
Evaluate
Explanation
Understand how pinning actually works. The parent repository's tree stores a gitlink: the exact commit SHA of each submodule. .gitmodules only stores the path, URL and an optional branch. A normal git submodule update checks out the recorded SHA. Only --remote floats to the branch tip.
For vendored code, the question is whether you can prove the files match a known upstream release.
Insight
Plain submodules are pinned by default. Drift creeps in through scripts that add --remote and through vendored code that people edit in place.
Practical
- Confirm the recorded SHA for a submodule:
git ls-tree HEAD path/to/submodule
# 160000 commit 3f1c2a9... path/to/submodule
- Check every CI and developer script for
--remote. - Check the Git version on runners and developer machines against the fixed releases for CVE-2024-32002 and CVE-2025-48384.
- For each vendored library, diff it against the upstream release you think it is.
Tools/Techniques
- OSV-Scanner, which can identify some vendored C/C++ libraries by file hash and check them for known vulnerabilities.
git diff --no-index third_party/libfoo /tmp/libfoo-1.2.3to compare vendored code with upstream.
Metrics/Signal
- Vendored libraries with an identified upstream version.
- Runners and laptops on a patched Git version.
Fortify
Explanation
Keep submodules pinned to the gitlink and update them through reviewed PRs. Treat vendored code as generated: reproduce it from a known version, and fail CI if someone edits it by hand.
Insight
Updating a submodule should look like updating any dependency: a bot opens a PR, you read the changes, you merge.
Practical
- Remove
--remotefrom CI. Use the pinned SHA:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
submodules: recursive
persist-credentials: false
- Let Dependabot or Renovate propose submodule bumps:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "gitsubmodule"
directory: "/"
schedule:
interval: "weekly"
- In Go projects, regenerate and compare vendored code in CI:
go mod vendor
git diff --exit-code vendor/
- Record upstream name, version and source URL for every vendored library, so SBOM and SCA tools can see it.
- Keep Git patched everywhere, and avoid
--recurse-submoduleswhen cloning untrusted repositories. - Add CODEOWNERS for
.gitmodules,third_party/andvendor/.
Tools/Techniques
- Dependabot
gitsubmoduleecosystem. - Renovate's
git-submodulesmanager (disabled by default, enable it explicitly). - Git submodules documentation.
Metrics/Signal
- CI jobs using
--remote: zero. - Submodule updates merged through reviewed PRs.
Limit
Explanation
Code from a submodule runs with whatever the build job has. Keep cloud credentials and publish tokens out of jobs that build and test third-party code.
Insight
This attack only paid off because cloud keys were in the test job's environment.
Practical
- Give test jobs
permissions: contents: readand no cloud credentials. - Use OIDC with tightly scoped roles in deploy jobs only.
- Restrict egress from build jobs to the package registries and mirrors you need.
Tools/Techniques
- GitHub environments for deploy credentials.
- StepSecurity Harden-Runner with an egress allow-list.
Metrics/Signal
- Test jobs with access to cloud or registry credentials.
- Blocked egress attempts from build jobs.
Expose
Explanation
Detect when the code you build differs from the code you reviewed. A submodule checked out at a commit other than its gitlink is a clear signal.
Insight
git submodule status marks a mismatch with a leading +. That one character is your drift alarm.
Practical
- Fail the build if any submodule is not at its recorded commit:
if git submodule status --recursive | grep -q '^[-+U]'; then
echo "Submodule drift detected" >&2
git submodule status --recursive >&2
exit 1
fi
- Alert on PRs that change
.gitmodulesURLs or add new submodules. - Monitor CI egress for destinations outside the allow-list.
Tools/Techniques
- The drift check above as a required status check.
- Harden-Runner egress logs.
- OSV-Scanner on a schedule, so new advisories against vendored code surface.
Metrics/Signal
- Builds failed by the drift check.
- New submodule URLs added per month.
Exercise
Explanation
Simulate a compromised upstream with local repositories only. Nothing touches a real project or registry.
Insight
Seeing --remote pull an unreviewed commit into a build makes the risk concrete for the whole team.
Practical
- Create a local bare repository to act as the "upstream" library and add it as a submodule of a sandbox project.
- Run your CI script locally and note the submodule SHA.
- Push a harmless marker commit to the upstream's
mainthat writesDRIFT-MARKERto a file during tests. - Run the CI script again. If the marker appears, your pipeline floats.
- Apply the Fortify changes and confirm the drift check fails instead.
Tools/Techniques
git init --barefor the fake upstream.- act or your CI system's local runner to run the pipeline.
Metrics/Signal
- Whether the marker reached the build before and after the fix.
- Whether the drift check failed the build.