Skip to content

Vulnerability Scanning with Grype

Grype v0.114.0 introduced a native zarf: scan target that lets you scan a Zarf package archive directly — without extracting it first. Grype reads the embedded SBOMs from the package and reports vulnerabilities for every image and component artifact inside it.

Terminal window
grype zarf:/path/to/package.tar.zst

How It Works

When Grype receives a zarf: source, it opens the outer .tar.zst archive, locates the sboms.tar bundle inside, and parses each Syft SBOM it finds. Packages that appear across multiple images (e.g., shared base-layer content) are deduplicated, and every matching vulnerability record tracks which image(s) the affected package originated from via the artifact.annotations["zarf-sbom-source"] field in JSON output.

This means a single scan covers the entire package — all component images and file artifacts — and you can trace any finding back to its source.

Scanning a Package

Terminal window
# Scan and print a table of findings (default output)
grype zarf:/path/to/package.tar.zst
# Write JSON output to a file for further analysis
grype zarf:/path/to/package.tar.zst -o json --file findings.json

The table output gives a quick human-readable summary. Use -o json when you need to query findings programmatically or preserve results as a CI artifact.

Gating CI on Severity with --fail-on

Use the -f / --fail-on flag to fail a pipeline when any unignored vulnerability at or above a given severity is found. Grype exits with code 2 when the threshold is met, and 0 when no findings exceed it.

Accepted severity values (lowest to highest): negligible, low, medium, high, critical.

- name: Scan Zarf package
run: |
grype zarf:/path/to/package.tar.zst \
--fail-on high \
-o json \
--file findings.json
- name: Upload scan results
if: always()
uses: actions/upload-artifact@v4
with:
name: grype-findings
path: findings.json

Ignoring Known Acceptable Findings

For findings that are accepted risk (e.g., no fix available, vendor advisory disputed), create a Grype ignore file rather than lowering your threshold:

.grype.yaml
ignore:
- vulnerability: CVE-2024-12345
reason: "No fix available; mitigated at network boundary"
- fix-state: "wont-fix"
Terminal window
grype zarf:/path/to/package.tar.zst --fail-on high --config .grype.yaml

Isolating Findings by Artifact

JSON output includes an artifact.annotations["zarf-sbom-source"] field on each match — a JSON array of image refs or file paths within the package that contain the vulnerable package. Use this to drill into which component is responsible for a finding.

List All Source Images in the Findings

Terminal window
jq -r '[.matches[].artifact.annotations["zarf-sbom-source"][]?] | unique[]' findings.json

Example output:

ghcr.io/go-gitea/gitea
ghcr.io/zarf-dev/agent
registry1.dso.mil/ironbank/redhat/ubi/ubi9-minimal

Count Findings per Image

Terminal window
jq -r '
[.matches[] | .artifact.annotations["zarf-sbom-source"][]?]
| group_by(.)
| map({source: .[0], count: length})
| sort_by(-.count)[]
| "\(.count)\t\(.source)"
' findings.json

Filter Findings for a Specific Image

zarf-sbom-source is a JSON array and index does exact element matching, so omit the tag:

Terminal window
# Strip tag if starting from a full image ref: IMAGE="${FULL_IMAGE%%:*}"
IMAGE="ghcr.io/go-gitea/gitea"
jq --arg img "$IMAGE" '[
.matches[] | select(
(.artifact.annotations["zarf-sbom-source"] // []) | index($img)
)
]' findings.json

Show Critical and High Findings Only

Terminal window
jq '[.matches[] | select(.vulnerability.severity | test("Critical|High"))]' findings.json

Show Critical/High Findings per Image

Combine severity filtering with source attribution to get a targeted remediation list:

Terminal window
jq -r '
.matches[]
| select(.vulnerability.severity | test("Critical|High"))
| .artifact.annotations["zarf-sbom-source"][]? as $src
| [$src, .vulnerability.severity, .vulnerability.id, .artifact.name, .artifact.version]
| @tsv
' findings.json | sort | column -t

Example output:

ghcr.io/go-gitea/gitea Critical CVE-2024-56337 tomcat-embed-core 10.1.31
ghcr.io/go-gitea/gitea High CVE-2025-24813 tomcat-embed-core 10.1.31
registry1.dso.mil/.../ubi9-minimal High CVE-2024-50602 expat 2.5.0-2

Extract a Per-Image Summary Report

Terminal window
jq -r '
[
.matches[]
| .vulnerability as $vuln
| .artifact.annotations["zarf-sbom-source"][]? as $src
| {source: $src, severity: $vuln.severity, id: $vuln.id, package: .artifact.name}
]
| group_by(.source)
| map({
source: .[0].source,
critical: (map(select(.severity == "Critical")) | length),
high: (map(select(.severity == "High")) | length),
medium: (map(select(.severity == "Medium")) | length)
})
| sort_by(-.critical, -.high)[]
| "\(.source) critical=\(.critical) high=\(.high) medium=\(.medium)"
' findings.json

Relating Findings Back to Zarf Components

Grype reports findings at the image level. To understand which Zarf component owns a given image, first identify which image refs appear in your scan output, then cross-reference with the package definition. Docker Hub (docker.io) images may be stored without the registry prefix in the definition output, so if a match isn’t found try just the image name.

Terminal window
# 1. List every image ref present in the findings
jq -r '[.matches[].artifact.annotations["zarf-sbom-source"][]?] | unique[]' findings.json
# 2. Cross-reference with the component that owns the image
zarf package inspect definition /path/to/package.tar.zst 2>/dev/null | grep -B10 "your-image-name"
# 3. Count findings for that image
IMAGE="ghcr.io/your-org/your-image" # use an image from step 1
jq --arg img "$IMAGE" '
[.matches[] | select((.artifact.annotations["zarf-sbom-source"] // []) | index($img))]
| length
' findings.json

Further Reading