---
title: 'Container Image Scanning'
source: 'https://academia.sh/en/courses/security-automation/container-image-scanning'
course: 'Security Automation and the Supply Chain'
language: en
updated: '2026-09-26T03:02:40+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "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'
---

# 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.

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

```js
// 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

```js
// 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.
