A Scanner Has Three Ways to End, Not Two
Most CLI tools give you two exit codes. Zero, everything's fine. One, something broke. That binary is usually enough. For a compiler, 1 means stop, fix the code, try again.
It isn't enough for a scanner sitting in CI, deciding whether a build should fail. There are two genuinely different reasons a scan step can come back unhappy, and if your pipeline can't tell them apart, it ends up treating both the same way, which is wrong for at least one of them.
Two failures, not one
Say bomwerk scan runs on a firmware repo and finds a .a archive it genuinely can't identify: stripped, no symbols, no version string anywhere. Nothing's broken there. The scanner is doing exactly what it should: telling you honestly that it doesn't know. You probably want the build to keep going, with a warning surfaced somewhere a human will actually see it.
Now say the same command hits a filesystem permission error halfway through, or the path you gave it doesn't exist. There's no finding to report here. The tool simply didn't run. You want that one to fail loud and immediately, before anyone reads the SBOM and mistakes an empty result for a clean one.
Collapse both into exit code 1 and your CI script can't tell "we found something worth a human's attention" from "the scan never happened." Teams that go looking for that difference end up parsing log text, which breaks the next time someone rewords a message.
So bomwerk uses three:
0 clean run, nothing flagged
1 ran to completion, found something (unknown component, missing manifest, low-confidence match)
2 did not complete (bad args, unreadable path, internal error)
A pipeline can gate on exit code alone:
$ bomwerk scan . -o sbom.json
$ echo $?
1
$ if [ $? -eq 2 ]; then
echo "scan didn't run, fix the invocation" >&2
exit 1
elif [ $? -eq 1 ]; then
echo "scan ran, flagged something, see sbom.json" >&2
fi
Code 1 is a finding to route somewhere. Code 2 is a tool problem to fix. Different owners, different urgency, and now the pipeline can express that without touching stdout.
The part that makes the contract honest
None of this works if a single bad input can take the whole process down. If one malformed archive out of four hundred in a repo throws an unhandled exception, you get a stack trace and zero output: no SBOM at all, clean components and broken one alike, all withheld because of a single file.
Every component producer inside bomwerk, one per package ecosystem or detection method, implements the same signature: Result<std::vector<Component>> parse(const std::filesystem::path& root). Nothing throws across that boundary. A parser that can't make sense of an input doesn't crash the run. It logs a warning at that point and returns what it did find, and the run moves on to the next file.
$ bomwerk scan ./firmware
Scanned 412 files, 6 ecosystems.
WARN vendor/mystery-codec/: no manifest, no recognisable version markers
WARN build/lib/libimaging.a: 2 archive members with no symbol table
118 components identified, 2 flagged unknown.
Exit code: 1
You get 118 real components, two honest question marks, and a nonzero exit code telling CI to make sure someone looks at those two. That's a materially different outcome from a crash that gives you nothing.
Why we didn't stop at "warnings in the log"
We could have kept exit codes binary and pushed all of this into log text instead. Some tools do exactly that. It works until someone builds automation on top of the tool, at which point log-scraping is what you're left with, and log formats drift in ways exit codes don't.
An exit code is the smallest interface a scanner can offer a machine. It survives a wrapper script rewrite. It survives someone piping stdout through jq and dropping the human-readable lines entirely. Keeping the machine-facing signal down to a three-value integer, and pushing everything else into structured output, is the boring choice, and boring is what you want from something a release pipeline depends on.
Where this doesn't help you
Three exit codes tell you that something needs attention. They don't tell you what. For that you still read sbom.json or the warnings block. The exit code is a routing signal, not a report.
And it's worth saying plainly: an unknown component isn't a security finding by itself. It's a gap in identification. Some of those gaps close with a better heuristic next release; some stay unknown because the archive genuinely carries no recoverable evidence. We report the difference as confidence levels rather than pretending every component is equally certain.
None of this is dramatic. It's a scanner returning the right number instead of the wrong one, and that's most of what makes a tool safe to depend on in someone else's pipeline.
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