Overview
Per github.com/anchore/syft (opens in new window), Syft generates SBOMs from "container images, filesystems, archives" (OCI, Docker, Singularity). The SBOM is the input artifact for vuln scanning with Grype (Step 7), for SBOM-to-SBOM drift diffing (Step 8), and for compliance delivery in SPDX or CycloneDX format.
SBOMs are mandated by US EO 14028 (software sold to federal agencies), the EU Cyber Resilience Act (products with digital elements, in effect 2024+), and FDA medical-device guidance; internal supply-chain audits also need a full dependency manifest.
When to use
Step 1 - Install
Per sf-gh (opens in new window):
# Homebrew
brew install syft
# Docker
docker run --rm -v "$PWD:/scan" anchore/syft scan dir:/scan -o cyclonedx-json
# Install script into a user-owned dir (-b sets the target; no sudo needed)
curl -sSfL https://get.anchore.io/syft | sh -s -- -b "$HOME/.local/bin"Other paths (consult sf-gh (opens in new window)): Scoop, Chocolatey, Nix.
Step 2 - Basic SBOM generation
Per sf-gh (opens in new window):
# Container image
syft alpine:latest
# Local directory
syft ./my-project
# Specific output format to stdout
syft <image> -o cyclonedx-json
# Multiple formats to files in one pass
syft <image> -o spdx-json=./spdx.json -o cyclonedx-json=./cdx.jsonThe default output is the table format (human-readable); use explicit -o for machine-readable formats in CI.
Step 3 - Output format catalog
Per sf-gh (opens in new window), the common formats are cyclonedx-json (CycloneDX 1.5+, broad support), spdx-json (SPDX 2.3, preferred by US federal procurement), and syft-json (richest metadata). For Grype scan input (Step 7) use syft-json or cyclonedx-json; for compliance delivery the consumer dictates (SPDX-JSON US federal, CycloneDX-JSON most EU) - see sbom-formats for the format-choice guidance. The full format catalog is in references/formats.md.
Step 4 - Source types
Per sf-gh (opens in new window), the common sources are a local Docker image (syft alpine:latest), a remote registry (syft registry:docker.io/alpine:latest), an archive (syft oci-archive:./image.tar), and a directory (syft dir:./my-project). The full source-type syntax table is in references/formats.md.
Step 5 - Attestation pattern (cosign)
For supply-chain integrity, attach the SBOM to the container image via Sigstore cosign:
# Generate SBOM
syft my-image:1.0 -o cyclonedx-json=sbom.json
# Sign + attach to image (Sigstore)
cosign attest --predicate sbom.json --type cyclonedx my-image:1.0
# Verify
cosign verify-attestation --type cyclonedx my-image:1.0The attestation lives alongside the image in the registry; downstream consumers can verify provenance + retrieve the SBOM.
Step 6 - False-positive triage analogue
Syft generates inventories, not findings - there's no FP triage per se. The analogue here is inventory accuracy: ensuring Syft correctly identifies all components.
| Mechanism | Use |
|---|---|
--exclude=PATH_PATTERN | Skip directories from scan (vendor / generated) |
--catalogers=CATALOGER | Restrict to specific catalogers (e.g., npm, python) |
--source-name=NAME / --source-version=VERSION | Override SBOM-level metadata |
--platform=linux/amd64 | Target specific platform for multi-arch images |
Inventory accuracy validation:
# Compare two SBOMs (e.g., before vs after a build change)
syft image:1.0 -o syft-json=v1-sbom.json
syft image:1.1 -o syft-json=v1.1-sbom.json
diff <(jq -S . v1-sbom.json) <(jq -S . v1.1-sbom.json)If Syft misses a component (false negative on inventory), the downstream vuln scan misses any CVEs against that component. Periodic accuracy validation against known dependencies catches this.
Step 7 - Generate + scan workflow (Grype)
Grype is the Anchore vuln scanner that pairs with Syft. Per github.com/anchore/grype (opens in new window), three input modes:
| Input mode | Use |
|---|---|
| Container image | grype alpine:latest (Grype generates SBOM internally) |
| Directory | grype ./my-project (filesystem scan) |
| SBOM input | grype sbom:./sbom.json (no re-generation; faster + auditable) |
The SBOM-input mode is the recommended production pattern: generate the SBOM once via Syft (Step 2), attest via cosign (Step 5), then scan the SBOM. Decoupling gives an audit trail and allows re-scanning with a refreshed vuln DB without re-building. Per gr-gh (opens in new window), coverage spans major OS package ecosystems (Alpine, Debian, Ubuntu, RHEL, Amazon Linux) and language packages (Ruby, Java, JavaScript, Python, .NET, Go, PHP, Rust).
# Install (Homebrew, or the install script into a user-owned dir - no sudo)
brew install grype
# curl -sSfL https://get.anchore.io/grype | sh -s -- -b "$HOME/.local/bin"
# Scan the Syft-generated SBOM (recommended)
grype sbom:./sbom.json
# Output formats: table (default) / json / sarif / cyclonedx-json / template
grype sbom:./sbom.json -o sarif # GitHub Code Scanning
grype sbom:./sbom.json -o json # EPSS + KEV + risk-score annotations
# CI gate: exit 1 at/above severity; focus on upgradable findings
grype sbom:./sbom.json --fail-on high --only-fixedSeverity levels per gr-gh (opens in new window): critical, high, medium, low, negligible, unknown. The JSON output annotates findings with EPSS, KEV, and risk scoring for downstream prioritization.
Suppression (MANDATORY triage): Grype's native path is .grype.yaml ignore rules - per-CVE, per-package+version, or pattern-based (fix-state), each with a mandatory expires: date and a reachability reason:. Full config + justification template: references/grype-ignore-rules.md. OpenVEX status assertions (not_affected / fixed / etc., see vex-author) filter findings in a signed, machine-readable way. Audit ignore entries quarterly; expired entries are removed.
DB management for CI determinism - Grype's DB updates multiple times per day; pin per scan or results vary run to run:
grype db update # manual refresh
grype db status
grype db import grype-db-v6-2026-05-06.tar.gz # pinned version for CICI wiring - anchore/scan-action wraps Grype + SARIF upload:
- uses: anchore/scan-action@v5
with:
sbom: sbom.cyclonedx.json
fail-build: true
severity-cutoff: high
output-format: sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
with: { sarif_file: results.sarif }Grype's DB is Anchore-curated; coverage differs from OSV.dev / Snyk / NVD - pair with osv-scanner or snyk-test for consensus. No reachability analysis: every CVE on a declared component counts.
Step 8 - Diffing SBOMs across versions
An SBOM diff turns two point-in-time inventories into a change signal: which components are net-new, removed, or version-changed between image or build versions. Per github.com/CycloneDX/cyclonedx-cli (opens in new window), the CycloneDX CLI provides a first-class diff subcommand accepting any two CycloneDX BOMs (XML, JSON, or Protobuf):
# Install: brew install cyclonedx/cyclonedx/cyclonedx-cli
# or: docker run cyclonedx/cyclonedx-cli ...
# Generate both inventories with the SAME source type + format
syft myapp:1.0 -o cyclonedx-json=sbom-v1.0.json
syft myapp:1.1 -o cyclonedx-json=sbom-v1.1.json
# Human-readable diff
cyclonedx diff sbom-v1.0.json sbom-v1.1.json --component-versions
# Machine-readable diff for CI gate logic
cyclonedx diff sbom-v1.0.json sbom-v1.1.json \
--component-versions --output-format json > diff-result.jsonKey flags per cdx-cli-gh (opens in new window): --component-versions (the signal flag - reports added / removed / version-changed components; without it only structural BOM changes are reported), --from-format / --to-format (autodetect / json / xml / protobuf), --output-format (text / json).
The JSON output carries three lists: added (net-new deps - review every entry: expected transitive dep, base-image update, or supply-chain risk), removed (may hide a dep renamed by a packaging change), and modified (version changes with old + new values). Minimal CI gate:
ADDED=$(jq '.added | length' diff-result.json)
if [ "$ADDED" -gt "0" ]; then
echo "::error::Net-new components detected. Review diff-result.json."
jq '.added' diff-result.json
exit 1
fiThe full GitHub Actions gate with allowlist-subtraction, plus the nightly-drift workflow (diff production tag vs last known-good SBOM, rotate the baseline only on a clean diff), are in references/diff-ci-workflows.md.
Diff rules of thumb: always pass --component-versions; generate both SBOMs with the same tool, format, and source type (dir: vs image scope mismatch produces noisy diffs); diff multi-arch images per-platform (--platform=linux/amd64 on both runs); SPDX SBOMs need cyclonedx convert first. The diff shows inventory change, not vulnerability change - scan net-new components with Grype (Step 7).
Step 9 - CI integration
jobs:
sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: anchore/sbom-action@v0
with:
path: ./
format: cyclonedx-json
output-file: sbom.cyclonedx.json
- uses: anchore/sbom-action@v0
with:
path: ./
format: spdx-json
output-file: sbom.spdx.json
- uses: actions/upload-artifact@v4
with:
name: sboms
path: sbom.*.jsonThe anchore/sbom-action GHA wraps Syft + handles GitHub dependency-graph submission automatically when format: github-json.
Step 10 - Composition with sister tools
| Sister tool | Use |
|---|---|
sbom-formats | Reference for CycloneDX + SPDX schemas and format choice |
trivy-image | Alternative scanner (built-in SBOM gen + scan in one pass) |
vex-author | OpenVEX documents that filter Grype findings (Step 7) |
osv-scanner | Cross-plugin: also accepts SBOM input |
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Generate SBOM only at release time | Misses build-time inventory differences | Generate per CI build + attest |
| Use single format only | Different consumers need different formats | Generate both CycloneDX + SPDX (Step 2) |
| Skip platform targeting on multi-arch images | Misses platform-specific deps | --platform=linux/amd64 (Step 6) |
| Generate SBOM but don't attest / sign | Provenance unverifiable downstream | Cosign attest pattern (Step 5) |
| Trust Syft inventory without validation | Misses components → misses CVEs | Periodic accuracy check (Step 6) |
| Re-generate SBOM via Grype on every scan | Slow + non-deterministic; SBOM lives outside scan | Use sbom: input mode (Step 7) |
.grype.yaml ignore without expires: | Permanent debt | Mandatory expires: (Step 7) |
| Skip Grype DB pin in CI | Different DB version per run; non-deterministic | Pin DB version (Step 7) |
Diff without --component-versions | Reports only structural BOM changes, not inventory delta | Always pass the flag (Step 8) |
| Diff SBOMs from different tools / scan depths | Format + scope mismatches appear as false diffs | Same generator, format, source type (Step 8) |
Limitations
References
Full GitHub Actions workflows for the SBOM-diff step of syft-generation. The SKILL.md spine keeps the minimal jq gate; the complete CI-gate job, the allowlist pattern, and the nightly drift-detection workflow live here.
CI gate on net-new components
- name: SBOM diff gate
run: |
ADDED=$(jq '.added | length' diff-result.json)
echo "Net-new components: $ADDED"
if [ "$ADDED" -gt "0" ]; then
echo "::error::Net-new components detected. Review diff-result.json."
jq '.added' diff-result.json
exit 1
fiTo allow a pre-approved set of additions (a known intentional dependency upgrade), maintain an allowlist and subtract matches before the count check:
UNAPPROVED=$(jq --rawfile allow allowlist.txt \
'[.added[] | select(.name as $n | $allow | test($n) | not)] | length' \
diff-result.json)Adjust the gate threshold and allowlist policy to the team's change-control requirements; the CI step above enforces zero-tolerance as the strictest form.
Nightly drift detection
For production image monitoring, run a nightly diff against the last known-good SBOM rather than comparing two build artifacts:
jobs:
sbom-drift:
runs-on: ubuntu-latest
schedule:
- cron: "0 2 * * *"
steps:
- name: Generate current SBOM
run: syft myapp:production -o cyclonedx-json=sbom-current.json
- name: Download last known-good SBOM
run: |
aws s3 cp s3://sbom-store/sbom-last-good.json sbom-baseline.json
- name: Diff
run: |
cyclonedx diff sbom-baseline.json sbom-current.json \
--component-versions --output-format json \
> drift-result.json
- name: Alert on drift
run: |
ADDED=$(jq '.added | length' drift-result.json)
REMOVED=$(jq '.removed | length' drift-result.json)
if [ "$ADDED" -gt "0" ] || [ "$REMOVED" -gt "0" ]; then
echo "Supply-chain drift detected"
cat drift-result.json
# Pipe to Slack/PagerDuty/JIRA as needed
exit 1
fi
- name: Rotate known-good on clean diff
if: success()
run: aws s3 cp sbom-current.json s3://sbom-store/sbom-last-good.jsonThe "rotate known-good on clean diff" step ensures the baseline advances only when the image passes the gate, catching regressions introduced in a later build.
Full catalogs extracted from syft-generation. Per github.com/anchore/syft (opens in new window), Syft supports multiple SBOM output formats and scan-source types; the SKILL.md spine keeps the common ones inline and links here for the complete tables.
Output format catalog
| Format | Use |
|---|---|
cyclonedx-json | CycloneDX 1.5+ JSON; broad ecosystem support |
cyclonedx-xml | CycloneDX XML (older toolchains) |
spdx-json | SPDX 2.3 JSON; preferred by US Federal procurement |
spdx-tag-value | SPDX tag-value format (legacy) |
syft-json | Syft-native JSON; richest metadata |
table | Human-readable terminal table (default) |
github-json | GitHub dependency-graph submission format |
For Grype scan input (SKILL.md Step 7), use syft-json (richest metadata) or cyclonedx-json (broader compat). For compliance delivery, the consumer's requirement dictates: SPDX-JSON for US federal, CycloneDX-JSON for most EU contexts.
Source types
| Source | Syntax |
|---|---|
| Local Docker daemon | syft alpine:latest |
| OCI / remote registry | syft registry:docker.io/alpine:latest |
| OCI archive (tar) | syft oci-archive:./image.tar |
| Docker archive (tar) | syft docker-archive:./image.tar |
| Local directory | syft dir:./my-project (or syft ./my-project) |
| File | syft file:./pom.xml |
| Singularity image | syft singularity:./image.sif |
Full suppression examples for the Grype scan step of syft-generation. Per github.com/anchore/grype (opens in new window): "Configuration can be managed through .grype.yaml files with ignore rules for customized scanning behavior."
Example config
# .grype.yaml
ignore:
# Per-CVE ignore
- vulnerability: CVE-2024-1234
reason: "Reachability analysis confirms unreachable; tracked in JIRA-1234"
expires: 2026-12-15
# Per-package + version ignore
- package:
name: lodash
version: 4.17.20
vulnerability: CVE-2024-5678
reason: "Test fixture; not in production dependency graph"
expires: 2026-09-30
# Pattern-based ignore (per-fix-state)
- vulnerability: GHSA-*
fix-state: not-fixed
reason: "Pending vendor fix; not exploitable in our context"
expires: 2026-12-15Justification template (mandatory in .grype.yaml)
Every ignore entry needs a reachability reason, an approver, and an expiry date:
ignore:
- vulnerability: CVE-2024-1234
reason: |
Reachability: vulnerable function `parse_xml` not called from
production code paths (verified via static analysis 2026-05-15).
Component is required for test fixtures only.
approved-by: alice@example.com
expires: 2026-09-15
re-review-date: 2026-09-15