Scanning DevContainer Images for Vulnerabilities

A pinned base image is reproducible but not automatically safe — it can carry known CVEs. This page scans the devcontainer image with trivy (or grype) and gates on criticals in CI, so a vulnerable base never reaches developers unnoticed.

The reason this matters is that a digest pin freezes a moment in time, and time keeps moving. The mcr.microsoft.com/devcontainers/base@sha256:PINNED reference you locked in last quarter was clean the day you pinned it, but vulnerability databases are updated continuously as researchers disclose new CVEs against packages that were already inside that layer. Nothing about the image changed; what changed is the world's knowledge of it. A scanner closes that gap by re-checking the same immutable bytes against today's advisory feeds, which is why scanning is a recurring activity rather than a one-time gate you clear and forget. The pin gives you a stable target to scan; the scan tells you whether that target is still trustworthy.

Reach for this whenever a devcontainer image is about to be handed to other people — pushed to a shared registry, baked into a prebuild, or referenced by a team's devcontainer.json. The mental model is a two-phase loop: pin so the artifact is exact and knowable, then scan that exact artifact on every build and on a schedule so drift in the CVE landscape surfaces as a failing check instead of a silent exposure. Trivy and grype both work by matching the packages they find in the image against known vulnerability databases, so the quality of the answer depends entirely on scanning the real shipping artifact — the digest — rather than a floating tag that may resolve to something else entirely.

Prerequisites

You need a scanner and the pinned image to scan.

  • trivy or grype installed (or a scan action in CI).
  • The digest-pinned image reference.
  • A CI step to run and gate the scan.

Scan prerequisitesYou need a scanner, the pinned image, a CI gate, and a refresh trigger.Scannertrivy/grypeScan imageby digestGatefail on criticalsRefreshon new CVE

The one detail people get wrong here is treating the scanner install as the whole prerequisite while glossing over the image reference. A scanner is useless if it is pointed at a moving tag, so the digest-pinned reference is the load-bearing input, not an afterthought. Make sure the @sha256: digest you feed the scanner is the same one your devcontainer.json and any prebuild pipeline actually consume — if those diverge, you will scan one artifact and ship another, and the green check will be describing an image nobody runs.

The CI step is the third leg of the stool and the one that turns a manual habit into an enforced guarantee. trivy and grype are perfectly happy to print findings and exit zero, which means a scan that merely runs is not a scan that gates. You need a step that runs the scanner and propagates a non-zero exit code on the severities you care about, so the pipeline blocks rather than logs. Keeping the scanner binary itself current matters too: an out-of-date vulnerability database will miss recent CVEs, so pin the scanner's version deliberately but refresh it on a cadence, the same way you refresh the base digest.

Step-by-Step Implementation

  1. Scan the pinned image, failing on criticals.
trivy image --severity CRITICAL --exit-code 1 \
  mcr.microsoft.com/devcontainers/base@sha256:PINNED

The two flags here are what make this a gate rather than a report. --severity CRITICAL narrows the findings trivy will act on, so lower-severity noise does not block a build while you decide how to handle it; --exit-code 1 is the piece that actually fails the process when a matching finding exists, because without it trivy prints its table and returns zero regardless of what it found. Passing the full @sha256:PINNED reference rather than a tag guarantees the scan describes the precise bytes you pinned, so the result is not an approximation of some tag that may have been re-published. The failure mode this prevents is the quiet one: a critical CVE sitting in the base for weeks because everyone assumed a scan that "ran" was a scan that would have stopped them.

  1. Add it to CI so a vulnerable base fails the build.
      - run: trivy image --severity CRITICAL,HIGH --exit-code 1 $IMAGE

In CI the severity list widens to CRITICAL,HIGH because the pipeline is the right place to be stricter than a developer's ad-hoc local scan — a high-severity CVE that you would tolerate for an afternoon of local work should not silently ride into a shared prebuild. $IMAGE is expected to carry the digest reference, so the CI scan and the local scan target the same immutable artifact and cannot disagree about what was checked. Running this as an explicit - run step, rather than folding it into a build script, keeps the gate visible in the workflow log and makes it obvious to a reviewer that the scan is enforced. The failure this design prevents is drift between "what a developer scanned by hand" and "what the pipeline actually ships," which is exactly where an unscanned base slips through.

  1. Generate an SBOM alongside for auditability.
syft $IMAGE -o spdx-json > sbom.spdx.json

The SBOM turns "are we affected?" from an investigation into a lookup. syft walks the same $IMAGE and emits a full inventory of packages and versions; writing it to sbom.spdx.json in the standardised SPDX JSON format means the artifact is consumable by other tooling rather than locked to one vendor. Storing this file per build is what makes a future CVE disclosure cheap: when a new advisory lands against, say, a specific openssl version, you grep your stored SBOMs instead of rebuilding and re-scanning every image to find out where that version lived. The failure mode without an SBOM is the scramble — days of rebuilding old images just to answer whether a newly disclosed vulnerability ever shipped, when a recorded inventory would have answered it in seconds.

  1. Refresh the pinned digest when a scan flags a fixable CVE.

Refreshing is the step that closes the loop, and it only happens when a fix actually exists upstream — a CVE with no available patch is a finding to track and possibly suppress with a documented exception, not a reason to churn the digest. When a fixable critical does appear, you re-pin to the patched image's new @sha256: digest, re-run the scan against that new reference to confirm the fix landed, and commit the change so the update is reviewable rather than silent. Treating the refresh as a normal, reviewed pin bump keeps the base both reproducible and current: you always know exactly which bytes you run, and you know they carry no unaddressed critical. The failure mode this guards against is the opposite of the first step's — not an unscanned base, but a perfectly scanned base that is pinned so hard it never receives the fixes the scans keep flagging.

Scan modelScanning the pinned image against CVE databases and gating criticals keeps the base safe.Pinned imagethe exact bytesScanmatch against CVE DBsGate criticalsfail the buildRefreshre-pin on fix

Common Pitfalls

Scan gaps are no gate, scanning a tag not a digest, or never refreshing.

The most common trap is a scanner's vulnerability database that itself goes stale. Trivy and grype cache their advisory data, and if that cache is populated once in a container layer and then reused for months, the scan will run cleanly against a snapshot of yesterday's knowledge while today's critical passes straight through. This is the ownership-and-freshness angle: whoever owns the CI image or the cached database is responsible for its currency, and a --exit-code 1 gate gives false confidence if the database behind it has not been updated. Let the scanner refresh its database on each run, or rebuild the scanner layer on a schedule, so the gate is judging the image against a current feed rather than a frozen one.

The second, subtler pitfall is scanning something other than the artifact you ship. It is easy to scan a locally built image, or a tag that a registry re-published, and get a green result that does not describe the digest referenced in devcontainer.json. The scan and the pin must name the same @sha256: reference, or the whole exercise measures the wrong thing. A related failure is treating every finding as equally actionable: a critical with an upstream fix demands a refresh, but one with no available patch needs a tracked, documented suppression instead — burying it in the same noise as the fixable ones is how genuinely urgent CVEs get lost.

Scan triageA triage path from an unscanned base to a gated, refreshed one.Does a scan gate run in CI?NOAdd trivy --exit-code 1Scanning the pinned digest?YESRefresh on fixable CVEsBase stays safe + current

SymptomRoot CauseRemediation
Critical CVE reaches developersNo scan gate in CIAdd trivy/grype with --exit-code 1
Scan result drifts from realityScanned a tag, not a digestScan the pinned @sha256 digest
Base never gets CVE fixesDigest pinned, never refreshedRefresh the digest on fixable CVEs
Can't answer 'are we affected?'No SBOMGenerate and store an SBOM per build

Conclusion

Pinning makes the base reproducible; scanning makes it safe. Run trivy or grype against the pinned digest in CI, fail on criticals, and refresh the pin when a fixable CVE appears. Add an SBOM and vulnerability response becomes a query instead of an investigation.

The strategic payoff is that scanning turns your pin-and-cache discipline from a purely reproducibility story into a security one without adding a second source of truth. The same @sha256: digest that guarantees every developer builds the identical devcontainer is the exact reference the scanner, the SBOM generator, and the CI gate all consume. You are not maintaining a separate list of "images to check" that can drift from the images you actually run — the pin is the list. That single-reference discipline is what makes the whole loop cheap to operate: one authoritative digest, scanned on every build and on a schedule, with an SBOM recorded beside it.

Tied back to the broader themes, this is the reproducibility argument extended in time. A pin freezes what you run; a scheduled scan against that pin tracks whether what you run is still safe to run, and the refresh step is how you move the pin forward deliberately instead of letting it rot. Teams that get this right stop treating security patches as emergency interruptions and start treating them as ordinary, reviewed digest bumps — the same reviewed-change workflow they already use for any other dependency. The base image stops being a black box that "was fine when we set it up" and becomes a known, auditable, continuously verified part of the environment.

Scan and refreshScanning proves the base is clean; refreshing keeps it that way.Scan provesNo known criticalsAuditable via SBOMGated in CIThen youRefresh on fixesRe-scan the new pinRecord the change

FAQ

Isn't a pinned image already safe? Pinning makes it reproducible, not safe — the exact bytes you pinned can still contain known CVEs discovered later. Scan the pinned digest against vulnerability databases and gate on criticals, so 'reproducible' and 'safe' are both guaranteed. The key insight is that the image does not need to change for its risk profile to change: a CVE disclosed today against a package baked into last quarter's layer makes yesterday's clean scan obsolete even though not a single byte moved. That is why scanning is a recurring check against a moving advisory feed, not a one-time acceptance test you clear once.

Should I scan the tag or the digest? The digest — the exact image you ship. Scanning a tag can drift from what actually builds. Scan base@sha256:…, the same reference you pinned in devcontainer.json, so the scan result describes the real artifact. A tag like :latest or even a version tag is a mutable pointer that a registry can re-publish to point at different bytes, so a scan of the tag can pass while the digest you actually run carries a critical. Feeding the scanner the @sha256: reference removes that ambiguity entirely: there is exactly one image the result can be describing.

How do I respond when a scan flags a CVE? If a fix exists upstream, refresh the pinned digest to the patched image, re-scan, regenerate the SBOM, and commit it as a reviewed change. The SBOM lets you quickly answer which projects are affected without rebuilding each one. If no fix exists yet, do not churn the digest — record a documented, time-boxed suppression so the finding stays visible without blocking every build, and revisit it when upstream ships a patch. Keeping fixable and unfixable findings clearly separated is what stops a genuinely urgent CVE from getting lost in noise.

Trivy or grype — does it matter which I use? Both work the same way: they inventory the packages in the image and match them against known vulnerability databases, so either can back the gate. The important thing is not the tool but the inputs — scan the digest, keep the vulnerability database current, and propagate a non-zero exit code on the severities you care about. Many teams run one in CI and reach for the other as a second opinion when triaging a specific finding, since their databases and matching heuristics differ slightly at the edges.

How often should the scan run? On every build, and also on a schedule independent of builds. Build-time scans catch problems introduced by a change, but a base you pinned months ago and have not rebuilt will not be re-checked by build-time scans alone — its risk changes as new CVEs are disclosed, not as your code changes. A nightly or weekly scheduled scan against the currently pinned digest surfaces newly disclosed vulnerabilities in an otherwise idle image, so a critical does not wait for the next unrelated commit to be noticed.