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.
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 upgradein 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
| Symptom | Cause | Fix |
|---|---|---|
| First run takes minutes | Database download (about 2 GB) | Persist GRYPE_DB_CACHE_DIR in a CI cache |
| Counts differ from this article | Newer database, or different Grype version | Expected; compare fix state and components, not totals |
| Findings in packages the app does not use | Base image tooling in the runtime stage | Remove it in the runtime stage, as above |
--only-fixed hides a finding you care about | No fix published yet | Track it outside the gate; it returns to the gate when a fix ships |
| Exit code 2 even for one Low finding | --fail-on low | Choose 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