SBOM Generation and Vulnerability Scanning with Syft, Grype, and Supply Chain Policy Enforcement
How to generate SBOMs with Syft, scan them with Grype, and enforce supply chain policies that actually block vulnerable builds in CI.
When Log4Shell dropped in December 2021, most teams spent the first 48 hours just trying to answer one question: do we even use Log4j? Not “are we patched” — that came later. The first crisis was pure inventory: which services, which container images, which transitive dependencies buried three levels deep pulled in log4j-core. Teams with mature SBOMs answered in hours. Everyone else was still running grep -r "log4j" across repos two days later, missing the artifact in their Gradle cache or the vendored JAR in a legacy service.
Software Bill of Materials (SBOM) is not a compliance checkbox. It is the foundation of any supply chain security posture that can respond faster than an incident. This post covers generating SBOMs with Syft, scanning them with Grype, and enforcing policy so vulnerable builds never reach production.
What an SBOM Actually Is
An SBOM is a structured inventory of every package, library, and component in a software artifact — container image, binary, directory, or archive. Two dominant formats exist: SPDX (ISO/IEC 5962:2021, backed by Linux Foundation) and CycloneDX (OWASP-backed, JSON/XML). They are not interchangeable in practice: CycloneDX has richer vulnerability and service relationship metadata; SPDX has broader toolchain support for license compliance. For supply chain security, CycloneDX is the better choice. For legal/compliance teams that need license audit trails, SPDX.
Syft produces both. Grype consumes both. Pick CycloneDX unless you have a specific reason not to.
Generating SBOMs with Syft
Syft, from Anchore, scans container images, filesystems, archives, and source directories. It understands package managers across every major ecosystem: Go modules, Python pip/conda/poetry, Node npm/yarn/pnpm, Java Maven/Gradle, Ruby gems, Rust cargo, and more.
# snippet-1
# Scan a container image and emit CycloneDX JSON
syft anchore/grype:latest -o cyclonedx-json=grype-sbom.json
# Scan a local filesystem (useful in CI before image build)
syft dir:. -o cyclonedx-json=app-sbom.json
# Scan an OCI image already on disk (from docker save)
syft oci-archive:my-image.tar -o cyclonedx-json=image-sbom.json
# Multiple outputs in one pass
syft anchore/grype:latest \
-o cyclonedx-json=sbom.cdx.json \
-o spdx-json=sbom.spdx.json \
-o syft-json=sbom.syft.json
The syft-json format is Syft’s native schema — more detailed than either standard format, useful when you need raw cataloger output for custom tooling. The others are for interoperability.
One non-obvious production detail: Syft’s image scanning behavior differs depending on how you provide the image. When you do syft my-image:tag, it pulls from the daemon or registry. When you do syft oci-archive:path.tar, it scans the saved artifact. For reproducible CI pipelines, always scan the exact digest you’re about to push, not a tag that could theoretically be re-tagged between scan and deploy.
# snippet-2
# Production pattern: pin to digest, not tag
IMAGE_DIGEST=$(docker buildx build \
--platform linux/amd64 \
--output type=image,push=false \
--metadata-file metadata.json \
. && jq -r '."containerimage.digest"' metadata.json)
# Scan the exact artifact
syft "docker.io/myorg/myservice@${IMAGE_DIGEST}" \
-o cyclonedx-json=sbom.cdx.json \
--config .syft.yaml
Syft’s .syft.yaml configuration file handles exclusions, cataloger overrides, and scope settings. You almost always want to tune the scope in production:
# snippet-3
# .syft.yaml — production configuration
package:
search-unindexed-archives: true
search-indexed-archives: true
catalogers:
- go-module-binary-cataloger
- go-module-file-cataloger
- python-index-cataloger
- python-package-cataloger
- java-archive-cataloger
- javascript-package-cataloger
- javascript-lock-cataloger
file:
metadata:
cataloger:
enabled: true
digests:
- sha256
# Exclude test fixtures and vendor mocks
exclude:
- "**/testdata/**"
- "**/*_test.go"
# Ensure layer content is fully unpacked
scope: all-layers
The scope: all-layers vs squashed distinction matters for security scanning. Squashed only sees the final filesystem state — a file added in layer 3 and deleted in layer 5 disappears. all-layers sees everything that ever existed in the image, including files that were removed. Deleted files cannot be exploited at runtime, but if your policy is “no known-critical packages ever present in the build,” use all-layers.
Scanning with Grype
Grype takes an SBOM or image reference and matches components against its vulnerability database (NVD, GitHub Advisory Database, RedHat, Debian, Ubuntu, Alpine, and more).
# snippet-4
# Scan an SBOM file (faster — no re-cataloging)
grype sbom:sbom.cdx.json
# Scan with specific output format and fail on severity threshold
grype sbom:sbom.cdx.json \
--output json \
--fail-on high \
--file grype-results.json
# Update the vulnerability DB before scanning (do this in CI)
grype db update
grype sbom:sbom.cdx.json --output table
# Check DB status and age
grype db status
The --fail-on flag is your gate. In production pipelines, high is a reasonable starting threshold. critical is too permissive during incidents like Log4Shell. medium will generate so much noise you’ll disable it in a week. Start at high, tune down to medium on a per-service basis once you have a baseline.
Grype’s configuration file handles ignore rules, fail-on behavior, and DB configuration:
# snippet-5
# .grype.yaml — production configuration
db:
update-url: https://toolbox-data.anchore.io/grype/databases/listing.json
auto-update: false # In CI, control DB updates explicitly
validate-by-age: true
max-allowed-built-age: 120h # Fail if DB is older than 5 days
output: json
file: grype-results.json
fail-on: high
only-fixed: false # Show unfixed too — you need to know
# Ignore specific CVEs with documented rationale
ignore:
# Ignore CVEs in dev dependencies never deployed to prod
- vulnerability: CVE-2023-45853
package:
name: zlib
version: "1.2.11"
type: deb
reason: "Only present in builder stage, not final image"
# Time-bounded ignore while upstream patch is in flight
- vulnerability: CVE-2024-12345
package:
name: openssl
fix-state: not-fixed # Auto-re-enables once a fix exists
# Restrict to specific namespaces for noise reduction
search:
scope: all-layers
unindexed-archives: true
Two Grype behaviors that bite teams in production: First, only-fixed: false is the correct default. Teams that set only-fixed: true to reduce noise miss critical vulnerabilities where upstream hasn’t released a patch yet — those are exactly the vulnerabilities you need visibility into. Second, auto-update: false with explicit DB update steps in CI gives you reproducible scan results. Auto-update means two pipeline runs from the same commit can produce different results if the DB updated between them.
Integrating into CI/CD
The SBOM and scan steps belong in two places: post-build (before push) and scheduled rescans (weekly, against already-deployed images). The post-build gate blocks new vulnerabilities from reaching the registry. The rescan catches vulnerabilities disclosed after the image was built.
# snippet-6
# GitHub Actions — production supply chain security job
name: Supply Chain Security
on:
push:
branches: [main]
pull_request:
jobs:
sbom-scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # For SARIF upload to GitHub Security tab
steps:
- uses: actions/checkout@v4
- name: Build image
run: |
docker build \
--label "org.opencontainers.image.revision=$" \
-t myservice:$ \
.
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: myservice:$
format: cyclonedx-json
output-file: sbom.cdx.json
artifact-name: sbom-$.cdx.json
- name: Update Grype DB
run: grype db update
- name: Scan SBOM
uses: anchore/scan-action@v3
id: scan
with:
sbom: sbom.cdx.json
fail-build: true
severity-cutoff: high
output-format: sarif
output-file: grype-results.sarif
- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always() # Upload even on failure so you can see what blocked
with:
sarif_file: grype-results.sarif
- name: Attach SBOM to image
if: github.ref == 'refs/heads/main'
run: |
# Attach SBOM as attestation using cosign
cosign attest \
--predicate sbom.cdx.json \
--type cyclonedx \
myservice:$
The SARIF upload is critical for team adoption. Developers can see vulnerabilities in the GitHub Security tab alongside code scanning results, without switching tools. The cosign attestation step embeds the SBOM into the registry as a verifiable artifact — consumers of your image can fetch and verify it.
Supply Chain Policy Enforcement
Scanning produces findings. Policy enforcement decides what to do with them. The most common failure mode is treating Grype’s --fail-on as sufficient policy — it handles severity thresholds but nothing else. Production supply chain policy needs to address:
- License compliance: no GPL-3 in commercial artifacts
- Package origin restrictions: internal packages must come from internal registry
- Age policy: base images older than 90 days must be rebuilt
- Known-bad packages: any version of
event-stream(the compromised npm package) should block build - Attestation verification: images without valid SBOM attestation cannot be deployed
Conftest with OPA Rego handles the richer policy cases that Grype’s config cannot express:
# snippet-7
# policy/sbom.rego — CycloneDX SBOM policy enforcement
package sbom
import future.keywords.contains
import future.keywords.if
# Deny GPL-3 licensed components in production artifacts
deny contains msg if {
component := input.components[_]
license := component.licenses[_].license
license.id == "GPL-3.0-only"
msg := sprintf(
"GPL-3.0 license found in component %s@%s — not permitted in production artifacts",
[component.name, component.version]
)
}
# Deny known-compromised packages regardless of version
compromised_packages := {
"event-stream",
"flatmap-stream",
"ua-parser-js", # October 2021 supply chain attack
}
deny contains msg if {
component := input.components[_]
component.name in compromised_packages
msg := sprintf(
"Known-compromised package detected: %s — remove immediately",
[component.name]
)
}
# Warn on base image staleness via metadata
warn contains msg if {
metadata := input.metadata
created := time.parse_rfc3339_ns(metadata.timestamp)
age_days := (time.now_ns() - created) / (24 * 60 * 60 * 1e9)
age_days > 90
msg := sprintf(
"SBOM is %.0f days old — rebuild from updated base image",
[age_days]
)
}
# snippet-8
# Enforce SBOM policy in CI using conftest
# Install: https://www.conftest.dev/install/
# Test SBOM against policy bundle
conftest test sbom.cdx.json \
--policy ./policy \
--namespace sbom \
--output json \
--all-namespaces
# Example output on failure:
# FAIL - sbom.cdx.json - sbom - GPL-3.0 license found in component pygments@2.11.2
# FAIL - sbom.cdx.json - sbom - Known-compromised package detected: event-stream
# Combine Grype + Conftest in a single gate script
#!/bin/bash
set -euo pipefail
SBOM_FILE="${1:-sbom.cdx.json}"
GRYPE_EXIT=0
CONFTEST_EXIT=0
grype db update
grype "sbom:${SBOM_FILE}" --fail-on high --output json --file grype-results.json || GRYPE_EXIT=$?
conftest test "${SBOM_FILE}" \
--policy ./policy \
--namespace sbom \
--output json \
--output-file conftest-results.json || CONFTEST_EXIT=$?
if [[ $GRYPE_EXIT -ne 0 ]] || [[ $CONFTEST_EXIT -ne 0 ]]; then
echo "Supply chain policy check FAILED"
echo "Grype exit: ${GRYPE_EXIT}, Conftest exit: ${CONFTEST_EXIT}"
exit 1
fi
echo "Supply chain policy check PASSED"
What Teams Get Wrong
Scanning only at build time. A vulnerability disclosed six months after you built and deployed your image is just as exploitable as one present at build time. Scheduled rescans with Grype against your registry — daily for critical services, weekly for others — give you the detection surface you need.
Ignoring the vulnerability DB age. Grype’s database has a freshness SLA. A 30-day-old DB running against a 6-month-old image is theater. Configure max-allowed-built-age and fail the scan explicitly if the DB is stale. If the DB update fails, the correct behavior is to fail the pipeline, not skip the scan.
Not storing SBOMs. The SBOM you generated at build time is forensic evidence for future incidents. Attach it to the image using cosign attestations (verifiable, stored in the registry) and archive it in object storage with a retention policy tied to how long you support the artifact. When a zero-day drops, the first thing you need is the SBOM from the affected deployment — not the one you’d generate by re-scanning today’s image.
Ignoring transitive dependencies. Package managers report what’s in your lockfile. Syft goes further — it finds JARs inside JARs, wheels embedded in containers, and Go binaries compiled with vulnerable stdlib versions. The Java archive cataloger unpacks nested archives by default. Do not disable it.
Treating SBOM as a one-time artifact. SBOM has a lifecycle. When you add a dependency, the SBOM is stale. When a vulnerability is patched, Grype results change. When you promote from staging to production, the attestation chain should reflect that. Integrating SBOM generation into every build (not as a separate security job) keeps the lifecycle correct.
Operational Runbook Integration
When Grype finds a critical vulnerability in a deployed image, you need a response runbook. The SBOM tells you exactly which packages are affected. Grype’s JSON output gives you the CVE ID, affected package, installed version, and fixed version. From there:
- Check if the vulnerability is exploitable in your runtime context (is the vulnerable code path reachable?)
- Check
fix-statein Grype output —fixedmeans upgrade,not-fixedmeans mitigate - If upgrade is available: patch, rebuild, rescan, redeploy
- If no fix: apply mitigating controls (WAF rules, network isolation, feature flag to disable the code path), document the accepted risk with an expiry date, add a time-bounded ignore rule in
.grype.yaml
The ignore rule with fix-state: not-fixed is particularly useful — it automatically removes the exemption once upstream ships a patch, forcing a decision rather than letting accepted risks rot forever.
Supply chain security compounds. The teams that have a clean baseline, consistent SBOM generation, and enforced policy gates at build time find that the marginal cost of staying compliant is low. The teams scrambling during the next Log4Shell equivalent are the ones who deferred it. ```