
Supply Chain
Syft SBOM Blind Spots: Catalogers, Unknowns and Grype
By default, a Syft SBOM of an image skips package-lock.json and Cargo.lock, lists binaries it cannot name as unknowns, and carries no Go symbols for Grype.
Most Syft pipelines are one command: point it at an image, write CycloneDX, file the result. The document validates. What it cannot show is what Syft never looked for, which is the part an auditor, or a scanner reading the file years later, depends on.
Which of Your Images Does Syft Actually See?
Three things decide how complete a Syft SBOM is, and the operator controls all three. The source type decides which catalogers run: image and directory scans select different sets, by design. The build decides what a cataloger can read: a binary with embedded dependency metadata can be named, one without it becomes an unknown. And the SBOM's configuration decides what evidence a later scan gets, which matters most for Go.
Everything below is read from Syft v1.51.1 and Grype v0.118.0, both published on 2026-08-27 and still the latest releases on 2026-09-14. Grype v0.118.0 builds against Syft v1.51.1 as a Go module dependency. Parts of this surface are recent — Grype's Go symbol qualifier merged on 2026-07-15, and Syft first shipped Go symbol capture in v1.48.0 — so read every default here as true at those tags and re-check when you move the pin.
An Image Scan and a Directory Scan Run Different Catalogers
In v1.51.1, findDefaultTags in create_sbom_config.go maps an image source to the image tag and a file or directory source to the directory tag, and the default set is whatever carries that tag. Anchore's cataloger guide states the intent: for an image, "Syft assumes installation steps have completed"; for a directory, "Syft looks for both what's installed and what's declared as a dependency".
The tag declarations in internal/task/package_tasks.go make that concrete:
- JavaScript:
javascript-package-catalogeris taggedinstalledandimage;javascript-lock-catalogeris taggeddeclaredanddirectory. An image scan runs the installed-package cataloger and skips the lockfile; a directory scan does the reverse. - Rust:
rust-cargo-lock-catalogeris taggeddeclaredanddirectoryonly.cargo-auditable-binary-catalogeris taggeddirectory,installedandimage, so it runs on image scans by default. - Go:
go-module-file-catalogeris taggeddeclaredanddirectoryonly;go-module-binary-catalogercarriesdirectory,installedandimage.
That split is a trade. The same guide warns that "most of the time, files that hint at the intent to install software do not have enough information in them to determine the exact version of the package that would be installed." An image scan that ignores lockfiles is declining to guess. The gap opens when a dependency reaches the image only in a form image-tagged catalogers cannot read: a Rust binary built without cargo-auditable, say, beside a Cargo.lock an image scan never opens. The SBOM then has no entry for those crates; at best, the binary that contains them shows up as an unknown.
--select-catalogers +<name> adds a cataloger to the defaults, -<name-or-tag> removes catalogers by name or tag, --override-default-catalogers replaces the defaults entirely, and syft cataloger list shows every cataloger with its tags and tests selection expressions. Adding +javascript-lock-cataloger to an image scan is legitimate, but those entries then assert declared intent, with the version caveat above. Rather than reason from tags, diff the two scans; the syft-json descriptor records the catalogers requested and used.
#!/usr/bin/env bash
# coverage-diff.sh: what an image scan and a directory scan of one service
# each catalog. Cataloger tags were checked at Syft v1.51.1.
# usage: coverage-diff.sh <image-ref> <source-dir> [out-dir]
set -euo pipefail
IMAGE="${1:?usage: coverage-diff.sh <image-ref> <source-dir> [out-dir]}"
SRC="${2:?missing source directory}"
OUT="${3:-./coverage}"
for bin in syft jq comm; do
command -v "$bin" >/dev/null || { echo "missing: $bin" >&2; exit 127; }
done
[[ -d "$SRC" ]] || { echo "not a directory: $SRC" >&2; exit 64; }
mkdir -p "$OUT"
syft scan "$IMAGE" -q -o "syft-json=$OUT/image.syft.json"
syft scan "dir:$SRC" -q -o "syft-json=$OUT/dir.syft.json"
used() { jq -r '.descriptor.configuration.catalogers.used[]' "$1" | sort -u; }
echo "== ran for the image only"
comm -23 <(used "$OUT/image.syft.json") <(used "$OUT/dir.syft.json")
echo "== ran for the directory only"
comm -13 <(used "$OUT/image.syft.json") <(used "$OUT/dir.syft.json")
for side in image dir; do
echo "== $side: packages per cataloger, then unknowns"
jq -r '[.artifacts[].foundBy] | group_by(.) | .[] | "\(length)\t\(.[0])"' \
"$OUT/$side.syft.json"
jq -r '.files[]? | select((.unknowns // []) | length > 0)
| "\(.location.path)\t\(.unknowns | join("; "))"' "$OUT/$side.syft.json"
doneUnknowns: The List of What Syft Could Not Name
Beyond what it found, Syft records what it could not account for, and that list is the closest thing it offers to a per-image coverage metric.
The configuration key unknowns.executables-without-packages is on by default (environment variable SYFT_UNKNOWNS_EXECUTABLES_WITHOUT_PACKAGES). Wherever no package references an executable, the labeler behind it records no package identified in executable file against it. In syft-json output that lands under files[].unknowns, prefixed with the task name: unknowns-labeler: no package identified in executable file. Typical residents: a C binary with no package note, a Rust binary built without cargo-auditable, a vendor tool copied in by hand.
Treat a new unknown as a binary no SBOM describes, and gate on it. A reviewed-paths file makes each accepted exception a line in version control:
#!/usr/bin/env bash
# unknowns-gate.sh: fail CI when a syft-json SBOM records files Syft could not
# account for, minus paths a human has already reviewed.
# usage: unknowns-gate.sh <sbom.syft.json> [reviewed-paths.txt]
set -euo pipefail
SBOM="${1:?usage: unknowns-gate.sh <sbom.syft.json> [reviewed-paths.txt]}"
REVIEWED="${2:-/dev/null}"
command -v jq >/dev/null || { echo "missing: jq" >&2; exit 127; }
jq -e '.descriptor.name == "syft"' "$SBOM" >/dev/null ||
{ echo "not a syft-json document: $SBOM" >&2; exit 64; }
# One path per line in the reviewed file; blank lines and # comments ignored.
report="$(jq -r --rawfile reviewed "$REVIEWED" '
($reviewed | split("\n") | map(select(length > 0 and (startswith("#") | not))))
as $ok
| .files[]?
| select((.unknowns // []) | length > 0)
| select(.location.path | IN($ok[]) | not)
| .location.path as $p
| .unknowns[]
| "\($p)\t\(.)"' "$SBOM")"
if [[ -z "$report" ]]; then
echo "unknowns: none unreviewed"
exit 0
fi
echo "unknowns by reason:"
cut -f2 <<<"$report" | sort | uniq -c | sort -rn
echo
echo "unreviewed paths:"
printf '%s\n' "$report"
exit 1Keep the syft-json document as the record for this. Syft's format-conversion notes call conversion experimental and say packages transfer easily, while "files and relationships, as well as other information Syft doesn't support, are more likely to be lost." Whether a given SPDX or CycloneDX export carries unknowns is something to check, not assume: grep -c 'no package identified in executable file' sbom.cdx.json against a file where the syft-json record has unknowns answers it for your version.
Binaries: Buildinfo, cargo-auditable, .note.package, Then Guesswork
Syft can only name an executable from evidence inside it, and for binaries you build, that evidence is a build-time decision.
Go is the strongest by default. The debug/buildinfo package reads what a Go binary embeds about how it was built — "the Go toolchain version, and the set of modules used (for binaries built in module mode)" — and that is what go-module-binary-cataloger catalogs. Since Go 1.24, go build also sets the main module's version from the VCS tag or commit, appending +dirty for uncommitted changes; -buildvcs=false omits that information. Check size flags too: the linker's -s omits the symbol table and debug information and implies -w. Verify what a stripped build keeps with go version -m on the shipped artefact.
Rust carries nothing equivalent unless asked. cargo-auditable works "by embedding data about the dependency tree in JSON format into a dedicated linker section of the compiled executable" when you build with cargo auditable build --release, and cargo-auditable-binary-cataloger reads that section on image scans by default. Without it, that cataloger has nothing to read, and the Cargo.lock cataloger does not run on an image at all.
C and C++ binaries you build can carry a package note. The UAPI group's Package Metadata for Executable Files specification stores a JSON payload in an ELF note — section .note.package, note type 0xcafe1a7e, owner FDO — and GNU Binutils 2.39, announced on 5 August 2022, gave the ELF linker a --package-metadata option that embeds it. Syft's elf-binary-package-cataloger parses that payload. Two traps. The obvious -Wl,--package-metadata=... breaks, because -Wl splits its option at commas; -Xlinker passes it intact. And a TODO in Syft's cataloger says it accounts for a single data shape from the note, so treat the note as the fix for binaries you build, not a promise about everyone else's.
Below the evidence rungs sits pattern matching. binary-classifier-cataloger carries classifiers for specific well-known binaries — nginx, OpenSSL, Redis and curl among them — and binaries, per Anchore's ecosystem guide, fall back to CPE matching against the NVD, which "may produce false positives". Below that is nothing, and nothing is an unknowns entry.
#!/usr/bin/env bash
# build-evidence.sh: give each binary you build something Syft can read.
# Each block skips itself when its toolchain is missing.
set -euo pipefail
OUT="${OUT:-$PWD/dist}"
mkdir -p "$OUT"
# C and C++: a UAPI .note.package note, written by GNU ld 2.39 or later.
if command -v gcc >/dev/null && command -v readelf >/dev/null; then
meta='{"type":"deb","os":"debian","osVersion":"12","name":"edge-agent","version":"2.3.1","architecture":"amd64"}'
printf 'int main(void) { return 0; }\n' >"$OUT/edge-agent.c"
# -Wl,--package-metadata=... would be split at every comma in the JSON.
gcc -O2 -o "$OUT/edge-agent" "$OUT/edge-agent.c" \
-Xlinker "--package-metadata=$meta"
readelf -n "$OUT/edge-agent" | grep 'Packaging Metadata'
fi
# Rust: the dependency tree, embedded as JSON in a linker section.
if [[ -f Cargo.toml ]] && cargo auditable --version >/dev/null 2>&1; then
cargo auditable build --release
fi
# Go: buildinfo is embedded by default; confirm the main module version.
if [[ -f go.mod ]] && command -v go >/dev/null; then
go build -trimpath -o "$OUT/service" .
go version -m "$OUT/service"
fiGrype Against an SBOM Is Not Grype Against the Image
The common pattern is to generate the SBOM once, attest it, and re-scan the stored document as advisories arrive; the supply-chain pipeline article builds that loop around cosign attestations. For Go binaries, that re-scan and a direct image scan can disagree, because of a default.
Given an image or directory, Grype "invokes Syft internally to generate an SBOM, then immediately matches it against vulnerabilities." Its root command configures that embedded run with WithCaptureSymbols(cataloging.SymbolScopeAll), and the comment explains why: capture Go function symbols "so the gosymbols qualifier can suppress false positives for module- and stdlib-scoped govulndb advisories (e.g. a net/http server DoS matching any binary that merely links net/http)." Syft on its own does the opposite. Its Go cataloger setting golang.capture-symbols extracts function symbols from the binary's pclntab, with scopes none, stdlib, extended-stdlib and all, and at v1.51.1 it defaults to none.
The qualifier, added in Grype PR #3509, is explicit about SBOMs without that evidence: "packages without symbol evidence always satisfy the qualifier so that module-granularity matching behavior is preserved." So an SBOM from a default Syft run, scanned later with grype sbom:, gets module-granularity Go matching, while grype <image> with the same database filters those advisories by symbol.
Grype flags it. v0.118.0 logs a warning that begins go binary packages were found but none carry function symbols, continues that Go matching "falls back to module granularity and may report false positives", and advises regenerating an SBOM "with symbol capture enabled for more precise results." In a scheduled re-scan, that line means the archived SBOMs are the less precise path. The fix is configuration:
# .syft.yaml: configuration for the SBOM you archive as evidence.
# Keys checked against Syft v1.51.1.
scope: squashed
# Written out, not inherited: an upstream default change then shows up in review.
unknowns:
remove-when-packages-defined: true
executables-without-packages: true
unexpanded-archives: true
golang:
# Syft's default is "none". Grype sets "all" for its own image scans,
# so match it: a later `grype sbom:` then sees the same symbol evidence.
capture-symbols: all
# Deliberately empty. Adding +javascript-lock-cataloger to an image scan
# reports what package-lock.json declares, not what was installed. Only
# add it for an image that ships a lockfile and no node_modules tree.
select-catalogers: []Two limits keep this honest. Symbol evidence is not reachability analysis: the qualifier asks whether a package "plausibly uses" a vulnerable symbol, so a linked function counts whether or not anything calls it. And Grype's symbol normaliser documents a gap for nested generic instantiations, where "the consequence is a missed match for that symbol, not a false positive." Capture symbols so the archived SBOM carries the evidence Grype's own scan uses, not because it makes Go findings exhaustive.
Running the Pair Offline: Database, Gate and Exit Code
On a disconnected runner, the database arrives through a channel you control and nothing reaches out on its own. Grype's database is schema v6, produced by a daily publishing workflow, and grype db import takes an archive from a local file or URL, so the archive can travel the same way air-gapped package mirrors do. Set db.auto-update to false (GRYPE_DB_AUTO_UPDATE=false) so the runner never tries, and scan images by reference from a registry you run.
Database age is the trap. Anchore's database guide says Grype "will automatically fail scans if the vulnerability database is more than 5 days old". The threshold is db.max-allowed-built-age, default 120h, and db.validate-age: false disables the check. Leave it on: a gate passing against a month-old database proves only that the image was clean a month ago. Raise the threshold to match how often the mirror refreshes, and alert when an import lags.
Then the exit code. At v0.118.0 the --fail-on flag help reads "set the return code to 2 if a vulnerability is found with a severity >= the given severity". Pin the gate to that behaviour at your version and treat any other non-zero exit as a tool failure: a crashed scanner must never read as a passing image. The script is runner-agnostic; in a Forgejo and Woodpecker pipeline it is one step after the image build.
#!/usr/bin/env bash
# offline-gate.sh: SBOM record plus vulnerability gate on a runner with no egress.
# usage: offline-gate.sh <image-ref> <grype-db-archive> [out-dir]
set -euo pipefail
IMAGE="${1:?usage: offline-gate.sh <image-ref> <grype-db-archive> [out-dir]}"
DB_ARCHIVE="${2:?missing grype database archive}"
OUT="${3:-./sbom}"
for bin in syft grype; do
command -v "$bin" >/dev/null || { echo "missing: $bin" >&2; exit 127; }
done
export GRYPE_DB_AUTO_UPDATE=false
export GRYPE_DB_CACHE_DIR="${GRYPE_DB_CACHE_DIR:-$PWD/.grype-db}"
export SYFT_GOLANG_CAPTURE_SYMBOLS=all # same scope Grype's own scan uses
mkdir -p "$OUT"
grype db import "$DB_ARCHIVE"
grype db status
# One catalog run, three encodings: syft-json is the record.
syft scan "$IMAGE" -q \
-o "syft-json=$OUT/sbom.syft.json" \
-o "spdx-json=$OUT/sbom.spdx.json" \
-o "cyclonedx-json=$OUT/sbom.cdx.json"
rc=0
grype "sbom:$OUT/sbom.syft.json" --fail-on high \
-o json --file "$OUT/grype.json" || rc=$?
case "$rc" in
0) echo "gate: pass" ;;
2) echo "gate: vulnerability at or above high, see $OUT/grype.json" >&2; exit 2 ;;
*) echo "gate: grype exited $rc; tool error, not a verdict" >&2; exit "$rc" ;;
esacFailure Modes and the Evidence an Auditor Can Use
Each of these produces a valid SBOM and a clean-looking scan:
- Declared versus installed. A directory scan reports the lockfile; the image runs what was installed. Keep both SBOMs, labelled by source type.
- Unknowns discarded. An SPDX or CycloneDX export kept as the only record, never checked for the unknowns. Keep the syft-json document.
- Symbol-less Go SBOMs. Archived with
capture-symbolsatnoneand re-scanned at module granularity. The Grype warning is the tell. - CPE matches taken as findings. Classifier-identified binaries matched through CPE against the NVD. Triage them as candidates.
- A stale database. Age validation switched off, or a mirror that quietly stopped refreshing.
- The wrong scope for the question. Syft's default layer scope is
squashed, withall-layersanddeep-squashedas the alternatives. For questions about earlier layers, choose one deliberately and record it.
Per image, retain: the syft-json record, whose descriptor names the Syft version, the catalogers used and the configuration; the SPDX and CycloneDX exports; the unknowns report with the reviewed-paths file behind every exception; the Grype JSON result; and grype db status output for the database it ran against. That set answers what an auditor asks — what was scanned, with what, and what could not be seen — and is the evidence trail the Cyber Resilience Act article argues belongs in the build pipeline.
Exit Ramps: Formats, Conversion and a Second Scanner
Keeping syft-json as the record does not tie you to Anchore. Syft writes SPDX and CycloneDX in the same run, as the gate does, so standard encodings exist from the start rather than by conversion — the Syft CLI documents syft convert as "[Experimental] Convert SBOM files to, and from, SPDX, CycloneDX and Syft's format". A second scanner reads the exports. Grype reads other tools' SBOMs too, and --add-cpes-if-none generates CPEs for packages that arrive without them, for example from a third-party SPDX document, with the CPE false-positive caveat attached. The costly thing to replace is not the scanner but evidence never recorded. Record unknowns, catalogers and symbols now, and changing tools stays a configuration change.
The Long Game: Coverage as a Build Property
Every default above carries a version, and the Go symbol behaviour dates only from July 2026. What will not move is where coverage comes from: a scanner can only report evidence the build left behind. So make completeness a property of the build — Go binaries stamped with their release version, Rust built with cargo-auditable, your own C binaries carrying a package note, and an unknowns count per image driven toward zero with each exception reviewed in the open. Base images you build yourself are the natural place to reach zero first. Then the SBOM archived this year is still evidence when a scanner nobody has chosen yet reads it in five.
§FAQ/Common questions
Frequently asked
What is a Syft SBOM?
It is a software bill of materials generated by Anchore's open-source Syft, listing the packages Syft catalogs from an image, directory or file, with the cataloger that found each one. Syft writes its own syft-json format as well as SPDX and CycloneDX. How complete it is depends on the catalogers selected for the source type and on the build metadata inside your binaries; executables Syft cannot name are recorded as unknowns.
Why does Syft find different packages in an image than in the source directory?
Because it selects different catalogers. In Syft v1.51.1 an image source selects catalogers tagged image and a directory source selects those tagged directory. Lockfile and manifest catalogers such as javascript-lock-cataloger, rust-cargo-lock-cataloger and go-module-file-cataloger are directory-only, while javascript-package-cataloger is image-only. Binary catalogers for Go, cargo-auditable and ELF package notes run on both. The --select-catalogers and --override-default-catalogers flags change the set.
What does 'no package identified in executable file' mean in a Syft SBOM?
It is an unknowns entry. With unknowns.executables-without-packages enabled, which is the default, Syft's unknowns labeler records that message against every executable no package references. In syft-json it appears under files[].unknowns, prefixed with unknowns-labeler. Typical causes are C binaries without a .note.package note, Rust binaries built without cargo-auditable, and vendor binaries copied into an image.
Why does Grype report different Go vulnerabilities for an SBOM than for the image?
Grype's own image scan runs Syft with Go symbol capture set to all, so its gosymbols qualifier can suppress module- and stdlib-scoped advisories when no vulnerable symbol is present. A standalone Syft run defaults golang.capture-symbols to none, and packages without symbol evidence always satisfy the qualifier, so scanning that SBOM falls back to module granularity. Grype v0.118.0 logs a warning when this happens. Regenerate the SBOM with capture-symbols set to all.
What exit code does grype --fail-on return?
In Grype v0.118.0 the --fail-on flag sets the return code to 2 when a vulnerability at or above the given severity is found. Gate on 2 as a policy failure and treat any other non-zero exit as a tool error, so a crashed scan never passes as a clean one. Grype also fails scans when its database is more than five days old unless the age check is changed.
Further reading
- Supply Chain Security: SBOM, Sigstore and Admission Control
- Distroless Without a Vendor: Building Your Own Base Images
- Own the Registry: Harbor and Zot for Air-Gapped Images
- The Cyber Resilience Act Reaches Your Build Pipeline
- Forgejo, Woodpecker and Zot: CI Off GitHub Actions
- Air-Gapped Patching with Katello: Mirrors and Evidence
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.