CVE scanning

Use Grype to scan Hummingbird images and your own derived images. See "near zero CVE" with your own eyes.

⏱ 20 minutesSection 6

This is the section where the marketing-speak of “near zero CVE” gets converted into actual scan output. We’ll scan three images in increasing order of complexity and look at what the numbers actually mean.

What Grype is doing

Grype reads the image’s filesystem (or, more precisely, its SBOM-equivalent package list), looks each package up in a vulnerability database, and prints any matches. The matches are classified by severity (Critical / High / Medium / Low / Negligible) and include a fix version where one is known.

Two important caveats up front:

  • A Grype match is not the same as an exploitable vulnerability. A match means a CVE is reported against a package version present in the image. Whether the CVE is reachable from the application’s actual code paths is a separate question.
  • The database moves daily. A scan that comes back with zero CVEs on Monday may show one on Tuesday because a new CVE was published. This is the moving zero line we discussed in section 2.

Step 1 — Make sure the database is fresh

grype db status
grype db update

grype db status shows when the database was last refreshed. grype db update pulls the latest. Run both before any comparison scan — otherwise the numbers are not comparable.

Step 2 — Scan a stock Hummingbird image

# Scan the Hummingbird Nginx you pulled in section 3.
grype "$HB_REGISTRY/nginx:1"

Expected output: zero or near-zero matches. The exact number depends on what was disclosed since the image was last rebuilt.

For a more readable summary:

grype "$HB_REGISTRY/nginx:1" -o json \
  | jq '{
      total: (.matches | length),
      by_severity: (.matches | group_by(.vulnerability.severity)
                    | map({key: .[0].vulnerability.severity, count: length})
                    | from_entries)
    }'

Step 3 — Scan a non-minimal comparison image

To anchor the result, scan a general-purpose Nginx image. We pulled this one in section 3:

# Make sure it's still around.
podman pull docker.io/library/nginx:latest

grype docker.io/library/nginx:latest -o json \
  | jq '{
      total: (.matches | length),
      by_severity: (.matches | group_by(.vulnerability.severity)
                    | map({key: .[0].vulnerability.severity, count: length})
                    | from_entries)
    }'

The two results, side by side, are the empirical version of section 2’s “near zero CVE” claim. The Hummingbird image is typically near zero; the general-purpose image is typically tens to hundreds of matches.

Step 4 — Scan your own derived image

This is the most useful number, because it shows what your application contributes on top of the Hummingbird base.

# Use the Node example from section 4.
grype hummingbird-node-example:latest -o json \
  | jq '{
      total: (.matches | length),
      by_severity: (.matches | group_by(.vulnerability.severity)
                    | map({key: .[0].vulnerability.severity, count: length})
                    | from_entries)
    }'

If your app declares any production npm dependencies, you’ll see matches against those. The Hummingbird-base part of the image should still be clean. The work of “keeping your image clean” is now reduced to “keeping your app’s direct dependencies patched” — which is a much smaller and more tractable problem than tracking every transitive package in a general-purpose base.

Step 5 — Filter scans down to the things that matter

In a real workflow, the raw scan is too noisy to act on. A few common filters:

# Critical and High only.
grype hummingbird-node-example:latest \
  --fail-on high \
  -o table

# Only matches with a known fix available.
grype hummingbird-node-example:latest -o json \
  | jq '.matches | map(select(.vulnerability.fix.versions | length > 0))
        | map({id: .vulnerability.id,
               severity: .vulnerability.severity,
               package: .artifact.name,
               fix: .vulnerability.fix.versions})'

# A single CVE, by ID, to confirm it's present.
grype hummingbird-node-example:latest -o json \
  | jq '.matches[] | select(.vulnerability.id == "CVE-2024-12345")'

The --fail-on high form is what you want in CI: the command exits non-zero if any high-or-critical CVE is found, which lets the pipeline stop the build automatically.

Step 6 — Wire scanning into a pre-commit hook

For local development, the cheapest possible feedback loop is to scan on every commit. A minimal Git pre-commit hook:

mkdir -p ~/hummingbird-tutorial/examples/node-example/.git/hooks
cat > ~/hummingbird-tutorial/examples/node-example/.git/hooks/pre-commit <<'EOF'
#!/usr/bin/env bash
# Pre-commit hook: build the image and run Grype with --fail-on high.
# Aborts the commit if any high-or-critical CVE is found.

set -euo pipefail

IMAGE_TAG="hummingbird-node-example:precommit"

echo "→ Building image for CVE scan..."
podman build -q -t "$IMAGE_TAG" . > /dev/null

echo "→ Running Grype scan..."
if ! grype "$IMAGE_TAG" --fail-on high -o table; then
    echo
    echo "✘ Grype found high or critical CVEs. Aborting commit."
    echo "  Run 'grype $IMAGE_TAG' to see the full list."
    exit 1
fi

echo "✔ No high or critical CVEs."
EOF

chmod +x ~/hummingbird-tutorial/examples/node-example/.git/hooks/pre-commit

This will slow your commits down a bit, which is the point — you find out now that you need to bump a dependency, not in CI an hour later.

When the database is stale or unreachable

In an air-gapped or partially restricted environment, you’ll need to provide Grype’s database manually. The simplest approach:

# Mirror the Grype DB index from a connected machine.
mkdir -p ~/grype-db-mirror
curl -L \
  -o ~/grype-db-mirror/listing.json \
  https://toolbox-data.anchore.io/grype/databases/listing.json

# Point Grype at the mirror.
mkdir -p ~/.config/grype
cat >> ~/.config/grype/config.yaml <<EOF
db:
  update-url: file://${HOME}/grype-db-mirror/listing.json
  auto-update: true
EOF

A full air-gapped Grype setup involves syncing the actual database tarballs as well, which is well-documented in the Grype project itself. The pointer above is enough to get the configuration started.

Verify before moving on

You should now be able to:

  • explain the difference between a Grype match and an exploitable vulnerability,
  • compare scan results across a Hummingbird image, a general-purpose image, and your own derived image, and
  • run Grype with --fail-on high to gate a build.

Where to go next

Multi-container apps with Podman Compose takes the single-image work we’ve done so far and assembles a multi-service application from it.