Grype and Syft
SBOM generation with Syft and vulnerability matching with Grype: catalogers, source schemes, match quality, and CI gating.
Grype and Syft
SBOM generation with Syft and vulnerability matching with Grype: catalogers, source schemes, match quality, and CI gating.
Overview
Syft and Grype are two halves of one job, deliberately kept apart. Syft catalogues — it walks an image, directory, or archive and produces an inventory of packages with versions, licences, file locations, and package URLs. Grype matches — it takes that inventory and joins it against a vulnerability database. Neither tool does the other's work, and the seam between them is a file you can store, sign, and re-scan.
That seam is the point. A scanner that rebuilds its own inventory on every run can only ever tell you about the image in front of it right now. An SBOM lets you answer the question that actually matters six months after a release: is the thing we shipped affected by this new CVE? You cannot rebuild that image faithfully — base tags have moved, mirrors have rotated, the builder has a different Go toolchain — but you still have the SBOM.
Both tools are single static Go binaries with no runtime dependencies, no daemon, and no server component. That is their defining trade-off against Clair (a service) and, to a lesser degree, Trivy (a binary that wants to be a whole platform).
flowchart LR
subgraph Sources["Scan sources"]
IMG["Container image"]
DIR["Directory / repo"]
ARC["Archive (OCI, docker save)"]
end
IMG --> SYFT[Syft]
DIR --> SYFT
ARC --> SYFT
SYFT --> SBOM["SBOM (syft-json, CycloneDX, SPDX)"]
SBOM --> STORE["Release artefact / attestation"]
SBOM --> GRYPE[Grype]
STORE -.->|"re-scan months later"| GRYPE
DB[("Vulnerability DB v6")] --> GRYPE
GRYPE --> OUT["Matches: table, JSON, SARIF, CycloneDX"]
GRYPE --> GATE{"--fail-on met?"}
GATE -->|yes| FAIL["exit 2"]
GATE -->|no| PASS["exit 0"]
Versions in this sheet
Everything below was run against Syft 1.51.1 (SBOM schema 16.1.10) and Grype 0.118.0 (DB schema v6.1.9, built 2026-09-05) on Debian 13. Captured output is real. Grype's DB schema moved to v6 in 2025 and the listing URL, cache layout, and grype db subcommands all changed with it — anything you find online referencing toolbox-data.anchore.io or a listing.json is describing v5.
# Both install as a single binary; the installer script needs no root if -b is writable
curl -sSfL https://get.anchore.io/syft | sh -s -- -b /usr/local/bin
curl -sSfL https://get.anchore.io/grype | sh -s -- -b /usr/local/bin
syft version # Version: 1.51.1 / SchemaVersion: 16.1.10
grype version # Version: 0.118.0 / Supported DB Schema: 6
The upstream READMEs are now thin landing pages. The reference material lives at
oss.anchore.com/docs/— CLI pages, configuration pages, and the Syft JSON schema.
Syft: Source Schemes
Syft infers the source type from a bare argument, which is convenient and occasionally wrong. Be explicit in anything scripted.
# Explicit schemes — the prefix form
syft image:alpine:3.22 # generic image resolution
syft docker:myrepo/app:1.2 # via the Docker daemon
syft podman:myrepo/app:1.2 # via the Podman API socket
syft registry:myrepo/app:1.2 # straight from the registry, no runtime
syft docker-archive:app.tar # output of `docker save`
syft oci-archive:app.tar # output of `skopeo copy` / `podman save --format oci-archive`
syft oci-dir:./layout # OCI layout directory
syft singularity:app.sif # SIF container
syft dir:./my-project # a filesystem tree
syft file:./app.jar # a single file
# Equivalent modern form — --from takes the same values
syft --from registry myrepo/app:1.2
oci-dir: is the current spelling (older material says oci-directory). dir: and registry: have been stable throughout.
Daemon versus registry — the distinction that matters
| Route | What it reads | Needs | Caveat |
|---|---|---|---|
docker: |
Image as materialised by the Docker daemon | Docker socket | Reads the local copy; a stale :latest scans stale |
podman: |
Same, via Podman's API socket | Podman socket, not just the CLI | See below |
registry: |
Manifest and layers pulled directly | Network + credentials | Always the current digest; no runtime needed |
podman: is the one that catches people. Having the podman CLI on PATH is not enough — Syft speaks the API, so the socket must be running:
$ syft podman:scopedemo:local
[0000] ERROR could not determine source: an error occurred attempting to resolve
'scopedemo:local': podman: podman not available: no host address
Fix it by starting the rootless socket (systemctl --user start podman.socket), or sidestep it entirely by exporting an archive:
podman save --format oci-archive -o app.tar localhost/app:local
syft oci-archive:app.tar
In CI, prefer registry:. It needs no daemon, no privileged socket mount, and it pins to what the registry actually holds.
Syft: Catalogers
A cataloger is a single-ecosystem package detector: apk-db-cataloger parses /lib/apk/db/installed, java-archive-cataloger opens JARs, javascript-lock-cataloger reads package-lock.json. Syft 1.51.1 ships 69 of them, each carrying a set of tags that drive selection.
syft cataloger list # table of catalogers and their tags
syft cataloger list -o json # machine-readable
syft cataloger list --show-hidden # include de-selected ones
The default set depends on the source type
This is the single most important thing to understand about Syft, and it is not obvious from the CLI:
# Captured with syft 1.51.1
syft cataloger list -o json | jq '.catalogers|length'
# 69 — every cataloger that exists
syft cataloger list -o json --override-default-catalogers image | jq '.catalogers|length'
# 37 — the default set for an image source
syft cataloger list -o json --override-default-catalogers directory | jq '.catalogers|length'
# 59 — the default set for a directory source
The split runs along an installed / declared axis:
- Image scans use the installed catalogers —
python-installed-package-cataloger,javascript-package-cataloger(readsnode_modules/**/package.json),ruby-installed-gemspec-cataloger,dpkg-db-cataloger. They read what is actually on disk. - Directory scans additionally use the declared catalogers —
javascript-lock-cataloger,python-package-cataloger,java-pom-cataloger,rust-cargo-lock-cataloger,terraform-lock-cataloger. They read manifests and lockfiles.
Consequence: scanning a source tree finds what the project declares; scanning an image finds what is installed. These are different questions and they give different answers.
Selecting catalogers
# Replace the base set entirely
syft dir:. --override-default-catalogers all
# Add to the default set (+), remove from it (-), or filter it (bare name/tag)
syft image:app:1.0 --select-catalogers +sbom-cataloger # 37 -> 38
syft image:app:1.0 --select-catalogers -binary # 37 -> 31
syft dir:. --select-catalogers python,javascript # only these ecosystems
+sbom-cataloger is worth knowing: it makes Syft ingest SBOMs it finds inside the target, which is how you pick up a vendored dependency's own SBOM.
Syft: Scope — the Layer Footgun
--scope controls which layers are catalogued. The default is squashed, meaning the filesystem as it appears at runtime. Most people assume a scan covers "the image", which is not the same thing.
Build an image that installs a package and then removes it:
FROM alpine:3.18
RUN apk add --no-cache curl
RUN apk del curl
# Captured against that image, syft 1.51.1
for s in squashed all-layers deep-squashed; do
echo -n "$s: "
syft oci-archive:app.tar -q --scope $s -o syft-json | jq '.artifacts|length'
done
# squashed: 15 packages
# all-layers: 23 packages
# deep-squashed: 15 packages
syft oci-archive:app.tar -q --scope all-layers -o syft-json \
| jq -r '[.artifacts[]|select(.name|test("curl|nghttp2|libidn2"))|.name]|join(", ")'
# curl, libcurl, libidn2, nghttp2-libs
Neither answer is wrong; they answer different questions.
| Scope | Sees | Use when |
|---|---|---|
squashed (default) |
Final filesystem only | You want what actually runs — the reachable attack surface |
all-layers |
Every layer, including deleted files | You want what is distributed — anyone with the image tarball can recover a deleted layer |
deep-squashed |
Squashed, with archive contents opened more aggressively | Nested archives matter (JARs in WARs, and similar) |
The security argument for all-layers: RUN apk del curl does not remove curl from the image. The layer that contains it still ships, and docker save plus tar -x gets it back. If your threat model includes a leaked build secret or a removed-but-still-present vulnerable binary, squashed will not show it.
The triage argument for squashed: a package in a discarded layer is not on PATH and cannot be invoked by the running container. Gating CI on it generates work nobody should do.
Pick deliberately, document the choice, and be aware that grype --scope has the same three options and the same default.
Syft: Output Formats
syft <src> -o syft-json # native, lossless
syft <src> -o cyclonedx-json # CycloneDX 1.7 JSON (also cyclonedx-xml)
syft <src> -o spdx-json # SPDX 2.3 JSON (also spdx-tag-value)
syft <src> -o spdx-json@2.2 # pin an older spec revision
syft <src> -o cyclonedx-json@1.5 # ditto for CycloneDX
syft <src> -o purls # newline-separated package URLs
syft <src> -o github-json # GitHub dependency-submission API
syft <src> -o syft-table # the default human view
syft <src> -o template -t x.tmpl # Go template
# Multiple outputs, each to its own file
syft <src> -o spdx-json=./spdx.json -o cyclonedx-json=./cdx.json -o syft-json=./syft.json
-o json is an accepted alias for syft-json, and -o cyclonedx / -o spdx alias the tag-value or XML variants. --file still works but is deprecated; use -o FORMAT=PATH.
Convert between formats without re-scanning:
syft convert sbom.syft.json -o cyclonedx-json=sbom.cdx.json
Where the lossy conversions actually bite
The folklore is that CycloneDX degrades vulnerability matching. Measured, that is false — and the truth is more interesting.
# alpine:3.18, syft 1.51.1 — round-trip syft-json -> cyclonedx-json -> syft-json
bytes artifacts relationships files
a.syft.json 107257 15 129 79
b.cdx.json 53280 15 — —
c.syft.json (rt) 29616 15 20 0
# Grype matches, same DB, all three inputs
grype sbom:./a.syft.json -> 14 matches
grype sbom:./b.cdx.json -> 14 matches
grype sbom:./c.syft.json -> 14 matches
On a larger target (node:18-bookworm-slim, 293 packages) the match count and the match-type breakdown are identical across syft-json, CycloneDX JSON, and SPDX JSON — 328 matches, 28 cpe-match, 61 exact-direct-match, 239 exact-indirect-match. Stripping every syft:* property out of the CycloneDX document to simulate a third-party generator still gives 328.
The reason is the package URL. Syft writes the distro into the purl qualifier, and that is all Grype needs to select a distro namespace:
pkg:deb/debian/apt@2.6.1?arch=amd64&distro=debian-12.11
So what is lost? The file inventory (79 files to 0) and most of the relationship graph (129 to 20). If you use the SBOM for licence compliance, provenance, or "which package owns this file", CycloneDX and SPDX are lossy and syft-json is not. If you use it purely for vulnerability matching, use whichever format your consumers want.
The genuinely lossy path is -o purls. A bare purl list drops packages that have no purl and loses the distro context that is not encoded per-line:
# node:18-bookworm-slim
syft-json : 293 packages -> 328 matches
purls : 282 lines -> 300 matches
Syft: Configuration, Exclusion, and Enrichment
Syft looks for a config file in this order and uses the first it finds:
.syft.yaml
.syft/config.yaml
~/.syft.yaml
~/.config/syft/config.yaml
/etc/xdg/syft/config.yaml
syft config locations prints exactly that list; syft config --load prints the merged, validated result. Grype has the identical scheme with .grype.yaml. Precedence overall: flags beat environment variables beat config file beat defaults, and -c FILE overrides the search entirely.
# .syft.yaml
scope: all-layers
output:
- syft-json
# Glob-excluded before cataloguing; relative to the scan root
exclude:
- "**/node_modules/**"
- "**/test/fixtures/**"
- "**/*.md"
# Package enrichment — network calls, off by default
# options: all, golang, java, javascript, python, vcpkg
enrich:
- golang
- java
# Exclusion, measured on a small source tree
syft dir:./srcproj -o syft-json | jq '.artifacts|length' # 4
syft dir:./srcproj --exclude '**/package-lock.json' -o syft-json | jq '.artifacts|length' # 2
--enrich makes network requests. It fetches licence and metadata from upstream ecosystems (proxy.golang.org, Maven Central, and so on) to fill gaps Syft cannot resolve locally. It is off by default for exactly that reason — turning it on in a build that is supposed to be hermetic will surprise you, and turning it on in CI adds a third-party dependency to your pipeline.
Attestation
syft attest produces an in-toto attestation with the SBOM as its predicate and pushes it to the registry alongside the image:
# Uses cosign's keyless flow by default; -k for a key file
syft attest --output cyclonedx-json registry:ghcr.io/org/app:1.2.3
This is the same mechanism cosign attest --predicate uses, and the verification side is cosign verify-attestation. Signing policy, keyless OIDC, and Rekor are out of scope here — see Container Image Optimisation for the cosign material and Container Registries for how attestations are stored as referring artefacts.
Grype: Scan Targets
Grype accepts everything Syft does — it embeds Syft for the cataloguing step — plus several inputs Syft has no use for:
grype registry:alpine:3.22 # image, straight from the registry
grype dir:./my-project # filesystem tree
grype sbom:./sbom.json # an SBOM on disk <- the important one
cat sbom.json | grype # an SBOM on stdin <- the other important one
syft <src> -o syft-json | grype # the canonical pipeline
grype purl:./purls.txt # newline-separated package URLs
grype 'pkg:apk/openssl@3.2.1?distro=alpine-3.20.3' # a single purl
grype cpes:./cpes.txt # newline-separated CPEs
grype 'cpe:2.3:a:openssl:openssl:3.0.14:*:*:*:*:*' # a single CPE
grype zarf:./package.tar.zst # SBOMs inside a Zarf package
Grype auto-detects an SBOM on stdin, so the sbom: prefix is only needed for a path. Scanning the SBOM rather than the image is faster because the cataloguing work is already done:
# alpine:3.18, warm DB
grype registry:alpine:3.18 -> real 0m15.678s
grype sbom:./sbom.json -> real 0m10.431s (identical 14 matches)
Most of that residual 10s is loading the 2 GB vulnerability database, not matching.
Grype: The Vulnerability Database
What is in it
Grype does not scrape NVD at scan time. Anchore runs vunnel (https://github.com/anchore/vunnel) to normalise 26 upstream providers into one SQLite database, published daily:
$ grype db providers
NAME VERSION PROCESSOR DATE CAPTURED
alma 1 vunnel@0.63.0.post14+c2d8acc 2026-09-05 00:31:40 +0000 UTC
alpine 1 vunnel@0.63.0.post14+c2d8acc 2026-09-05 00:32:08 +0000 UTC
debian 1 vunnel@0.63.0.post14+c2d8acc 2026-09-05 00:32:09 +0000 UTC
...
The full provider set as of v6.1.9: alma alpine amazon arch bitnami chainguard chainguard-libraries debian echo eol epss fedora github govulndb hummingbird kev mariner minimos nvd oracle photon rhel secureos sles ubuntu wolfi.
Three of those are not vulnerability sources at all and are worth calling out: epss (exploit prediction scores), kev (CISA Known Exploited Vulnerabilities), and eol (distro end-of-life dates). They feed the risk column and the EOL warning discussed below.
Managing it
grype db status # path, schema, build date, validity
grype db check # is a newer one available? exit 0 if current
grype db update # download and install the latest
grype db list # the listing entry: URL, checksum, schema
grype db delete # remove the local copy
grype db providers # provenance per upstream source
grype db import ./vulnerability-db.tar.zst # install from a local file or URL
grype db search --vuln CVE-2026-40200 # what the DB actually knows
grype db diff <a> <b> # what changed between two DBs
$ grype db status
Path: /var/tmp/gs-scratch/cache/6/vulnerability.db
Schema: v6.1.9
Built: 2026-09-05T06:27:00Z
From: https://grype.anchore.io/databases/v6/vulnerability-db_v6.1.9_2026-09-05T00:31:34Z_...tar.zst
Status: valid
grype db search is the tool for understanding why a match happened, because it shows the same CVE across every namespace with its own constraint:
$ grype db search --vuln CVE-2026-40200
VULNERABILITY PACKAGE ECOSYSTEM NAMESPACE VERSION CONSTRAINT
CVE-2026-40200 cpe:2.3:a:musl-libc:musl:*:*:*:... nvd:cpe >= 0.7.10, <= 1.2.6
CVE-2026-40200 musl apk alpine:distro:alpine:3.19 < 1.2.4_git20230717-r6
CVE-2026-40200 musl apk alpine:distro:alpine:3.20 < 1.2.5-r3
CVE-2026-40200 musl apk alpine:distro:alpine:3.21 < 1.2.5-r11
One CVE, four different opinions about which versions are affected. That table is the match-quality problem in miniature.
Size, cost, and the v6 change
The v6 database is large. Budget for it:
Download: ~3 minutes on a decent link
On disk: 2.0 GB ($GRYPE_DB_CACHE_DIR/6/vulnerability.db plus import.json, last_update_check)
That rules out a tmpfs /tmp and most default CI scratch space. In containerised CI, mount a real cache volume or accept a three-minute download per job.
The v5-to-v6 change moved the listing from toolbox-data.anchore.io/grype/databases/listing.json to a versioned path under a new host:
update-url (config): https://grype.anchore.io/databases
resolved listing: https://grype.anchore.io/databases/v6/latest.json
Air-gapped operation
# Config keys, with their environment equivalents
db:
cache-dir: '~/.cache/grype/db' # GRYPE_DB_CACHE_DIR
update-url: 'https://grype.anchore.io/databases' # GRYPE_DB_UPDATE_URL
ca-cert: '' # GRYPE_DB_CA_CERT
auto-update: true # GRYPE_DB_AUTO_UPDATE
validate-by-hash-on-start: true # GRYPE_DB_VALIDATE_BY_HASH_ON_START
validate-age: true # GRYPE_DB_VALIDATE_AGE
max-allowed-built-age: 120h0m0s # GRYPE_DB_MAX_ALLOWED_BUILT_AGE
require-update-check: false # GRYPE_DB_REQUIRE_UPDATE_CHECK
update-available-timeout: 30s # GRYPE_DB_UPDATE_AVAILABLE_TIMEOUT
update-download-timeout: 5m0s # GRYPE_DB_UPDATE_DOWNLOAD_TIMEOUT
max-update-check-frequency: 2h0m0s # GRYPE_DB_MAX_UPDATE_CHECK_FREQUENCY
The air-gapped pattern is: fetch the archive on a connected host, ship it inside, import it, and turn auto-update off.
# Connected side — grype db list gives you the exact URL and checksum
grype db list
# Air-gapped side
export GRYPE_DB_AUTO_UPDATE=false
export GRYPE_DB_CACHE_DIR=/opt/grype/db
grype db import /media/transfer/vulnerability-db_v6.1.9_....tar.zst
grype db status # confirm schema and build date before trusting a scan
grype db import also accepts a URL, so hosting the archive plus your own latest.json on an internal artefact server works — point GRYPE_DB_UPDATE_URL at it and normal grype db update flows resume.
Staleness: what Grype does and does not protect you from
Grype does refuse to run on a database older than max-allowed-built-age (120h / 5 days by default):
$ GRYPE_DB_MAX_ALLOWED_BUILT_AGE=1s grype sbom:./sbom.json
[0008] ERROR failed to load vulnerability db: the vulnerability database was built
9 hours ago (max allowed age is 1 second)
$ echo $?
1
$ GRYPE_DB_CACHE_DIR=/nonexistent grype sbom:./sbom.json
[0000] ERROR failed to load vulnerability db: database does not exist
$ echo $?
1
Exit code 1 for a database problem, distinct from 2 for a policy failure. That distinction is what lets CI tell "the scanner broke" from "the scan found something".
The silent failure is one you opt into:
$ GRYPE_DB_MAX_ALLOWED_BUILT_AGE=1s GRYPE_DB_VALIDATE_AGE=false grype sbom:./sbom.json -q
NAME INSTALLED TYPE VULNERABILITY SEVERITY ...
busybox 1.36.1-r7 apk CVE-2025-60876 Medium ...
$ echo $?
0
GRYPE_DB_VALIDATE_AGE=false is the setting that turns an arbitrarily old database into a clean pass. It exists for air-gapped sites that genuinely cannot update daily; if you set it, pair it with an independent alarm on DB age. Note also that require-update-check defaults to false, so a failed update check against a still-in-age database is a warning, not an error — set GRYPE_DB_REQUIRE_UPDATE_CHECK=true in CI if you want the network failure to be loud.
Grype: Match Quality
This is the section worth reading twice. Grype's results are not a single kind of thing, and treating them as one is the root of most complaints about false positives.
Three matching strategies
Grype records how every match was made. On node:18-bookworm-slim (293 packages: 88 deb, 204 npm, 1 binary):
grype sbom:./node.json -o json \
| jq -c '[.matches[].matchDetails[].type]|group_by(.)|map({(.[0]):length})|add'
# {"cpe-match":28,"exact-direct-match":61,"exact-indirect-match":239}
grype sbom:./node.json -o json \
| jq -c '[.matches[].matchDetails[].matcher]|group_by(.)|map({(.[0]):length})|add'
# {"dpkg-matcher":271,"javascript-matcher":29,"stock-matcher":28}
exact-direct-match — the package name matched a distro or ecosystem advisory directly. libssl3 in debian:distro:debian:12. High confidence: the distro maintainer said this exact binary package is affected.
exact-indirect-match — matched via the source package. Debian ships libgnutls30 built from source package gnutls28; advisories are written against the source:
{
"type": "exact-indirect-match",
"matcher": "dpkg-matcher",
"searchedBy": {
"distro": {"type": "debian", "version": "12.11"},
"package": {"name": "gnutls28", "version": "3.7.9-2+deb12u4"},
"namespace": "debian:distro:debian:12"
},
"found": {"vulnerabilityID": "CVE-2011-3389", "versionConstraint": "none (unknown)"}
}
Also high confidence, and the reason a scanner that ignores source-package relationships under-reports on Debian and RHEL badly.
cpe-match — no distro or ecosystem advisory applied, so Grype fell back to generating CPEs from the package metadata and matching them against NVD. This is the low-confidence path, and it is where false positives live:
{
"type": "cpe-match",
"matcher": "stock-matcher",
"searchedBy": {
"namespace": "nvd:cpe",
"cpes": ["cpe:2.3:a:nodejs:node.js:18.20.8:*:*:*:*:*:*:*"],
"package": {"name": "node", "version": "18.20.8"}
},
"found": {
"vulnerabilityID": "CVE-2026-21710",
"versionConstraint": "< 20.20.2||>= 21, < 22.22.2||>= 23, < 24.14.1||>= 25, < 25.8.2 (unknown)",
"cpes": ["cpe:2.3:a:nodejs:node.js:*:*:*:*:*:*:*:*"]
},
"fix": {"suggestedVersion": "20.20.2"}
}
All 28 CPE matches on that image came from one package: the node binary found by the binary classifier. Note the suggested fix — 20.20.2, a major-version jump, because NVD's constraint spans release lines and the first "fixed" version in the range belongs to a different one. The 18.x line has its own patch releases; NVD's ranges do not model that well.
Why CPE matching goes wrong, concretely: Syft generates candidate CPEs from package names, and for a package called ca-certificates-bundle it emits twelve of them, including cpe:2.3:a:mozilla:ca_certificates_bundle:.... Any NVD entry for any of those vendor/product guesses becomes a match. It is deliberately over-inclusive — a missed vulnerability is worse than a spurious one — but it means CPE matches need triage, not automation.
Use grype explain to see the reasoning for a specific finding:
$ grype sbom:./sbom.json -o json | grype explain --id CVE-2026-40200
[0000] WARN grype explain is a prototype feature and is subject to change
CVE-2026-40200 from nvd:cpe (High)
An issue was discovered in musl libc 0.7.10 through 1.2.6. ...
Matched packages:
- Package: musl, version: 1.2.4-r3
PURL: pkg:apk/alpine/musl@1.2.4-r3?arch=x86_64&distro=alpine-3.18.12
Match explanation(s):
- nvd:cpe:CVE-2026-40200 CPE match on `cpe:2.3:a:musl-libc:musl:1.2.4:*:*:*:*:*:*:*`.
Why Grype and Trivy disagree
Neither is wrong; they weight the same trade-off differently.
- Grype reports more CPE-derived findings. The
stock-matcherfallback fires wherever no curated advisory exists. Trivy leans harder on curated sources and is quieter on binaries and unusual packages. - Trivy reports more on some language ecosystems because it carries ecosystem-specific logic Grype delegates to the GitHub Security Advisory feed.
- Both use distro trackers for OS packages and agree closely there. Disagreement on
apt/apk/rpmfindings usually means a DB age difference, not a methodology one. - Severity differs because they choose different sources for it. Grype prefers the distro's severity where one exists, falling back to NVD; a CVE that Debian rates "unimportant" and NVD rates 9.8 will be reported differently by tools with different precedence rules.
The productive move is not to pick a winner but to normalise: --by-cve on the Grype side collapses vendor identifiers to CVEs so the two sets are actually comparable.
# 29 findings carry non-CVE IDs (GHSA-...) by default; --by-cve resolves them
grype sbom:./node.json -o json | jq '[.matches[].vulnerability.id]|map(select(test("^CVE")|not))|length' # 29
grype sbom:./node.json --by-cve -o json | jq '[.matches[].vulnerability.id]|map(select(test("^CVE")|not))|length' # 0
--by-cve reports the CVE rather than the GHSA or distro ID where an equivalence exists. Use it when feeding a tracker keyed on CVE, or when diffing against another scanner. Leave it off when you want the identifier the advisory was actually written under — the GHSA often has better remediation detail than its CVE.
The EOL trap — Grype's most dangerous silent behaviour
Scan an image built on an end-of-life distro release and Grype stops using the distro namespace:
# Same alpine:3.18 SBOM, same DB
grype sbom:./sbom.json -o json | jq -c '[.matches[].matchDetails[].searchedBy.namespace]|group_by(.)|map({(.[0]):length})|add'
# {"nvd:cpe":14} <- 14 matches, CPE fallback only
grype sbom:./sbom.json --distro alpine:3.22 -o json | jq -c '...same...'
# {"alpine:distro:alpine:3.22":147,"nvd:cpe":3} <- 140 matches
14 versus 140 findings for the same packages. Grype does warn, but only on stderr, and -q suppresses it:
15 packages from EOL distro "alpine 3.18.12" - vulnerability data may be incomplete
or outdated; consider upgrading to a supported version
A CI job that redirects stderr, or runs with -q, or only parses JSON from stdout, sees a clean 14-finding report on an image that a supported-release comparison rates at 140. The gate passes.
It is recoverable from the JSON — check alertsByPackage:
grype sbom:./sbom.json -o json | jq -r '[.alertsByPackage[].alerts[].type]|unique|join(",")'
# distro-eol
{"type": "distro-eol",
"message": "Package is from end-of-life distro: alpine 3.18.12",
"metadata": {"name": "alpine", "version": "3.18.12"}}
Make your pipeline fail on a non-empty alertsByPackage containing distro-eol. Do not use --distro to paper over it — forcing alpine:3.22 onto a 3.18 image produces findings against version constraints that were never evaluated for that release. --distro is an override, not a hint; it replaces the entire matching basis.
Grype: Severity Gating and Exit Codes
grype <target> --fail-on high # exit 2 if anything >= high is found
grype <target> --fail-on critical
| Exit code | Meaning |
|---|---|
0 |
Scan completed; no finding met the --fail-on threshold (or none was set) |
1 |
Grype itself failed — bad target, missing or stale DB, unreadable SBOM |
2 |
Scan completed and a finding met the --fail-on threshold |
# Captured, alpine:3.18 (highest severity present: High)
grype registry:alpine:3.18 -q ; echo $? # 0
grype registry:alpine:3.18 -q --fail-on critical ; echo $? # 0
grype registry:alpine:3.18 -q --fail-on low ; echo $? # 2
Without --fail-on, Grype exits 0 no matter what it finds. A CI step that runs grype myimage and nothing else is a report, not a gate, and it will go green forever. This is the most common way a scanning pipeline turns out to be decorative.
Equally: a shell that does not set -o pipefail loses the exit code the moment you pipe to tee or jq.
Filtering by fix state
Severity alone is a poor gate because most findings are not actionable. Measured on node:18-bookworm-slim — 328 matches:
grype sbom:./node.json -o json | jq -c '[.matches[].vulnerability.fix.state]|group_by(.)|map({(.[0]):length})|add'
# {"":1,"fixed":117,"not-fixed":126,"wont-fix":84}
grype sbom:./node.json --only-fixed -o json | jq '.matches|length' # 117
grype sbom:./node.json --only-notfixed -o json | jq '.matches|length' # 211
grype sbom:./node.json --ignore-states wont-fix,not-fixed -o json | jq '.matches|length' # 118
Only 117 of 328 findings (36%) have a fix available. The other 211 cannot be resolved by rebuilding — they are waiting on an upstream maintainer, or the distro has decided the issue does not warrant a fix. Gating on all 328 means the gate can never go green, which means someone will disable it.
# The gate that a team can actually live with
grype sbom:./sbom.json --fail-on high --only-fixed
--ignore-states takes any comma-separated subset of fixed, not-fixed, unknown, wont-fix and is more expressive than the two shorthand flags.
Grype: Ignore Rules and VEX
.grype.yaml ignore blocks
Config file search order mirrors Syft's: .grype.yaml, .grype/config.yaml, ~/.grype.yaml, ~/.config/grype/config.yaml, /etc/xdg/grype/config.yaml.
# .grype.yaml
ignore:
# By vulnerability ID — the blunt instrument
- vulnerability: CVE-2011-3389
# By package identity; every field is ANDed
- package:
name: node
type: binary
- package:
name: lodash
version: "4.17.19"
type: npm
# By location — useful for test fixtures baked into an image
- package:
location: "/opt/legacy/**"
# By fix state — suppress everything the distro will never fix
- fix-state: wont-fix
# Combined: this CVE, only for this package
- vulnerability: GHSA-xxxx-yyyy-zzzz
package:
name: express
# By namespace, to drop the whole CPE fallback class
- namespace: nvd:cpe
Measured effect of the first three rules above, plus the fix-state: wont-fix rule, on the 328-match node scan (the first three on their own suppress only 29 — 28 of those are the node binary's CPE matches):
grype sbom:./node.json -o json | jq '.matches|length' # 215 (was 328)
grype sbom:./node.json -o json | jq '.ignoredMatches|length' # 113
Suppressed findings are not discarded — they move to ignoredMatches, each annotated with the rule that fired:
grype sbom:./node.json -o json | jq -c '.ignoredMatches[0]|{id:.vulnerability.id, pkg:.artifact.name, rules:.appliedIgnoreRules}'
# {"id":"CVE-2011-3389","pkg":"libgnutls30","rules":[{"vulnerability":"CVE-2011-3389","namespace":""}]}
grype sbom:./node.json --show-suppressed # table view marks them "(suppressed)"
# libgnutls30 3.7.9-2+deb12u4 deb CVE-2011-3389 Negligible ... (suppressed)
# coreutils 9.1-1 (won't fix) deb CVE-2016-2781 Low ... (suppressed)
Review ignoredMatches periodically. A rule written for a package version you no longer ship keeps matching nothing, and a rule written by CVE keeps suppressing that CVE in packages nobody assessed. Prefer the narrowest rule that works, and add a comment saying who decided and when — the format has no expiry mechanism, so the comment is the only control.
VEX
Ignore rules are local, unstructured, and invisible to anyone downstream. VEX (Vulnerability Exploitability eXchange) is the interoperable version: a signed, portable statement that a given product is not affected by a given vulnerability, with a machine-readable justification. Grype consumes OpenVEX (https://github.com/openvex).
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://example.com/vex/app-001",
"author": "Platform Team",
"timestamp": "2026-09-05T00:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {"name": "CVE-2026-21710"},
"products": [{"@id": "pkg:generic/node@18.20.8"}],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
]
}
grype sbom:./node.json --vex vex.json
# Verified: the finding moves from matches to ignoredMatches
# without --vex : 1 match for CVE-2026-21710
# with --vex : 0 matches, 1 ignoredMatch
# appliedIgnoreRules: [{"namespace":"vex","vex-status":"not_affected"}]
OpenVEX statuses are not_affected, affected, fixed, and under_investigation. A not_affected statement requires a justification from a fixed vocabulary — vulnerable_code_not_in_execute_path, component_not_present, vulnerable_code_not_present, vulnerable_code_cannot_be_controlled_by_adversary, inline_mitigations_already_exist.
Prefer VEX over .grype.yaml ignores for anything you would have to justify to an auditor or a customer. The document travels with the release, states why rather than merely that, and any VEX-aware scanner honours it. Keep .grype.yaml for local noise — build-time test fixtures, scanner artefacts, things nobody outside the repo cares about.
Grype: Output Formats
grype <target> -o table # default, human
grype <target> -o json # full detail: matches, matchDetails, ignoredMatches, alertsByPackage
grype <target> -o cyclonedx-json # CycloneDX with a VEX-style vulnerabilities block
grype <target> -o cyclonedx # CycloneDX XML
grype <target> -o sarif # for GitHub code scanning
grype <target> -o template -t ./report.tmpl
# Multiple outputs at once
grype <target> -o table -o 'sarif=results.sarif' -o 'json=results.json'
The deprecated embedded-cyclonedx-vex-json and embedded-cyclonedx-vex-xml names still parse; use cyclonedx-json and cyclonedx.
$ grype sbom:./sbom.json -o sarif | jq -c '{version, driver:.runs[0].tool.driver.name, rules:(.runs[0].tool.driver.rules|length), results:(.runs[0].results|length)}'
{"version":"2.1.0","driver":"grype","rules":14,"results":14}
The default table gained EPSS and risk columns, and sorts by risk descending:
NAME INSTALLED TYPE VULNERABILITY SEVERITY EPSS RISK
busybox 1.36.1-r7 apk CVE-2025-60876 Medium 0.3% (21st) 0.2
musl 1.2.4-r3 apk CVE-2026-40200 High 0.2% (6th) 0.1
zlib 1.2.13-r1 apk CVE-2026-27171 Medium 0.2% (12th) 0.1
--sort-by accepts package, severity, epss, risk, kev, or vulnerability; the default is risk, which blends severity with EPSS. If you have tooling that assumed severity ordering, set --sort-by severity explicitly.
The Pipeline: Scan What You Shipped
flowchart TD
subgraph Build["Build time"]
SRC["Source + lockfiles"] --> IMAGE["Image built and pushed by digest"]
IMAGE --> SYFT["syft registry:app@sha256:..."]
SYFT --> SBOM["sbom.syft.json"]
SBOM --> ATT["syft attest / cosign attest"]
SBOM --> ART["CI artefact, retained"]
end
subgraph Gate["Release gate"]
SBOM --> G1["grype sbom:./sbom.json --fail-on high --only-fixed"]
G1 --> D{"exit code"}
D -->|0| SHIP["Ship"]
D -->|2| BLOCK["Block: actionable finding"]
D -->|1| BROKEN["Block: scanner or DB failure"]
end
subgraph Later["Weeks later, new CVE published"]
ART --> RESCAN["grype sbom:./sbom.json"]
ATT --> RESCAN
RESCAN --> ANSWER["Answer for the exact artefact in production"]
end
The argument for the right-hand branch is worth stating plainly, because it is the reason to run these two tools rather than one:
Re-scanning the image is not the same as re-scanning what you shipped. By the time a CVE lands, myapp:1.4.2 may have been overwritten, the base image tag has certainly moved, and rebuilding from the same Dockerfile produces a different set of packages. Scanning the retained SBOM answers a question about the artefact that is actually running in production. Scanning a rebuild answers a question about a hypothetical artefact that has never existed anywhere.
Two disciplines make that work:
- Generate the SBOM against a digest, not a tag.
syft registry:ghcr.io/org/app@sha256:.... A tag is a mutable pointer; the SBOM must be bound to the immutable thing. - Retain the SBOM for as long as the artefact might run. Attach it to the image as an attestation, keep it as a release asset, or both. An SBOM you cannot find is an SBOM you did not generate.
CI Integration
GitHub Actions
name: Container scan
on:
push:
tags: ['v*']
permissions:
contents: read
jobs:
sbom-and-scan:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
security-events: write # required for upload-sarif
steps:
- uses: actions/checkout@v6
# Generate the SBOM once; everything downstream consumes it
- name: Generate SBOM
uses: anchore/sbom-action@v0.24.2
with:
image: ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}
format: syft-json
output-file: sbom.syft.json
upload-artifact: false
# Gate: fail only on things that can actually be fixed
- name: Scan SBOM
id: scan
uses: anchore/scan-action@v7.4.2
with:
sbom: sbom.syft.json
fail-build: true
severity-cutoff: high
only-fixed: true
output-format: sarif
# Upload even when the gate failed, so the findings are visible
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
- name: Retain the SBOM with the release
uses: actions/upload-artifact@v7
with:
name: sbom
path: sbom.syft.json
retention-days: 90
Two notes on the actions. anchore/sbom-action will, by default, upload the SBOM to the GitHub dependency-submission API — set upload-artifact and dependency-snapshot deliberately rather than inheriting them. And if: always() on the SARIF upload is not optional: without it, a failing gate means the findings never reach the Security tab, which is exactly when you want to see them.
The permissions: block matters — see GitHub Actions for the full token-scoping discussion.
Running the binaries directly
Sometimes clearer than the action wrappers, and the only option outside GitHub:
- name: Install and scan
run: |
set -euo pipefail
curl -sSfL https://get.anchore.io/syft | sh -s -- -b "$RUNNER_TEMP/bin"
curl -sSfL https://get.anchore.io/grype | sh -s -- -b "$RUNNER_TEMP/bin"
export PATH="$RUNNER_TEMP/bin:$PATH"
export GRYPE_DB_REQUIRE_UPDATE_CHECK=true # loud on network failure
syft "registry:$IMAGE" -o syft-json=sbom.json
grype sbom:./sbom.json -o 'json=results.json' -o table \
--fail-on high --only-fixed
# The EOL check the --fail-on gate will not do for you
if [ "$(jq '[.alertsByPackage[]?.alerts[]?|select(.type=="distro-eol")]|length' results.json)" != "0" ]; then
echo "::error::Base image is from an EOL distro; findings are incomplete"
exit 1
fi
GitLab CI
stages: [sbom, scan]
variables:
GRYPE_DB_CACHE_DIR: "$CI_PROJECT_DIR/.grype-db"
.anchore-tools: &anchore-tools
before_script:
- curl -sSfL https://get.anchore.io/syft | sh -s -- -b /usr/local/bin
- curl -sSfL https://get.anchore.io/grype | sh -s -- -b /usr/local/bin
generate-sbom:
stage: sbom
image: alpine:3.22
<<: *anchore-tools
script:
- apk add --no-cache curl jq
- syft "registry:$CI_REGISTRY_IMAGE@$IMAGE_DIGEST" -o syft-json=sbom.json
artifacts:
paths: [sbom.json]
expire_in: 90 days
scan-sbom:
stage: scan
image: alpine:3.22
<<: *anchore-tools
needs: [generate-sbom]
cache:
# The v6 DB is ~2 GB; caching it saves ~3 minutes per pipeline
key: grype-db-v6
paths: [.grype-db]
script:
- apk add --no-cache curl jq
- grype sbom:./sbom.json -o "json=grype.json" -o table --fail-on high --only-fixed
artifacts:
when: always
paths: [grype.json]
reports:
# GitLab Ultimate reads its own format; the JSON is kept regardless
container_scanning: grype.json
allow_failure: false
Cache the database, not the binaries. The binaries are ~90 MB each and download in seconds; the database is 2 GB and three minutes.
Grype/Syft versus Trivy versus Clair
| Grype + Syft | Trivy | Clair | |
|---|---|---|---|
| Shape | Two single static binaries | One single static binary | Server + PostgreSQL + indexer/matcher/notifier |
| Scope | Images, filesystems, archives, SBOMs, purls, CPEs | Images, filesystems, repos, IaC, Kubernetes, secrets, licences, misconfig | Container images, via an API |
| SBOM story | Best in class — Syft is the reference generator; SBOM is a first-class scan target | Good — generates and consumes CycloneDX/SPDX, but SBOM is a feature not the architecture | Weak — consumes indexed manifests; SBOM is not the interchange |
| DB provenance | 26 providers normalised by vunnel; grype db providers shows per-source capture times |
Aggregated by Aqua; OCI-distributed, well cached | Per-updater, distro-focused; you operate the pipeline |
| DB size / update | ~2 GB on disk, daily rebuild, grype db subcommands |
Smaller, OCI registry distribution, very fast pull | Continuous background updates into your Postgres |
| Air-gapped | Straightforward: grype db import, self-hosted listing |
Straightforward: mirror the OCI artefact | Natural fit — it is already your infrastructure |
| Noise profile | Higher on CPE-fallback packages; the fallback is explicit and filterable by namespace | Lower by default; less transparent about why something was excluded | Lowest — distro advisories only, so it misses language packages |
| Language ecosystems | Broad, via GHSA + ecosystem catalogers | Broad, with more bespoke per-ecosystem logic | Limited |
| Non-vuln checks | None — deliberately | IaC, secrets, misconfiguration, licences | None |
| Operational cost | Near zero | Near zero | Real: a database, a service, upgrades, backups |
Pick Grype and Syft when the SBOM is the deliverable — when you need to re-scan what you shipped, feed a VEX workflow, or hand a customer a CycloneDX document. The separation of cataloguing from matching is the architecture, and it is the right one for supply-chain work. Accept that you will triage more CPE-derived findings.
Pick Trivy when you want one tool for the whole security surface of a repository and you value a quiet default. Scanning Terraform, Kubernetes manifests, secrets, and images with one binary and one config is a genuine operational win, and for teams without a dedicated security function it usually beats assembling the equivalent from three tools.
Pick Clair when you are a registry operator, or when scanning must be a service with an API rather than a step in someone's pipeline — continuous re-evaluation of every image you host as new advisories land, without re-running anything. Do not pick it for CI; the deployment cost is not repaid by a per-commit gate.
Running two is defensible. Grype and Trivy disagree, and the union is a better picture than either alone — provided somebody owns the triage. Running two scanners and gating on neither is worse than running one and gating on it.
See Trivy and Clair for each tool in depth, and Container Security for where scanning sits in a broader posture.
Quick Reference
| Task | Command |
|---|---|
| SBOM to stdout | syft <target> |
| SBOM, native lossless | syft <target> -o syft-json=sbom.json |
| SBOM, CycloneDX + SPDX | syft <t> -o cyclonedx-json=cdx.json -o spdx-json=spdx.json |
| Pull without a runtime | syft registry:org/app:1.2 |
| Include deleted-layer packages | syft <target> --scope all-layers |
| List catalogers | syft cataloger list |
| Add / drop a cataloger | syft <t> --select-catalogers +sbom-cataloger / -binary |
| Convert formats | syft convert in.json -o cyclonedx-json=out.json |
| Attest to a registry | syft attest -o cyclonedx-json registry:org/app:1.2 |
| Show config precedence | syft config locations / grype config locations |
| Scan an image | grype registry:org/app:1.2 |
| Scan an SBOM | grype sbom:./sbom.json |
| Scan an SBOM from stdin | syft <t> -o syft-json | grype |
| Gate CI on actionable findings | grype <t> --fail-on high --only-fixed |
| Show suppressed findings | grype <t> --show-suppressed |
| Normalise IDs to CVE | grype <t> --by-cve |
| Apply VEX | grype <t> --vex ./vex.json |
| Explain one finding | grype <t> -o json | grype explain --id CVE-2026-40200 |
| DB status / update | grype db status / grype db update |
| DB provenance | grype db providers |
| Query the DB | grype db search --vuln CVE-2026-40200 |
| Air-gapped DB install | grype db import ./vulnerability-db_v6....tar.zst |
| SARIF for code scanning | grype <t> -o 'sarif=results.sarif' |
Exit codes
| Code | Tool | Meaning |
|---|---|---|
0 |
both | Success; no --fail-on threshold met |
1 |
grype | Grype failed: bad target, missing/stale DB, unreadable input |
2 |
grype | A finding met the --fail-on threshold |
Environment variables worth knowing
| Variable | Default | Why you would set it |
|---|---|---|
GRYPE_DB_CACHE_DIR |
~/.cache/grype/db |
Point at a CI cache volume; the DB is ~2 GB |
GRYPE_DB_AUTO_UPDATE |
true |
false for air-gapped or pinned-DB runs |
GRYPE_DB_UPDATE_URL |
https://grype.anchore.io/databases |
Self-hosted mirror |
GRYPE_DB_REQUIRE_UPDATE_CHECK |
false |
Set true in CI so a failed update check is an error |
GRYPE_DB_VALIDATE_AGE |
true |
false silently accepts an arbitrarily old DB — avoid |
GRYPE_DB_MAX_ALLOWED_BUILT_AGE |
120h |
Tighten for a daily-updated pipeline |
Common Issues and Solutions
| Issue | Cause | Fix |
|---|---|---|
| Packages installed then deleted are missing | Default --scope squashed sees only the final filesystem |
--scope all-layers when you care about what is distributed, not just what runs |
| Far fewer findings than expected on an old base image | Distro release is EOL; Grype falls back to CPE-only matching (14 vs 140 measured on alpine:3.18) | Rebuild on a supported release. Fail CI on alertsByPackage[].alerts[].type == "distro-eol" — the stderr warning is invisible under -q |
| CI is green but nothing is being gated | grype <target> alone always exits 0 |
Add --fail-on <severity>; add set -o pipefail if piping |
| Gate can never be made green | Gating on all severities, including 211-of-328 findings with no fix | Add --only-fixed, or --ignore-states wont-fix,not-fixed |
| High-severity findings against a package nobody uses | cpe-match fallback via stock-matcher; Syft emits many candidate CPEs per package |
Check matchDetails[].type before acting. Suppress the class with an - namespace: nvd:cpe ignore rule, or the package with a scoped rule |
| Suggested fix is a major-version jump | NVD constraints span release lines; the first "fixed" version may belong to a different one | Treat fix.suggestedVersion on a cpe-match as advisory; check the vendor's own advisory for the line you are on |
dir: scan of a source tree finds nothing |
Directory catalogers read lockfiles and manifests, not bare package.json. Measured: 2 packages without package-lock.json, 4 with |
Commit lockfiles, or scan the built image instead of the source |
podman: podman not available: no host address |
The podman: scheme needs the API socket, not just the CLI |
systemctl --user start podman.socket, or podman save --format oci-archive and use oci-archive: |
| Scan uses a stale image | docker:/podman: read the local daemon copy |
Use registry: and pin by digest |
failed to load vulnerability db: ... built N ago |
DB older than max-allowed-built-age (120h) |
grype db update. Do not reach for GRYPE_DB_VALIDATE_AGE=false unless air-gapped, and alarm on DB age if you do |
| DB download fills the disk / OOMs a runner | The v6 DB is ~2 GB uncompressed; tmpfs /tmp will not hold it |
Set GRYPE_DB_CACHE_DIR to real disk with 3 GB headroom; cache it between jobs |
| Ignore rule suppresses more than intended | A bare vulnerability: rule matches that CVE in every package |
Scope it with a package: block. Audit ignoredMatches and --show-suppressed regularly |
| SBOM lost file and dependency data | CycloneDX/SPDX drop the file inventory and most relationships (79 files to 0, 129 relationships to 20, measured) | Keep syft-json as the archival copy; emit CycloneDX/SPDX for consumers alongside it |
| Fewer matches from a purl list than from an SBOM | -o purls drops packages with no purl and loses context (282 lines vs 293 packages; 300 vs 328 matches) |
Use a real SBOM format for matching; keep purls for inventory lists |
| Grype and Trivy disagree | Different fallback strategies, different severity precedence, different DB ages | Normalise with --by-cve before comparing; expect Grype to be noisier on CPE-derived findings |
| Findings vanished after a config change | A .grype.yaml in the working directory is picked up automatically |
grype config locations; pass -c explicitly in CI |
| SARIF never appears in the Security tab | The upload step is skipped when the gate fails | if: always() on the upload-sarif step |
Related Topics
The following topics complement this cheatsheet and would be valuable additions:
- Trivy - The main alternative scanner: broader surface (IaC, secrets, misconfiguration), quieter defaults, and where its findings diverge from Grype's
- Clair - Service-shaped scanning for registry operators: indexer/matcher/notifier, Postgres, and continuous re-evaluation
- Container Security - Where scanning sits in a full posture: rootless runtimes, capabilities, seccomp, admission control, and runtime detection
- Container Image Optimisation - Multi-stage builds, distroless and minimal bases, and cosign signing — the practices that shrink the surface a scanner reports on
- CI/CD Patterns - Gate placement, artefact retention, and the difference between a report and a control
- Container Registries - Digests versus tags, referring artefacts, and how SBOMs and attestations are stored and retrieved