diff --git a/site/src/content/docs/best-practices/vulnerability-scanning.mdx b/site/src/content/docs/best-practices/vulnerability-scanning.mdx new file mode 100644 index 0000000000..e8bd9c2d5c --- /dev/null +++ b/site/src/content/docs/best-practices/vulnerability-scanning.mdx @@ -0,0 +1,237 @@ +--- +title: "Vulnerability Scanning with Grype" +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components"; + +[Grype](https://github.com/anchore/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](/ref/sboms) from the package and reports vulnerabilities for every image and component artifact inside it. + +```bash +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. + +:::note + +SBOMs are generated at package creation time. If a package was created with `--skip-sbom`, Grype has nothing to scan. Always generate SBOMs when creating packages intended for security gating. + +::: + +## Scanning a Package + +```bash +# 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`. + + + + +```yaml +- 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 +``` + + + + +```yaml +scan-package: + script: + - grype zarf:/path/to/package.tar.zst + --fail-on high + -o json + --file findings.json + artifacts: + when: always + paths: + - findings.json +``` + + + + +```bash +grype zarf:/path/to/package.tar.zst --fail-on critical -o json --file findings.json +if [ $? -eq 2 ]; then + echo "Package failed vulnerability gate" + exit 1 +fi +``` + + + + +### Ignoring Known Acceptable Findings + +For findings that are accepted risk (e.g., no fix available, vendor advisory disputed), create a [Grype ignore file](https://github.com/anchore/grype#specifying-matches-to-ignore) rather than lowering your threshold: + +```yaml +# .grype.yaml +ignore: + - vulnerability: CVE-2024-12345 + reason: "No fix available; mitigated at network boundary" + - fix-state: "wont-fix" +``` + +```bash +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. + +:::tip + +The `annotations` field only appears in `-o json` output, not in the default `table` format. + +::: + +:::note + +Source names are recorded at SBOM generation time and **do not include image tags**. When filtering by image, use just the registry and image name (e.g., `ghcr.io/go-gitea/gitea`) rather than the full tagged ref. + +::: + +### List All Source Images in the Findings + +```bash +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 + +```bash +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: + +```bash +# 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 + +```bash +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: + +```bash +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 + +```bash +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. + +```bash +# 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 + +- [SBOMs in Zarf](/ref/sboms) — how SBOMs are generated and embedded in packages +- [Grype documentation](https://github.com/anchore/grype) — full flag reference and ignore file format +- [Grype v0.114.0 release](https://github.com/anchore/grype/releases/tag/v0.114.0) — `zarf:` scan target release notes