Syft & Grype
Syft and Grype are two open-source command-line tools from Anchore, and this course covers them on one page because they were built to be used as a pair. Syft's job is narrow and specific: point it at a container image, a directory, or an archive, and it catalogs every package it can identify — OS packages, language dependencies, even statically-linked binaries — into a Software Bill of Materials, written out as SPDX, CycloneDX, or Syft's own native syft-json. Grype's job picks up exactly where that leaves off: take the resulting inventory — or point Grype straight at an image, which quietly catalogs it using Syft's own library internally — and match every package's exact identity against a locally cached vulnerability database pulled from the NVD, GitHub's advisory data, and a dozen distro-specific security trackers. Run separately they're two useful tools; piped together — syft <image> -o json | grype — they're the "build the inventory once, check it as many times as you need" pattern most real container pipelines are actually built around, and the SBOM Syft produces along the way survives as a reusable artifact in its own right, long after that particular scan is over.
Imagine a grocery delivery arrives in a sealed box. One person's job is to unpack it and write down exactly what's inside — brand, size, expiry date, every single item, no exceptions. That's Syft: it doesn't judge anything, it just makes the complete, honest list. A second person's job is to take that list to a big recall bulletin board and check every item against it: "has anything on my list been recalled?" That's Grype. The clever part is the second person never has to unpack the box themselves — someone already did that work and wrote it down, so the list gets checked in seconds, and the exact same list can be checked again next month against whatever new recalls have shown up since.
What Syft and Grype are, and the job they split between them
☺ Like you're 10: Syft answers "what's actually in here," Grype answers "is any of that known-bad" — two different questions, two small tools, one pipe between them.
Both tools come from Anchore, a company that built its whole product line — Syft, Grype, and the commercial Anchore Enterprise platform they underpin — around one conviction: you can't scan what you haven't first inventoried precisely. Both are written in Go, ship as single static binaries with no runtime dependency, and are released under the Apache License 2.0, which is why they show up embedded inside other tools' pipelines as often as they're run standalone — Docker's own docker sbom CLI plugin was built directly on Syft before Docker Scout existed, and Anchore's own GitHub Actions, anchore/sbom-action and anchore/scan-action, wrap exactly this pair for teams that don't want to manage the binaries by hand.
The architectural fact that actually explains why this page covers both tools together, rather than splitting them, is this: Grype imports Syft as a Go library. When you hand Grype a pre-built SBOM file, it trusts that inventory and skips straight to matching. When you hand Grype an image or a directory instead, it doesn't have its own separate cataloging code — it calls Syft's cataloging library internally, produces the same inventory Syft's own CLI would have produced, and then matches against it. There is no world where Grype scans something without an SBOM existing somewhere in the process; the only choice you actually make is whether that SBOM gets written to a file you keep, or gets built and thrown away in memory during a single scan.
Treat "run Syft, then feed the output to Grype" as the default, not an optimization. Scanning an image with Grype directly produces an identical vulnerability result to piping in a pre-built SBOM — Grype is running the same cataloger either way — but only the piped version leaves you with a file. That file is what you sign with cosign, archive as a release artifact, hand to a second scanner, or re-check next month against a database that's moved on without ever touching the image again. Skipping the SBOM step doesn't save real work; it just throws away the one artifact that outlives the scan.
Architecture: one catalog pass, two ways to spend it
☺ Like you're 10: First Syft builds the list (once, from a real look inside), then Grype checks the list against what's known dangerous (as many times as you like, without ever unpacking the box again).
Producing a Grype result is, underneath, always the same two-phase shape this course keeps coming back to for scanners generally: an expensive, one-time build step, followed by a cheap, repeatable check step. Syft's catalogers are the build step — one specialized detector per ecosystem, running in parallel, each looking for the files that ecosystem leaves behind: apk, dpkg, and rpm package databases for OS-level packages; package-lock.json, requirements.txt/poetry.lock, go.mod/compiled-binary build info, Gemfile.lock, Cargo.lock, composer.lock, and JAR/POM metadata for language dependencies; plus a binary classifier cataloger that fingerprints statically-linked binaries by matching known version-string patterns, which is what lets Syft identify something like a vendored Go runtime or a bundled OpenSSL inside a minimal image with no package manager present at all. Each cataloger emits a package with a name, a version, an ecosystem type, and — where the file format carries it — a license and a package URL (purl), a standardized string that names a package's exact coordinates the way pkg:npm/lodash@4.17.21 does.
Grype's own step, matching, prefers to match by package identity wherever a data source supports it directly: a purl or an ecosystem-specific advisory record ties a vulnerability to an exact name, version, and ecosystem, so an unrelated package that merely shares a name string can't misfire a match. Only where no such direct advisory link exists — mostly certain OS packages and a handful of ecosystems without a clean purl-to-advisory mapping — does Grype fall back to guessing a CPE (Common Platform Enumeration) string from the package's metadata and matching that against the NVD's own CPE dictionary, the same looser matching strategy that gives CPE-first tools like OWASP Dependency-Check a materially higher false-positive rate. Dependency-Check's own comparison of this page puts it plainly: Syft's SBOM identifies exact package coordinates rather than an inferred one, and Grype's matching stays low-false-positive specifically because it's built to prefer that exact identity whenever it's available.
"Purl-first, CPE fallback" is the one sentence worth remembering about Grype's whole matching strategy. It explains why a Grype scan against a well-cataloged image tends to produce a shorter, more trustworthy findings list than a CPE-first scanner would against the same image — and it also explains exactly where Grype can still misfire: any package Syft could only identify loosely (an OS package with no clean advisory feed, or a binary the classifier fingerprinted heuristically) inherits the same guessing-based risk any CPE matcher carries.
What you actually produce: SBOM formats and the two config files
☺ Like you're 10: One command writes the list in whichever paperwork format someone downstream needs; two small config files remember your defaults so you're not retyping the same flags every time.
A real Syft invocation almost always names an output format, and Syft can write more than one at once by repeating -o format=path:
$ syft ghcr.io/acme/checkout:2.4.0 \
-o table \
-o syft-json=sbom.syft.json \
-o cyclonedx-json=sbom.cdx.json \
-o spdx-json=sbom.spdx.jsonsyft-json is the native format and the richest one — it's the only format that preserves exactly which file inside the image or filesystem each package's evidence came from, which matters when you're debugging why a package was or wasn't found. CycloneDX (an OWASP project) is the format most vulnerability-scanning-centric tooling — Grype included — treats as the default interchange format, and it's the format that can also embed a VEX (Vulnerability Exploitability eXchange) statement suppressing a match a vendor has said doesn't actually apply. SPDX (an ISO/IEC standard, ISO/IEC 5962:2021) is the format most compliance and legal workflows expect, including the NTIA's minimum-elements guidance that US federal software-supply-chain rules increasingly point back to. Converting between the two after the fact is possible — syft convert sbom.syft.json -o cyclonedx-json=sbom.cdx.json — but it's lossy going through anything other than syft-json, so decide your team's canonical format early rather than treating conversion as free.
A trimmed syft-json entry for one package looks like this — the shape worth recognizing, since it's what Grype is actually matching against:
{
"name": "openssl",
"version": "3.0.13-1~deb12u1",
"type": "deb",
"purl": "pkg:deb/debian/openssl@3.0.13-1~deb12u1?arch=amd64&distro=debian-12",
"licenses": [{ "value": "OpenSSL", "type": "declared" }],
"locations": [{ "path": "/var/lib/dpkg/status" }]
}Syft's own defaults live in a config file, checked for at .syft.yaml (or passed with -c). The schema's exact key names have moved between minor releases, so verify the field names below against syft config's current output before relying on precise wording:
# .syft.yaml — defaults, so CI doesn't need every flag spelled out on every invocation
scope: squashed # squashed = final image state (default); all-layers = every layer's history
exclude:
- "./vendor/**"
- "**/*_test.go"
package:
cataloger:
enabled: [] # empty = every default cataloger runs
output: "syft-json"Grype's config file, .grype.yaml, is where the day-to-day judgment calls actually live — which severity fails a build, and which findings a team has reviewed and decided not to act on yet:
# .grype.yaml
fail-on-severity: high
output: "table"
ignore:
- vulnerability: CVE-2023-12345
reason: "No fix available upstream yet; mitigated by network policy — reviewed 2026-06-01"
- vulnerability: CVE-2022-98765
fix-state: "not-fixed" # ignore this class broadly: nothing actionable exists yet
- package:
name: "busybox"
version: "1.35.0-r17"
db:
auto-update: true
cache-dir: "~/.cache/grype/db"Grype's vulnerability data is downloaded, not bundled in the binary, and it's refreshed on a schedule measured in hours, not weeks — check grype db status for exactly how current the cached copy is, since the precise refresh cadence has shifted across releases and is worth confirming against Anchore's own docs rather than assumed. A cold CI runner that never calls grype db update, or an air-gapped environment with no path to the update source at all, doesn't fail loudly — it scans against whatever is cached, or against nothing, and reports "no matches found" either way. That result is indistinguishable from a genuinely clean image unless someone checks the DB's age first. Cache the DB directory between CI runs (it's a few hundred megabytes, refreshed multiple times a day, so a cold pull on every run is also just slow) and, for air-gapped environments, stage an internal mirror rather than assuming the default update source is reachable.
Day-to-day commands
☺ Like you're 10: A handful of commands cover almost everything: catalog it, scan it, gate on it, and keep the answer key fresh.
# Syft — cataloging $ syft version $ syft ghcr.io/acme/checkout:2.4.0 -o table # human-readable, straight to the terminal $ syft dir:./checkout -o spdx-json > sbom.spdx.json # scan a local filesystem, not an image $ syft ghcr.io/acme/checkout:2.4.0 -o cyclonedx-json=sbom.cdx.json $ syft convert sbom.syft.json -o cyclonedx-json=sbom.cdx.json # reformat an existing SBOM $ syft attest ghcr.io/acme/checkout:2.4.0 --output cyclonedx-json --key cosign.key # ^ builds the SBOM and hands it to cosign to sign as an in-toto attestation — see Sigstore & cosign # the core pattern: catalog once, pipe straight into the scanner — no second cataloging pass $ syft ghcr.io/acme/checkout:2.4.0 -o json | grype # Grype — scanning $ grype sbom:./sbom.cdx.json # scan a saved SBOM file directly $ grype ghcr.io/acme/checkout:2.4.0 # scan an image directly — catalogs via Syft internally $ grype dir:./checkout # scan a filesystem directly # gating a build $ grype sbom:./sbom.cdx.json --fail-on high -o json > grype-results.json $ echo $? # nonzero if a HIGH or CRITICAL match was found # keeping the vulnerability database current $ grype db status $ grype db update
Wiring both into CI usually means either driving the binaries directly, or reaching for Anchore's own GitHub Actions, which wrap the same two commands with sensible defaults and a SARIF output ready for GitHub's code-scanning tab:
# .github/workflows/sbom-and-scan.yml
name: SBOM & vulnerability scan
on: [push, pull_request]
jobs:
sbom-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t ghcr.io/acme/checkout:${{ github.sha }} .
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: ghcr.io/acme/checkout:${{ github.sha }}
format: cyclonedx-json
output-file: sbom.cdx.json
- name: Scan the SBOM for vulnerabilities
uses: anchore/scan-action@v3
id: grype-scan
with:
sbom: sbom.cdx.json
fail-build: true
severity-cutoff: high
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ${{ steps.grype-scan.outputs.sarif }}
- name: Archive the SBOM as a build artifact
uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.cdx.jsonVerify the current input names on the Actions Marketplace before pinning a version — both Actions have reshaped their inputs across major-version bumps, the same way most fast-moving GitHub Actions do. The shape worth keeping regardless of the exact flags: generate once, scan the generated file rather than the image a second time, upload the scan's SARIF for inline PR annotations, and keep the SBOM itself as a retained build artifact — it's the thing you'll want six months from now when a new CVE lands and the question is simply "were we ever running that."
Pick any image you can pull locally. Run syft <image> -o table and skim the package list — count how many entries came from the base OS versus your application's own dependencies. Then run syft <image> -o cyclonedx-json=sbom.json followed by grype sbom:sbom.json, and separately run grype <image> directly against the same image. Compare the two Grype runs: the vulnerability list should match exactly, because both ran the identical Syft cataloger underneath — the only real difference is that one left a file behind. Finally, add one line to a .grype.yaml ignoring a specific CVE by ID with a reason, rerun with --fail-on high, and watch that one finding drop out of the gate while everything else still reports.
Gotchas and failure modes
☺ Like you're 10: Most surprises trace back to one of two facts — the answer key has to be downloaded and kept fresh, and an empty list can mean "nothing found" instead of "nothing exists."
- A stale or missing local database reports clean, not "unknown." Covered above, and worth repeating as the single highest-impact gotcha on this page:
grype db updateis not automatic in every environment, and a scan against an outdated cache produces a passing result that looks identical to a genuinely clean one. - An empty SBOM from a truly minimal image doesn't mean nothing is there. Syft's binary classifier is heuristic — it fingerprints statically-linked binaries by matching known version-string patterns, which works well for common runtimes and libraries and can miss a custom-built or aggressively stripped binary entirely. A
scratch-based image with one hand-built static binary can legitimately produce a near-empty SBOM, and "Syft found nothing" reads very differently from "Syft confirmed there is nothing" — only the second one is actually true. scopeis a real judgment call, not a default to ignore. Syft's defaultsquashedscope catalogs only what's present in the final image layer — the honest answer to "what actually ships."all-layersscope walks every layer's history instead, which surfaces build-time tooling or secrets left in an earlier layer even if a later layer deleted them — useful for supply-chain forensics on a poorly multi-staged Dockerfile, but it will also report packages that were never actually present in the runtime image, which can mislead a reader who expects the SBOM to describe what ships.- CPE fallback carries the same false-positive risk any CPE matcher does. The purl-first matching described above is Grype's default behavior, not a guarantee — any package Syft could only identify loosely inherits Grype's CPE-guessing fallback, and that fallback can match an unrelated package that happens to share a name or vendor string. Review a CPE-derived finding with the same skepticism you'd apply to a raw Dependency-Check result before treating it as confirmed.
- Not every advisory carries a normalized severity. Some ecosystem feeds report only a source-specific rating, or none at all, so
--fail-on mediumcan let a real finding through simply because it has no severity Grype could compare against the threshold. Check the JSON output's severity field distribution, not just the pass/fail exit code, if a gate needs to be airtight. - A cold DB pull on every CI run is slow and occasionally rate-limited. Fetching a multi-hundred-megabyte database on a fresh runner every single build is the most common source of a "Grype is slow" complaint — cache
~/.cache/grype/dbbetween runs (anactions/cachestep keyed on the DB's own checksum works well) rather than accepting a full download on every invocation.
Where Syft & Grype sit against Trivy, Dependency-Check, and Snyk
☺ Like you're 10: Different tools spend the SBOM step differently — one binary that does everything, one pair that keeps the inventory as its own reusable thing, one that guesses identity instead of reading it, and one that pays a vendor to keep the answer key current.
| Tool | Model | Best when | Costs you |
|---|---|---|---|
| Syft & Grype (Anchore) | Two decoupled OSS binaries: Syft catalogs to a portable SBOM, Grype matches package identity against a locally cached vulnerability database | You want the SBOM itself as a first-class, reusable artifact — signed, archived, handed to a second consumer — not just a disposable scan result | Two binaries and two mental models to operate instead of one; DB freshness is entirely your job to manage |
| Trivy (Aqua) | One static binary that catalogs and scans in the same pass, and also covers misconfiguration, secrets, and license scanning | You want one tool covering the widest surface with the least setup, at every commit | Less separation between "build the inventory" and "check it" as two distinct, independently reusable steps |
| OWASP Dependency-Check | Identifies dependencies from declared manifests, then matches via a guessed CPE against the NVD | A Java- or .NET-heavy stack with no ecosystem-native advisory feed as good as npm's or PyPI's, or this course's own CDP curriculum context | No image scanning, no SBOM output by default, and CPE-only matching brings a materially higher false-positive rate than Grype's purl-first approach |
| Snyk | Commercial-first platform built around its own curated vulnerability intelligence (Snyk Intel), with computed fix suggestions and automatic pull requests | Budget exists for a vendor, and developer-facing fix automation matters more than owning the pipeline end to end | A subscription past the free tier's scan limits, network access and an account required to run at all, and your dependency graph lives in a vendor's cloud |
The practical rule mirrors what this course keeps landing on for every scanner pair: a mature pipeline doesn't necessarily pick exactly one. Because Syft's SBOM is a portable, standard-format artifact, the same file it produces can be scanned by Grype today and handed to a completely different consumer later — a second scanner during a vendor bake-off, a license-review tool (see dependency & license risk), or an internal inventory service — without ever re-cataloging the image. That portability is the whole argument for keeping SBOM generation decoupled from vulnerability scanning in the first place, and it's the reason Trivy's own tool page makes a point of noting that it can scan an SBOM someone else generated: teams standardize on Syft for the inventory specifically because that inventory outlives any one scanner's opinion about it. See software bills of materials for the fuller treatment of SPDX versus CycloneDX, and vulnerability management & triage for how a Grype finding and a finding from a second scanner on the same underlying package get deduplicated into one entry instead of two open tickets for one bug.
Benny the Beaver: Image's built, tagged, ready to push.
Pip the Hummingbird: Not yet — hand it to me first. syft ghcr.io/acme/checkout:2.4.0 -o cyclonedx-json=sbom.cdx.json… there. Two hundred and eleven packages, every one with a version and a license attached.
Timmy the Turtle: Feed that straight into me. grype sbom:sbom.cdx.json --fail-on high… one critical. libexpat, two versions behind. Gate's closed until that's fixed.
Foxy: Why generate the file at all, then? Why not just point Grype at the image and skip a step?
Pip the Hummingbird: Grype would still catalog it internally — same result today, same critical finding either way. But then I've got nothing to sign, nothing to archive, nothing to hand the next scanner without cataloging the whole image over again from scratch. The file is the entire point.
Benny the Beaver: Bumping libexpat now.
Timmy the Turtle: Regenerate the SBOM after the rebuild, don't just re-scan the old one. New image, new inventory, then I check it again. That's the whole loop.
1. In one sentence each, what does Syft do and what does Grype do — and what's the one command that chains them together? 2. What actually happens when you point Grype straight at an image instead of a pre-built SBOM file — does it skip cataloging entirely? 3. Name two reasons an SBOM file is worth keeping around as its own artifact, beyond being an input to Grype. 4. Grype prefers matching by package identity (a purl) over a guessed CPE where it can. Why does that matter for false-positive rate, and when does Grype fall back to CPE matching anyway? 5. Your CI pipeline runs grype sbom:./sbom.json --fail-on high and it passes clean, but a critical CVE for a package in that SBOM was disclosed yesterday. What's the most likely explanation, and what command fixes it?
Check your answers
- Syft catalogs a container image or filesystem into a Software Bill of Materials — a structured package inventory — in SPDX, CycloneDX, or its own native syft-json. Grype takes that inventory (or an image directly) and matches every package's version against a locally cached vulnerability database to report known CVEs. The chaining command:
syft <image> -o json | grype. - No — Grype still needs a package inventory to match against, so pointing it at an image directly just makes it build that inventory itself, using Syft's own library internally rather than the standalone CLI. The match result is identical either way; what's different is that no separate SBOM file exists afterward to sign, archive, or hand to anything else.
- Any two of: it can be cryptographically signed and attached to the image as an attestation (via cosign) as proof of exactly what shipped; it can be archived as a release artifact and re-scanned later — the moment a new CVE is disclosed — without re-pulling or re-cataloging the image at all; it satisfies SBOM-format compliance asks (SPDX, NTIA minimum elements) independent of any one scanner's opinion; and it can be handed to a completely different consumer — a second scanner, a license-review tool — without redoing the cataloging work.
- Purl-based matching ties a vulnerability to a specific, unambiguous package identity — name, version, ecosystem — so it can't misfire on an unrelated package that merely shares a name or vendor string, which is exactly the looser matching that gives CPE-first tools a higher false-positive rate. Grype falls back to guessed CPE matching only when a package has no direct advisory-feed match available — mostly certain OS packages and ecosystems without a clean purl-to-advisory mapping.
- Grype almost certainly matched against a stale local vulnerability database — the cache wasn't refreshed since before yesterday's disclosure, so the new CVE simply isn't in it yet, and a clean scan reported "nothing found" rather than "nothing exists." The fix is
grype db update(or making sure CI actually runs it, or restoring a recently refreshed cached DB directory) before re-running the scan.