Lesson 04 / 11
Container Image Scanning
A 348-component image's 144 findings split by source: 75 come from the base layer, 69 from the application layer, and moving the base image to a new version closes 64 findings — 85.3 percent of the base-sourced ones — in a single operation while never touching the application layer; 18 vulnerabilities sit in 31 components absent from the package registry, so they show up in no run. The three images' finding-row total is 269, unique vulnerabilities 144. The policy that blocks every severe finding stops 49 of 60 builds, and in 12 of them every blocking finding is entirely in the base layer, which the team cannot fix; narrowing the block leaves severe base exposure at 940 build-findings, and refreshing the base every twenty builds brings it down to 382.
Published Updated
In this lesson
- Splits an image's finding set by source: what comes from the base layer versus what the application adds
- Counts the share of findings a base image upgrade closes, and states separately the set it never touches
- Measures the blocking policy with two numbers: builds stopped and blocks the team cannot fix
Contents
The files in the repository passed through four checks. What ships to production is an image, and it carries a layer stack the team never wrote and never chose. Container image scanning is the check that pulls the components out of this stack and compares them against a known-vulnerability catalog; the matching mechanics are the same as dependency component analysis — what is different is the shape of the input.
A vulnerability found in an image has a single severity value but two separate fix paths. Part of it sits in the layers the application added, and the team that produces that layer can fix it. Part of it is inherited from a base layer nobody has touched; there is no code to write for it, what closes it is changing the base image’s version. This lesson splits the finding set along this axis and counts what the split means for the pipeline.
- AC24. An image is a layer list; the layers underneath come from the base image, the ones on top are what the application adds. Layer mechanics are input in this course and are not rebuilt here. The layer names, component counts, and vulnerabilities below are fictional.
- AC25. A finding’s source is the layer the vulnerable component sits in. This is a separate column in the scan output and it determines the fix path.
- AC26. The scan reads from the inventory. A component that does not appear in the package registry — a manually placed binary — never enters the inventory, and the vulnerability it carries shows up in no run.
- AC27. Whether a vulnerability in the base layer is closed in the new base version is a separate field. A vulnerability in the application layer is never affected by a base upgrade.
- AC28. Catalog records are published over time: even if the image never changes, the number of known findings grows.
- AC29. 1.5 model minutes per build, 35 per blocked build. Exposure is measured in build-finding units: the sum, across builds, of the number of severe base vulnerabilities sitting in the image at each build. The durations are data, not a measurement.
The Vulnerability’s Source and What the Upgrade Closes
// image.mjs — an image's layer list is being scanned; what is measured is whether a // vulnerability comes from the base layer or the application layer, and how many the // base upgrade closes. THIS IS A MODEL. export const SEED = 20260404, SCAN_MIN = 1.5; const rng = (t) => { let s = t >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; }; const r = rng(SEED); export const SEVERITY = ["low", "medium", "high", "critical"]; const SEVERE = new Set(["high", "critical"]); // Layers bottom to top: the first two come from the base image, the top three are what // the application adds. Layer names and component counts are fictional. export const LAYERS = [ ["os base", "base", 96], ["runtime", "base", 64], ["dependencies", "application", 148], ["compiled output", "application", 22], ["utility tools", "application", 18], ].map(([name, source, count]) => ({ name, source, count })); // inInventory: whether the component appears in the package manager's record. A component // absent from the record (a manually copied binary) never enters the scanner's inventory. // fixedInNewBase: whether the vulnerability is closed in the new version of the base image. export const COMPONENTS = []; for (const k of LAYERS) for (let i = 0; i < k.count; i++) { const inInventory = r() < (k.source === "base" ? 0.98 : 0.86), vulns = []; const n = r() < 0.26 ? 1 + Math.floor(r() * 2.4) : 0; for (let j = 0; j < n; j++) vulns.push({ severity: SEVERITY[Math.floor(r() * 4)], fixedInNewBase: k.source === "base" && r() < 0.84, hasFix: r() < 0.72 }); COMPONENTS.push({ layer: k, source: k.source, inInventory, vulns }); } // The scan reads from the inventory: a component absent from the record has its // vulnerabilities show up in no run. export function scan(newBase = false) { const findings = [], unseen = [], closed = []; for (const c of COMPONENTS) for (const v of c.vulns) { const record = { ...v, source: c.source, layer: c.layer.name }; if (!c.inInventory) unseen.push(record); else if (newBase && v.fixedInNewBase) closed.push(record); else findings.push(record); } return { findings, unseen, closed }; } const gen = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j]) : String(v).padEnd(B[0]))).join(""); if (import.meta.url === `file://${process.argv[1]}`) { const { findings, unseen } = scan(); console.log(`model (seed ${SEED}): ${COMPONENTS.length} components, ` + `${COMPONENTS.reduce((a, c) => a + c.vulns.length, 0)} vulns; ` + `${COMPONENTS.filter((c) => !c.inInventory).length} components not in the package registry`); const y1 = gen([18, 11, 10, 14, 8, 9, 18]); console.log("\n" + y1(["layer", "source", "comps", "not in reg", "vulns", "findings", "unseen vulns"])); for (const k of LAYERS) { const l = COMPONENTS.filter((c) => c.layer === k); const vc = l.reduce((a, c) => a + c.vulns.length, 0); const uns = l.filter((c) => !c.inInventory).reduce((a, c) => a + c.vulns.length, 0); console.log(y1([k.name, k.source, k.count, l.filter((c) => !c.inInventory).length, vc, vc - uns, uns])); } const before = scan(), after = scan(true); const y2 = gen([12, 9, 8, 13, 12, 11, 12]); console.log("\nwhen the base image is moved to a new version"); console.log(y2(["source", "findings", "severe", "new findings", "new severe", "closed", "closed %"])); for (const src of ["base", "application"]) { const o = before.findings.filter((a) => a.source === src); const s = after.findings.filter((a) => a.source === src); console.log(y2([src, o.length, o.filter((a) => SEVERE.has(a.severity)).length, s.length, s.filter((a) => SEVERE.has(a.severity)).length, o.length - s.length, (100 * (o.length - s.length) / o.length).toFixed(1)])); } const remainingSevere = after.findings.filter((a) => SEVERE.has(a.severity)); console.log(`\nthe upgrade closes ${before.findings.length - after.findings.length} findings ` + `in a single operation, ${after.findings.length} findings remain; of the ` + `${remainingSevere.length} remaining severe findings, ` + `${remainingSevere.filter((a) => a.source === "application").length} are in the ` + `application layer`); console.log(`${remainingSevere.filter((a) => !a.hasFix).length} of the remaining severe ` + `findings have no published fix; ${unseen.length} vulns are never reported in any run, ` + `because the component carrying them never appears in the package registry at all`); }
model (seed 20260404): 348 components, 162 vulns; 31 components not in the package registry layer source comps not in reg vulns findings unseen vulns os base base 96 2 43 41 2 runtime base 64 3 36 34 2 dependencies application 148 21 64 50 14 compiled output application 22 2 8 8 0 utility tools application 18 3 11 11 0 when the base image is moved to a new version source findings severe new findings new severe closed closed % base 75 33 11 6 64 85.3 application 69 39 69 39 0 0.0 the upgrade closes 64 findings in a single operation, 80 findings remain; of the 45 remaining severe findings, 39 are in the application layer 10 of the remaining severe findings have no published fix; 18 vulns are never reported in any run, because the component carrying them never appears in the package registry at all
The first table splits the finding set by layer. 160 of the 348 components are in the base layers and carry 79 vulnerabilities; 188 are in the layers the application adds and carry 83 vulnerabilities. The numbers sit close to each other, but the two halves are not equivalent on the pipeline. The fix for the 75 findings in the base layer is not in the hands of the team building the image; someone else produced that layer, and swapping its packages one by one breaks the image’s identity.
The same table’s last two columns write down the scanner’s own limit. 31 components never appear in the package registry, and 21 of them are in the dependencies layer: binaries placed by hand during the build, never passed through a package manager. The 18 vulnerabilities they carry are reported in no run. This number does not change with a threshold setting; its source is how the inventory was built. A scan’s coverage list is however many components the inventory recognizes, and unless that number is reported, there is no way to know whether a layer that looks clean is actually clean or merely unread.
The second table gives the payoff of the split. Moving the base image to a new version closes 64 findings in a single operation: 85.3 percent of the base-sourced findings. The same operation never touches the application layer — 69 findings stay exactly as they were, zero closed. This is something the severity field never carries: of two items at the same weight, one closes with a one-line version bump, the other closes with dozens of changes that each have to be handled separately.
39 of the 45 severe findings remaining after the upgrade are in the application layer, and 10 have no published fix. So the base image upgrade does not carry most of the severe items; the bulk of the severe findings were already in the layers the team itself added. The two sentences have to be read together: the upgrade drops the finding count fast, but the severity distribution of what it drops is different from what remains.
How Many Times the Same Finding Gets Counted
// pipeline.mjs — three images built from the same base image are scanned across 60 // builds; what is measured is how many images the same finding is recounted in, and // who the block falls on. THIS IS A MODEL. import { scan, SCAN_MIN } from "./image.mjs"; const BUILDS = 60, REFRESH_EVERY = 20, BLOCK_MIN = 35; const SEVERE = new Set(["high", "critical"]); // published: the build at which the record was published. Even if the image never // changes, the finding count grows, because new records keep being published over time. const stamped = scan().findings.map((a, i) => ({ ...a, published: (i * 37) % BUILDS })); const BASE = stamped.filter((a) => a.source === "base"); const APP = stamped.filter((a) => a.source === "application"); // Two base images: base-a carries the full component set, base-b carries a narrower base. const BASES = { "base-a": BASE, "base-b": BASE.filter((_, i) => i % 3 !== 0) }; const IMAGES = [ { name: "subscriber portal", base: "base-a", app: APP.filter((_, i) => i % 3 === 0) }, { name: "meter intake", base: "base-a", app: APP.filter((_, i) => i % 3 === 1) }, { name: "admin tool", base: "base-b", app: APP.filter((_, i) => i % 3 === 2) }, ]; const POLICIES = [ { name: "every severe finding", block: (a) => SEVERE.has(a.severity), refresh: false }, { name: "application layer only", block: (a) => SEVERE.has(a.severity) && a.source === "application", refresh: false }, { name: "application + base refresh", block: (a) => SEVERE.has(a.severity) && a.source === "application", refresh: true }, ]; function measure(p) { const seen = new Set(), closed = new Set(); let blockedBuilds = 0, unfixable = 0, exposure = 0, blocking = 0; for (let y = 0; y < BUILDS; y++) { const im = IMAGES[y % IMAGES.length]; if (p.refresh && y > 0 && y % REFRESH_EVERY === 0) for (const a of BASE) if (a.fixedInNewBase && a.published <= y) closed.add(a); const set = [...BASES[im.base], ...im.app] .filter((a) => a.published <= y && !closed.has(a)); const fresh = set.filter((a) => p.block(a) && !seen.has(a)); for (const a of fresh) seen.add(a); if (fresh.length) { blockedBuilds++; blocking += fresh.length; if (fresh.every((a) => a.source === "base")) unfixable++; } exposure += set.filter((a) => a.source === "base" && SEVERE.has(a.severity)).length; } return { name: p.name, blocking, blockedBuilds, unfixable, exposure, min: Math.round(BUILDS * SCAN_MIN + blockedBuilds * BLOCK_MIN) }; } const gen = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j]) : String(v).padEnd(B[0]))).join(""); const raw = IMAGES.reduce((a, im) => a + BASES[im.base].length + im.app.length, 0); const unique = new Set(IMAGES.flatMap((im) => [...BASES[im.base], ...im.app])).size; console.log(`${IMAGES.length} images, ${BUILDS} builds; the three images' finding-row total ` + `is ${raw}, unique vulns ${unique}. The difference is the base layer being recounted in ` + `every image`); const y = gen([28, 12, 9, 14, 16, 11]); console.log("\n" + y(["policy", "blocking", "stopped", "unfixable", "severe exposure", "total min"])); for (const p of POLICIES) { const o = measure(p); console.log(y([o.name, o.blocking, o.blockedBuilds, o.unfixable, o.exposure, o.min])); } const [all, appOnly, refreshed] = POLICIES.map(measure); console.log(`\nthe policy that blocks every severe finding stops ${all.blockedBuilds} builds, ` + `and in ${all.unfixable} of them every blocking finding is entirely in the base layer: ` + `there is no line of code the image-building team could write`); console.log(`narrowing the block to the application layer brings stopped builds down to ` + `${appOnly.blockedBuilds}, but leaves the severe base exposure sitting at ` + `${appOnly.exposure} build-findings`); console.log(`refreshing the base every ${REFRESH_EVERY} builds, with the same block, brings ` + `exposure down to ${refreshed.exposure} build-findings, ` + `${(100 * (1 - refreshed.exposure / appOnly.exposure)).toFixed(1)} percent lower`);
3 images, 60 builds; the three images' finding-row total is 269, unique vulns 144. The difference is the base layer being recounted in every image policy blocking stopped unfixable severe exposure total min every severe finding 72 49 12 940 1805 application layer only 39 37 0 940 1385 application + base refresh 39 37 0 382 1385 the policy that blocks every severe finding stops 49 builds, and in 12 of them every blocking finding is entirely in the base layer: there is no line of code the image-building team could write narrowing the block to the application layer brings stopped builds down to 37, but leaves the severe base exposure sitting at 940 build-findings refreshing the base every 20 builds, with the same block, brings exposure down to 382 build-findings, 59.4 percent lower
The first line shows the source of a counting error. When the three images’ reports are stacked, 269 finding rows come out, but the unique vulnerability count is 144. The 125-row gap is the same base layer being recounted across two images. An organization’s total finding count is a quantity multiplied by the number of images; producing twice as many images reports the same vulnerabilities twice as many times, and the number gets worse while the underlying state does not change. The same multiplier runs in reverse: a single base image upgrade closes across every image at once.
The policy table’s first row is the pipeline’s most common setup: every severe finding stops the build. 49 of 60 builds stop, and in 12 of them every blocking finding is entirely in the base layer. In those twelve builds there is no work for the developer to do; the finding is real, its severity is real, but the change that closes it belongs to another repository. This is the most expensive form of blocking: a stopped build sits in front of a team that does not own the fix.
The second row narrows the block to the application layer. Stopped builds drop from 49 to 37, total cost drops from 1,805 minutes to 1,385, and blocks the team cannot fix go to zero. But the exposure column does not move at all: 940 build-findings under both policies. This column writes the real question — the severe base vulnerabilities keep going to production across sixty builds. Stopping the build was never closing them; it was only getting someone’s attention.
The third row shows what actually closes it. Refreshing the base image every twenty builds, with the same block and the same 1,385 minutes, brings exposure down to 382 build-findings, 59.4 percent lower. What closes a base layer vulnerability is not a gate, it is a refresh rhythm. The gate only makes the finding visible; how often the image gets rebuilt is what actually removes it. The fact that the known finding count grows even when the image never changes makes this necessary: new records keep being published, and an image reported clean on the day it was built carries severe findings months later without ever being touched.
Summary
- The same finding set splits by source: 75 of 144 findings come from the base layer, 69 from the layers the application adds, and the two halves’ fix paths look nothing alike.
- Moving the base image to a new version closes 64 findings — 85.3 percent of the base-sourced ones — in a single operation and never touches the application layer; the closed share on the application side is zero.
- 39 of the 45 severe findings remaining after the upgrade are in the application layer: the upgrade drops the finding count fast but leaves most of the severe items in place.
- 31 components do not appear in the package registry, and the 18 vulnerabilities they carry are reported in no run; this number does not change with a threshold, its source is how the inventory was built.
- The three images’ 269 finding rows correspond to 144 unique vulnerabilities; the finding count is a quantity multiplied by the number of images, and a single base upgrade closes across every image at once.
- Blocking does not lower exposure: blocking every severe finding stops 49 builds, and in 12 of them there is no work the team can do, but severe base exposure stays at 940 build-findings; refreshing every twenty builds with the same block brings it down to 382.
Next Step
An image comes from somewhere: the environment that builds and runs it is itself a written text. How many copies will run, which network rule stays open, which bucket is readable from outside — all of it is defined in a template, and that template also lives in the repository. None of the checks so far have looked at it; yet a single field in the template can open a wider path than every finding inside the image combined. What sets template checking apart is its timing: a rule that can run before deployment stops a misconfiguration before its source ever comes into being. The next lesson runs a set of templates through a rule set, counts the items caught before deployment, and measures how many builds get blocked because of it.
To keep your progress and take notes, Log in
My notes
Log in to take notes.