Available for day contractsFrom 21st September I have availability for day and half day contracts. Please contact for more information.

Contact →
mikepreston.org

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).

Scan sourcesre-scan months lateryesnoContainer imageDirectory / repoArchive (OCI, dockersave)SyftSBOM (syft-json,CycloneDX, SPDX)Release artefact /attestationGrypeVulnerability DB v6Matches: table,JSON, SARIF,CycloneDX--fail-on met?exit 2exit 0Scan sourcesre-scan months lateryesnoContainer imageDirectory / repoArchive (OCI, dockersave)SyftSBOM (syft-json,CycloneDX, SPDX)Release artefact /attestationGrypeVulnerability DB v6Matches: table,JSON, SARIF,CycloneDX--fail-on met?exit 2exit 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 (reads node_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-matcher fallback 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/rpm findings 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

Weeks later, new CVE publishedRelease gateBuild time021Source + lockfilesImage built andpushed by digestsyftregistry:app@sha256:...sbom.syft.jsonsyft attest / cosignattestCI artefact,retainedgrypesbom:./sbom.json--fail-on high--only-fixedexit codeShipBlock: actionablefindingBlock: scanner or DBfailuregrypesbom:./sbom.jsonAnswer for the exactartefact inproductionWeeks later, new CVE publishedRelease gateBuild time021Source + lockfilesImage built andpushed by digestsyftregistry:app@sha256:...sbom.syft.jsonsyft attest / cosignattestCI artefact,retainedgrypesbom:./sbom.json--fail-on high--only-fixedexit codeShipBlock: actionablefindingBlock: scanner or DBfailuregrypesbom:./sbom.jsonAnswer for the exactartefact inproduction

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:

  1. 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.
  2. 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:

  1. Trivy - The main alternative scanner: broader surface (IaC, secrets, misconfiguration), quieter defaults, and where its findings diverge from Grype's
  2. Clair - Service-shaped scanning for registry operators: indexer/matcher/notifier, Postgres, and continuous re-evaluation
  3. Container Security - Where scanning sits in a full posture: rootless runtimes, capabilities, seccomp, admission control, and runtime detection
  4. Container Image Optimisation - Multi-stage builds, distroless and minimal bases, and cosign signing — the practices that shrink the surface a scanner reports on
  5. CI/CD Patterns - Gate placement, artefact retention, and the difference between a report and a control
  6. Container Registries - Digests versus tags, referring artefacts, and how SBOMs and attestations are stored and retrieved