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.
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
curland 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.
Step-by-step
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.
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.
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.
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
--productor--subcomponentsPURLs in the VEX document do not exactly match the PURLs Grype computed from the SBOM. Check the real PURL withgrype 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 (oroci-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_affectedwithout a justification.vexctland downstream consumers reject it. If you genuinely cannot justify it, the honest status isunder_investigation, notnot_affected. VEX narrows the exploitable set; it is not a mute button, and it does not fix the underlying CVE.
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.
Sources
- https://oss.anchore.com/docs/guides/vulnerability/filter-results/
- https://github.com/anchore/grype/releases
- https://github.com/openvex/vexctl
- https://www.chainguard.dev/unchained/vexed-then-grype-about-it-chainguard-and-anchore-announce-grype-supports-openvex
- https://github.com/anchore/syft/releases
Comments
Be the first to comment.