Detecting submodule URL tampering in pull requests Jump to heading

Most reviewers look at a submodule change and see a hash moving from one value to another. They rarely look at .gitmodules, and when they do, a URL that changed from github.com/acme/crypto-lib to github.com/acrne/crypto-lib reads as unchanged. That single edit redirects every future clone to a different repository, which can contain anything, under a commit hash the attacker chose. Because the pointer and the URL change together, CI happily clones the new source and the tests pass. This is a cheap, effective supply-chain attack, and it is cheap to defend against. This page builds that defence, within submodule and dependency integrity.

When to use this approach Jump to heading

  • Your repository has submodules, and outside contributors or many internal teams open pull requests.
  • .gitmodules changes are not routinely flagged for special review today.
  • Submodules point at security-sensitive code: cryptography, authentication, build tooling.
  • You already check that pointers are fetchable, as in verifying submodule commits are reachable upstream, and want to check where they are fetched from.

Step 1 — Understand what an attacker changes Jump to heading

Two edits are enough: the URL in .gitmodules, and the gitlink hash. The new hash only needs to exist in the attacker’s repository. Variants include changing the URL to a lookalike domain, an http:// URL that can be intercepted, a file:// path, or adding an entirely new submodule.

A benign submodule update against a tampered oneA benign update changes only the gitlink hash to a newer commit in the same repository. A tampered update also changes the URL in .gitmodules, often to a lookalike name, so the new hash is fetched from a repository the attacker controls.Benign bumpTamperedgitlink hashchangeschanges.gitmodules urlunchangedacme → acrnesource of the codesame repositoryattacker's forkdiff noiseone linetwo linesthe URL line is the whole attack, and it is easy to read past
# Show exactly what changed in submodule configuration in a PR
git diff "$BASE" "$HEAD" -- .gitmodules
git diff "$BASE" "$HEAD" --raw | awk '$2=="160000" || $1==":160000"'

Step 2 — Fail CI on any URL change not on the allow-list Jump to heading

Keep an allow-list of submodule URLs in a file the pull request cannot change — read it from the base branch. Fail when any submodule URL in the head is not on that list.

#!/bin/sh
# ci/check-submodule-urls.sh <base> <head>
set -eu
base=$1 head=$2
git show "$base:.github/allowed-submodule-urls" > /tmp/allowed-urls
git show "$head:.gitmodules" > /tmp/gitmodules.head 2>/dev/null || exit 0
fail=0
git config -f /tmp/gitmodules.head --get-regexp '^submodule\..*\.url$' |
while read -r key url; do
  case "$url" in
    https://*) ;;
    *) echo "REJECT $key: non-https URL $url"; fail=1; continue ;;
  esac
  grep -qxF "$url" /tmp/allowed-urls || { echo "REJECT $key: $url not on allow-list"; fail=1; }
done
exit $fail

Compare exact strings, after normalising trailing .git and slashes if your team is inconsistent about them. Fuzzy matching is exactly what a lookalike domain exploits.

How the URL check decidesThe job reads the allow-list from the base branch, reads the submodule URLs from the pull request head, rejects any URL that is not HTTPS, rejects any URL that is not an exact match on the allow-list, and otherwise passes.Allow-listfrom base branchHead URLs.gitmodulesSchemehttps onlyExact matchno fuzzy comparePass / failname each URLthe allow-list must come from the base branch, or the PR can approve itself

Step 3 — Require owner review for submodule configuration Jump to heading

CI catches unapproved URLs; a human should still approve every change to the allow-list and to .gitmodules. Route both through code owners.

# CODEOWNERS
/.gitmodules                         @acme/supply-chain
/.github/allowed-submodule-urls      @acme/supply-chain

With code-owner review required on the protected branch, adding a URL to the allow-list becomes a deliberate act by a small group, separate from the pull request that uses it. The mechanics are in enforcing CODEOWNERS review on sensitive paths.

# Verification: the rule applies to .gitmodules
gh api "repos/$OWNER/$REPO/codeowners/errors" --jq '.errors | length'   # expect 0

Step 4 — Protect developers from tampered URLs locally Jump to heading

A developer who checks out a malicious branch and runs git submodule update fetches from the attacker’s URL before CI has said anything. Two settings reduce that exposure: restrict the protocols Git may use, and disable automatic recursion into submodules on fetch.

git config --global protocol.allow never
git config --global protocol.https.allow always
git config --global protocol.ssh.allow always
git config --global protocol.file.allow user        # only when you type it, never from .gitmodules
git config --global fetch.recurseSubmodules on-demand

protocol.file.allow user blocks file:// submodule URLs from being followed automatically, which closes a class of local-path tricks. Recent Git versions default to this for the file protocol; setting it explicitly documents the intent.

Step 5 — Watch for URL rewrites that bypass the check Jump to heading

.gitmodules is not the only place a URL comes from. A local url.<base>.insteadOf rule, or a submodule.<name>.url in .git/config, overrides it on one machine. CI runners should start from clean configuration so their clone matches what the allow-list approved.

# On the runner, before cloning: show any rewrites that would apply
git config --show-origin --get-regexp '^url\..*\.insteadof$' || echo "no rewrites"
git config --show-origin --get-regexp '^submodule\.' || echo "no local submodule overrides"
Defences against a swapped submodule sourceThe CI allow-list stops unapproved URLs merging. Code-owner review puts a small group in charge of the allow-list. Protocol restrictions stop developers fetching from file or plain HTTP URLs. Clean runner configuration ensures CI fetches from the URL that was approved.four places the swap can be stoppedCI allow-list checkexact HTTPS URLs onlyCode-owner review.gitmodules + allow-listProtocol restrictionsno file:// or http:// by defaultClean runner configno insteadOf surprisesnone of these depend on a reviewer spotting acrne instead of acme

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Does this also catch a newly added submodule? Jump to heading

Yes. A new submodule adds a URL to .gitmodules, which the check compares against the allow-list like any other. Adding a dependency then requires the supply-chain owners to approve its source.

Can an attacker change the URL without touching .gitmodules? Jump to heading

Not for other people’s clones; .gitmodules is the only shared source of submodule URLs. Local overrides affect only the machine they are set on, which is why runner configuration should be clean.

Should we also check the pointed-to commit is signed? Jump to heading

If the submodule’s upstream signs commits and you have their keys, yes — it adds assurance that the commit came from the expected maintainers, not just the expected URL. Most third-party projects do not, which is one reason to vendor dependencies with git subtree and review them as your own code.