Every SBOM tutorial stops at syft image | grype. That command runs fine, and it is also exactly where the real trouble starts: Grype hands you 40 CVEs, half of them sit in code your service never calls, and three weeks later the same SBOM reports a different number without a single line of your code changing. This guide takes you past the happy path to a stored SBOM, a reproducible Grype gate, and VEX filtering that removes non-exploitable CVEs with a documented reason instead of a blanket mute.

TL;DR: Have Syft write a CycloneDX SBOM to disk as a build artifact, then point Grype at that file with grype sbom:api-1.4.2.cdx.json instead of re-scanning the image. Freeze the vulnerability database in CI with GRYPE_DB_AUTO_UPDATE=false plus an explicit grype db update, or the same SBOM will give you a different CVE count next month. For the false positives that remain, author an OpenVEX not_affected statement with a justification and pass it via --vex, keeping --show-suppressed on so the audit trail survives.

Two things a generic walkthrough will not tell you. First, why you scan the stored SBOM rather than the image, and why a green scan from last month means nothing today. Second, how to kill false positives for good with VEX not_affected statements that Grype filters and keeps an audit trail of. If you are still deciding which scanner belongs in which pipeline, the job-by-job split between Trivy and Grype is worth reading first. This is aimed at platform and AppSec engineers wiring supply-chain scanning into CI who are tired of a red pipeline nobody trusts, the same crowd that ends up fighting fast-moving supply-chain attacks with dependency cooldowns.

Prerequisites

  • Linux or macOS shell with curl and a container runtime (Docker or Podman), or a local build directory to scan.
  • Syft 1.46.0 (2026-06-26) or newer and Grype 0.115.0 (2026-06-26) or newer. VEX filtering needs Grype 0.108.0 or later; CSAF-format VEX needs 0.111.0 or later.
  • vexctl from the OpenVEX project to author VEX documents. It needs Go 1.22+ if you install from source.
  • Outbound HTTPS so Grype can pull its vulnerability database on first run.

What does the SBOM-to-gate pipeline actually look like?

Four moving parts, and only one of them is your code. Syft catalogs the image into a file, Grype matches that file against a database it downloads separately, VEX statements filter the matches, and whatever survives decides the exit code:

Container image api:1.4.2Syft catalogs packagesStored SBOM api-1.4.2.cdx.jsonGrype match runGrype vulnerability DB, rebuilt dailyMatch covered by a VEX not_affected statement?Suppressed, listed under --show-suppressedSeverity high or above?--fail-on high exits non-zero, CI blocksReported, pipeline passesyesnoyesno

Note where the arrows come from. The SBOM is frozen at build time, the database is not, and the VEX document is the only place where your judgement about exploitability gets written down.

How do you generate an SBOM and scan the stored file?

1. Install Syft, Grype, and vexctl, pinned to a version

# Syft + Grype (Anchore's official installer, pinned)
curl -sSfL https://get.anchore.io/syft  | sh -s -- -b /usr/local/bin v1.46.0
curl -sSfL https://get.anchore.io/grype | sh -s -- -b /usr/local/bin v0.115.0

# vexctl to author VEX documents
go install github.com/openvex/vexctl@latest

Pin the versions. Grype's matching logic and its DB schema both change between releases, so an unpinned | sh in CI is a silent moving part sitting on top of the moving part you are about to meet in step 4. I have watched a CVE count jump overnight purely because a runner pulled a newer Grype minor, so pin it.

2. Generate the SBOM as a stored artifact

syft registry.example.com/api:1.4.2 \
  -o cyclonedx-json=api-1.4.2.cdx.json \
  -o spdx-json=api-1.4.2.spdx.json

Syft catalogs the packages once and writes them to disk in two standard formats, CycloneDX and SPDX. This file is your bill of materials, so treat it like a build artifact: store it next to the image digest, not in a scratch dir that CI wipes. Emitting both formats now costs nothing and saves a re-catalog later when some downstream tool only speaks one of them.

3. Scan the SBOM, not the image

grype sbom:api-1.4.2.cdx.json -o table

Point Grype at the file with the sbom: scheme. This splits the two jobs cleanly: Syft answers what is in this image, Grype answers which of those have known CVEs. Scanning the stored SBOM means you can re-check a six-month-old release for newly disclosed CVEs without pulling or rebuilding the image, which matters a lot the day that base image no longer exists in your registry.

Why does the same SBOM report a different CVE count each month?

4. The trap: the SBOM is frozen, the database is not

grype db status
# Output includes: Location, Built (timestamp), Schema, Status: valid

The SBOM from step 2 is immutable. Grype's vulnerability database is rebuilt daily. Scan the exact same api-1.4.2.cdx.json today and again in three weeks and the CVE count will differ, not because your image changed, but because the DB learned about new vulnerabilities in packages that were always there. That is a feature, and the reason you store SBOMs. It also wrecks CI reproducibility if you let the DB auto-update mid-pipeline.

For a gate you can trust, control the clock:

# In CI: fetch the DB in an explicit step, then freeze it for the run
export GRYPE_DB_AUTO_UPDATE=false
grype db update           # do this deliberately, log the "Built" date
grype sbom:api-1.4.2.cdx.json --fail-on high

--fail-on high exits non-zero when a vulnerability at or above high severity survives filtering. Pair it with GRYPE_DB_AUTO_UPDATE=false and the same commit scanned twice against the same DB build gives the same result. Log that Built date somewhere you can retrieve it, the same way OIDC-based CI pipelines log the identity they assumed. When someone asks in six months why the gate passed, you want the DB timestamp on record, not a shrug.

How do you suppress a non-exploitable CVE without muting the scanner?

5. Author a VEX statement for a non-exploitable CVE

Say Grype flags CVE-2024-XXXXX in glibc, but your service never reaches the vulnerable code path. Suppressing it by pattern (an ignore rule, --only-fixed) hides all of that CVE everywhere and leaves no record of why. VEX is the auditable alternative:

vexctl create \
  --vuln="CVE-2024-XXXXX" \
  --product="pkg:oci/[email protected]" \
  --subcomponents="pkg:deb/debian/[email protected]" \
  --status=not_affected \
  --justification=vulnerable_code_not_in_execute_path \
  --impact-statement="The affected function is never called by our service" \
  --file=api.openvex.json

A not_affected status requires a justification. vexctl rejects the document without one. Valid values are component_not_present, vulnerable_code_not_present, vulnerable_code_not_in_execute_path, vulnerable_code_cannot_be_controlled_by_adversary, and inline_mitigations_already_exist. This is the honest part of the whole exercise. You are stating a specific, defensible reason the CVE does not apply, not muting the scanner and hoping nobody asks.

6. Scan with the VEX document applied

grype sbom:api-1.4.2.cdx.json \
  --vex api.openvex.json \
  --show-suppressed \
  --fail-on high

Grype filters out any match whose PURL and vulnerability line up with a not_affected (or fixed) statement. And --show-suppressed is the flag that earns its keep: it keeps the suppressed rows visible in the table, marked as suppressed, instead of letting them vanish. In JSON output they move to the ignoredMatches array. You get a clean gate and an auditor can still see exactly what you filtered and why.

Can Grype use the VEX your base-image vendor already publishes?

7. Wire VEX and CSAF into .grype.yaml

# .grype.yaml
vex-documents:
  - api.openvex.json
# By default Grype only acts on not_affected/fixed. To surface vendor
# "affected"/"under_investigation" statements as findings, opt in:
vex-add:
  - affected
  - under_investigation

New in Grype v0.111.0 (2026-04-09): the CSAF VEX transformer. Grype now ingests CSAF-format VEX, the format Red Hat, SUSE, and other upstreams actually publish, not only OpenVEX. So you can drop a vendor's own advisory-derived VEX into --vex and let it suppress the CVEs the vendor already declared non-exploitable, instead of hand-authoring every statement yourself. On a Debian- or Red Hat-based image that is most of your noise gone with zero manual VEX writing.

Verify it works

grype version          # confirm >= 0.115.0
grype db status        # Status: valid, note the Built date

# Confirm the suppression actually applied:
grype sbom:api-1.4.2.cdx.json --vex api.openvex.json -o json \
  | jq '.ignoredMatches[].vulnerability.id'

Success looks like CVE-2024-XXXXX appearing in ignoredMatches (JSON) or as a suppressed row under --show-suppressed, with the exit code dropping to 0 if that CVE was the only high+ finding. If the CVE is still sitting in the active matches list, your VEX did not match. Go straight to the first pitfall.

Common pitfalls

  • VEX suppression silently does nothing. The most common failure by far: the --product or --subcomponents PURLs in the VEX document do not exactly match the PURLs Grype computed from the SBOM. Check the real PURL with grype sbom:api-1.4.2.cdx.json -o json | jq '.matches[].artifact.purl' and copy it verbatim. A version or distro mismatch ([email protected] versus [email protected]) means no suppression, no error, no clue.
  • Scanning a directory instead of an image misses OS packages. syft dir:. catalogs application dependencies but not the base-image OS packages a container ships. Your SBOM, and therefore your CVE list, comes out incomplete. Scan the built image (or oci-dir:) for a production bill of materials.
  • Treating a stale green scan as current. Because the SBOM is frozen and the DB moves (step 4), "we scanned this last quarter and it was clean" is not a security statement. Re-scan stored SBOMs on a schedule against a fresh DB.
  • Suppressing without --show-suppressed. Filter and hide the suppressed matches, and a future engineer cannot tell a CVE that was assessed-and-dismissed from one that was never present. Keep the audit trail every time.
  • not_affected without a justification. vexctl and downstream consumers reject it. If you genuinely cannot justify it, the honest status is under_investigation, not not_affected. VEX narrows the exploitable set; it is not a mute button, and it does not fix the underlying CVE.

FAQ

Why is my VEX statement not suppressing the CVE? Almost always a PURL mismatch. The --product and --subcomponents values have to match the PURLs Grype computed from the SBOM character for character, down to the distro suffix on the version. Dump the real ones with grype sbom:api-1.4.2.cdx.json -o json | jq '.matches[].artifact.purl' and paste them in. Grype does not warn you when a statement matches nothing.

Should I emit CycloneDX or SPDX from Syft? Emit both, as in step 2. The cataloging work happens once and writing a second output file costs nothing, which saves you re-cataloging months later when a downstream tool only reads the format you skipped. Scan whichever one you standardize on with sbom:.

Can I run the Grype gate without network access? Grype needs outbound HTTPS to pull its vulnerability database, but only when you actually update it. Fetch it in an explicit, network-enabled CI step with grype db update, then set GRYPE_DB_AUTO_UPDATE=false so the scan step runs against the frozen copy instead of reaching out mid-pipeline.

What justification do I use when I am not sure the code path is unreachable? None of them. not_affected requires you to pick a specific justification such as vulnerable_code_not_in_execute_path, and asserting one you cannot defend is worse than a noisy scanner. If you have not finished the analysis, the honest status is under_investigation.

Does a not_affected VEX statement mean the CVE is fixed? No. It means you have assessed this specific product and component and concluded the vulnerability is not exploitable there, for a stated reason. The vulnerable package is still in the image, and the day your service starts calling that code path the statement becomes wrong. Review VEX documents when the code they describe changes.

Wrap-up

You now have a stored CycloneDX SBOM, a Grype scan that runs against that artifact rather than a rebuilt image, a pinned-DB CI gate that is genuinely reproducible, and VEX filtering that drops non-exploitable CVEs with a documented reason, vendor CSAF VEX included via the v0.111.0 transformer.

The next move is to attest the SBOM and the VEX document to the image with Cosign v3, so the bill of materials and its exceptions travel with the artifact and cannot be swapped out in your registry. That closes the loop from what is in the image to what you have verifiably signed off on. If you would rather have this wired into your pipelines than build it yourself, get in touch and we can go through it.

Sources