Sachin Chaurasiya

DevSecOps Part 3 of 5 · Software Supply Chain Security

Scan Images and SBOMs with Grype: From 183 Findings to 3

Grype against the SBOM from part 2: 183 matches read by severity, ecosystem and fix state; two Dockerfile changes that take it to 3; exit-code behaviour for CI; and how to triage what remains.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Reading time
11 min read
Difficulty
intermediate

Reviewed Tested with Grype 0.118.0 (anchore/grype image, DB schema v6, built 2026-09-15), Syft 1.51.1, node:22.20.0-alpine3.22 and node:22.23.2-alpine3.24 images, Docker 29.1

On this page

Overview

Part 2 produced an SBOM with 234 packages, 211 of them belonging to a package manager the runtime never runs. This part scans that SBOM with Grype, reads the result, and makes two changes to the Dockerfile that reduce the findings from 183 to 3. Every number is from a Grype 0.118.0 run against a vulnerability database built on 2026-09-15; scan the same image next month and the counts will differ, because the database will.

Grype matches package identities (name, version, ecosystem) against vulnerability data. It does not execute anything and does not need the image if it has the SBOM, which is why scanning the SBOM is the normal CI shape: Syft once, Grype as often as you like.

Prerequisites

  • The SBOM from part 2 (ci-demo-1.4.0.cdx.json) and the Dockerfile that built the image
  • Docker; Grype runs from its image with a cache directory for the database (about 2 GB on first run)
  • A JSON reader for the summary counts

Define the same helper the SBOM lab uses, so the database survives between runs:

mkdir -p grype-cache
grype() { docker run --rm -e GRYPE_DB_CACHE_DIR=/cache -v "$PWD/grype-cache:/cache" -v "$PWD:/out" anchore/grype:v0.118.0 "$@"; }
grype db update
grype db status
Vulnerability database updated to latest version!
Schema:    v6.1.9
Built:     2026-09-15T06:31:36Z
Status:    valid

Scan the SBOM

grype sbom:/out/ci-demo-1.4.0.cdx.json -o table
NAME                     INSTALLED   FIXED IN                            TYPE    VULNERABILITY        SEVERITY  EPSS          RISK
libcrypto3               3.5.4-r0    3.5.5-r0                            apk     CVE-2025-15467       Critical  48.2% (98th)  45.3
libssl3                  3.5.4-r0    3.5.5-r0                            apk     CVE-2025-15467       Critical  48.2% (98th)  45.3
node                     22.20.0     20.20.2, *22.22.2, 24.14.1, 25.8.2  binary  CVE-2026-21710       High      25.0% (97th)  18.8
node                     22.20.0     20.20.0, *22.22.0, 24.13.0, 25.3.0  binary  CVE-2025-59465       High      3.8% (89th)   2.8
libcrypto3               3.5.4-r0    3.5.7-r0                            apk     CVE-2026-45447       High      3.6% (88th)   2.8
...

Grype sorts by its RISK column, which combines severity with EPSS (the probability the vulnerability is exploited in the next 30 days, with the percentile). The top line is the one to read first: OpenSSL in the base image, Critical, an exploit probability in the 98th percentile, and a fix version one release away.

The table runs to 183 lines. Summarise it from the JSON output:

grype sbom:/out/ci-demo-1.4.0.cdx.json -o json > grype-1.4.0.json
python3 - <<'PY'
import json, collections
m = json.load(open("grype-1.4.0.json"))["matches"]
print("matches:", len(m))
print("severity:", collections.Counter(x["vulnerability"]["severity"] for x in m))
print("ecosystem:", collections.Counter(x["artifact"]["type"] for x in m))
print("fix available:", sum(x["vulnerability"]["fix"]["state"] == "fixed" for x in m))
print("npm packages live under:", sorted({l["path"].split("/node_modules/")[0] for x in m if x["artifact"]["type"] == "npm" for l in x["artifact"]["locations"]}))
PY
matches: 183
severity: Counter({'High': 94, 'Medium': 57, 'Low': 18, 'Critical': 14})
ecosystem: Counter({'apk': 102, 'npm': 54, 'binary': 27})
fix available: 179
npm packages live under: ['/usr/local/lib']

Three things in that summary decide the fix.

102 findings are Alpine packages, almost all of them libssl3 and libcrypto3 (each CVE counts twice, once per package) plus musl and busybox. The base image node:22.20.0-alpine3.22 shipped OpenSSL 3.5.4 and the fixes are in 3.5.5 through 3.5.8. Nothing in the application caused this; the base image aged.

54 findings are npm packages, all under /usr/local/lib, which is where npm’s own CLI lives. tar, minimatch, brace-expansion, glob, pacote: not one of them is a dependency of the application. They are findings against a program the container never runs.

27 findings are the node binary itself, with fixes in 22.22.x and 22.23.x. Node 22.20.0 was current when the CI/CD path pinned it; it is not any more.

179 of 183 have a fix available. That is the actionable set, and it is nearly everything.

Severity, EPSS and what to fix first

Severity is the CVSS band the vulnerability was published with; it describes the worst case for a component in general. EPSS is a daily estimate of exploitation likelihood in the wild. A High with 25 % EPSS (CVE-2026-21710 above) deserves attention before a Critical at 0.2 %, and Grype’s RISK ordering encodes that. For a gate, though, severity is what you can express (--fail-on), so the practical rule is: gate on severity, prioritise by risk, and fix by component, because one base-image bump closes a hundred lines at once.

Fix: two Dockerfile changes

Both problems are in the image, not the application, so both fixes are in the Dockerfile.

Move to a current base image. node:22.23.2-alpine3.24 was the newest Node 22 Alpine tag at the time of the run (docker run --rm node:22-alpine node --version prints v22.23.2; pin the exact tag, not 22-alpine). It carries a newer Node and Alpine 3.24 with OpenSSL 3.5.7.

Remove the package manager from the runtime stage. The build stage needs npm to run npm ci; the runtime stage needs only the node binary. Deleting npm, npx, corepack and yarn from the final image removes 211 packages and their 54 findings.

# syntax=docker/dockerfile:1
FROM node:22.23.2-alpine3.24 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
COPY src ./src
COPY build.js ./
ARG CI_COMMIT_SHORT_SHA=local
RUN node build.js && cp dist/version.json src/version.json

FROM node:22.23.2-alpine3.24
WORKDIR /app
ENV NODE_ENV=production
# The runtime only needs the node binary: drop npm, npx, corepack and yarn (and their ~200 packages). 
RUN apk upgrade --no-cache \ 
 && rm -rf /usr/local/lib/node_modules /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \ 
           /opt/yarn* /usr/local/bin/yarn /usr/local/bin/yarnpkg # [!code highlight]
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/src ./src
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]

apk upgrade --no-cache is the third change, added after a first rescan (below) showed that even the new base image’s OpenSSL 3.5.7 had findings fixed in 3.5.8, which the Alpine repository already carried. Upgrading the packages at build time picks up fixes the base image maintainers have not rebuilt for yet; the cost is that the build is no longer purely a function of the base image digest, which the SBOM records anyway.

Build, inventory, scan:

docker build -t ci-demo:1.4.2 .
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro -v "$PWD:/out" \
  anchore/syft:v1.51.1 ci-demo:1.4.2 -o cyclonedx-json=/out/ci-demo-1.4.2.cdx.json -q
grype sbom:/out/ci-demo-1.4.2.cdx.json -o table

The intermediate step (base image updated and npm removed, but no apk upgrade yet) produced an SBOM of 22 packages instead of 234, and the scan summarised to 23 matches: 4 Critical, 14 High, 5 Medium, 20 with a fix. The table began:

NAME           INSTALLED   FIXED IN  TYPE  VULNERABILITY   SEVERITY  EPSS         RISK
libcrypto3     3.5.7-r0    3.5.8-r0  apk   CVE-2026-18798  High      1.5% (72nd)  1.1
libssl3        3.5.7-r0    3.5.8-r0  apk   CVE-2026-18798  High      1.5% (72nd)  1.1
libcrypto3     3.5.7-r0    3.5.8-r0  apk   CVE-2026-63076  High      1.4% (69th)  1.0
...

183 became 23, every one of them OpenSSL 3.5.7 with a fix in 3.5.8. With apk upgrade in place:

NAME           INSTALLED   TYPE  VULNERABILITY   SEVERITY  EPSS         RISK
busybox        1.37.0-r31  apk   CVE-2025-60876  Medium    0.3% (21st)  0.2
busybox-binsh  1.37.0-r31  apk   CVE-2025-60876  Medium    0.3% (21st)  0.2
ssl_client     1.37.0-r31  apk   CVE-2025-60876  Medium    0.3% (21st)  0.2

Three findings, one CVE, Medium, no fix available, in BusyBox. 22 packages in the image instead of 234. The application code did not change.

Exit codes for CI

Grype exits 0 unless told otherwise. --fail-on <severity> makes it exit non-zero when any match is at or above that severity; --only-fixed restricts the match set to findings with a fix, which is the set a pipeline can act on. Both were exercised on the three SBOMs:

grype sbom:/out/ci-demo-1.4.0.cdx.json --fail-on high;                echo "exit=$?"
grype sbom:/out/ci-demo-1.4.2.cdx.json --fail-on high;                echo "exit=$?"
grype sbom:/out/ci-demo-1.4.2.cdx.json --fail-on medium --only-fixed; echo "exit=$?"
[0006] ERROR discovered vulnerabilities at or above the severity threshold
exit=2
exit=0
exit=0

The original image fails a High gate (exit code 2, with the error line above it). The fixed image passes a High gate outright, and passes a Medium gate once unfixable findings are excluded: the three BusyBox findings are Medium with no fix, so --only-fixed removes them from consideration. That combination, --fail-on medium --only-fixed, is a defensible default: strict enough to catch what matters, and it never blocks a build on a finding nobody can resolve.

A GitLab CI job for it, on the SBOM the build job produced:

scan-sbom:
  stage: security
  needs: [build] # build exports the SBOM as an artifact
  image:
    name: anchore/grype:v0.118.0
    entrypoint: ['']
  cache:
    key: grype-db
    paths: [.grype-cache/]
  variables:
    GRYPE_DB_CACHE_DIR: .grype-cache
  script:
    - grype sbom:ci-demo.cdx.json --fail-on medium --only-fixed -o table -o json=grype.json
  artifacts:
    when: always
    paths: [grype.json]

The DevSecOps Pipeline path shows the equivalent gate with Trivy, including SARIF output for merge-request widgets; the two scanners agree on most findings and differ at the edges, and either is a sound choice for the gate.

Triage: what to do with what remains

Three kinds of finding survive a good fix cycle, and each gets a different treatment.

No fix available (the BusyBox CVE above). The upstream project or distribution has not shipped a fix. Exclude it from the gate with --only-fixed, keep it in the report, and let the next base-image update clear it. Ignoring it by CVE ID would hide the fix when it arrives.

Not applicable. A finding in a component the application cannot reach (a CLI tool in the image, a feature flag the code never enables). The right fix is usually removal, as with npm above. When removal is not possible, record an ignore rule with a reason and an expiry:

ignore:
  # ssl_client is present for apk itself; the application makes no TLS calls through BusyBox. Re-check 2026-12-01.
  - vulnerability: CVE-2025-60876
    package:
      name: ssl_client

Grype reads .grype.yaml from the working directory and reports ignored matches separately; the file is reviewed with the code, like a Checkov skip.

Wrong match. Grype identified a package or version incorrectly, so the CVE does not apply. This happens most with binaries identified by content and with packages whose distribution backported a fix without changing the version string (Alpine and Debian both do this, and Grype uses the distribution’s own security data to account for it, which is why the apk findings above name -r0 revisions). Report wrong matches upstream; ignore them by vulnerability and package, never by package alone.

Security Considerations

  • A clean scan is a statement about today’s database. Rescan deployed images on a schedule (the SBOMs make this cheap), not only at build time.
  • apk upgrade in the runtime stage means the image contents depend on the Alpine repository at build time. The SBOM records exactly what was installed; keep it.
  • Removing npm from the runtime image also removes the attacker’s easiest tool for installing something else in a compromised container. Smaller images are a security property, not only a scanning one.

Troubleshooting

SymptomCauseFix
First run takes minutesDatabase download (about 2 GB)Persist GRYPE_DB_CACHE_DIR in a CI cache
Counts differ from this articleNewer database, or different Grype versionExpected; compare fix state and components, not totals
Findings in packages the app does not useBase image tooling in the runtime stageRemove it in the runtime stage, as above
--only-fixed hides a finding you care aboutNo fix published yetTrack it outside the gate; it returns to the gate when a fix ships
Exit code 2 even for one Low finding--fail-on lowChoose the threshold deliberately; medium --only-fixed is a sane start

Conclusion

Two lines in a Dockerfile removed 180 findings, and the SBOM is what showed which two. The image is now small enough to reason about. Part 4 makes sure the image that passed this scan is the one that gets deployed.

References

Keep reading