---
title: 'Dependency Component Analysis'
source: 'https://academia.sh/en/courses/security-automation/dependency-component-analysis'
course: 'Security Automation and the Supply Chain'
language: en
updated: '2026-09-26T03:02:40+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Matches the dependency inventory against the known-vulnerability catalog and eliminates raw matches in three steps'
    - 'Splits affected components along two axes: direct versus transitive, invoked versus not invoked'
    - 'Counts the builds a blocking policy stops and shows where the remaining blockage comes from'
---

# Dependency Component Analysis

A 210-component inventory is matched against the known-vulnerability catalog: 102 of the 139 raw name matches are eliminated at the version-range check, 6 at the backported-fix check, leaving 31 real matches; 12 unlisted vulnerabilities are never seen at all, 6 of them in a component the catalog does not recognize. 25 of the 30 affected components are transitive, 12 are never invoked. Blocking on name alone stops 78 of 80 builds; the version-range check brings that to 70, the invocation filter to 57, and the remaining blocked builds trace to a single transitive component with no fixed upstream version.

The previous two checks worked from the same assumption: what gets scanned is the code the
team wrote. But most of a running process comes from components written elsewhere, and the
vulnerabilities in those components are usually already known — someone has found them, put
them on record, and published them. Here the job is not finding, it is **matching**: there is
a dependency inventory on one side and a list of records on the other.

**Software composition analysis** is the check that extracts the components a piece of
software carries and compares them against a **known-vulnerability catalog**. The matching
has its own false positives and its own false negatives; both are this lesson's subject. The
last lesson of the Secure Coding course measured the same graph's **structure** — how many
components there are, how many are transitive, how many are never invoked. Here the graph is
taken as input, and what is measured is **the matching itself**.

- **AC13.** The inventory is 210 components; 24 are direct dependencies that appear by name
  in the manifest file, the rest are transitive. Component names (`b000`, `b001`, …) and
  catalog records are **fictional**.
- **AC14.** A catalog record carries a component name and an **affected version range**. Name
  matching alone is not proof; the range check is a separate step.
- **AC15.** A component that appears within the version range may still be unaffected: the fix
  may have been backported to the version in use. This is the false positive the range check
  does **not** eliminate.
- **AC16.** The catalog is not complete. A component may not be recognized by the catalog at
  all; a vulnerability may also not yet be on record. Both situations are collected into a
  single field named **unlisted vulnerability**, and no matching can see it.
- **AC17.** Being invoked is not the same thing as being installed; this distinction is a
  separate field in the inventory.
- **AC18.** A dependency finding blocks **every build** until it is fixed. Fix time depends on
  the path and is given in model builds; the durations are **data**, not a measurement.

## From Raw Match to Real Impact

```js
// matching.mjs — the dependency inventory is matched against the known-vulnerability catalog.
// THIS IS A MODEL; component names (b001...) and catalog records are fictional.
export const SEED = 20260323, COMPONENTS = 210, DIRECT = 24;
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"];

// Inventory: every component has a version, whether it is invoked is a separate property.
// inCatalog = whether the known-vulnerability catalog recognizes this component at all.
export const INVENTORY = Array.from({ length: COMPONENTS }, (_, i) => {
  const direct = i < DIRECT, inCatalog = r() < 0.80;
  return { name: `b${String(i).padStart(3, "0")}`, direct, inCatalog,
    version: 1 + Math.floor(r() * 9), invoked: r() < (direct ? 0.86 : 0.55),
    hasUpstreamFix: r() < 0.62,              // has a fixed upstream version been published
    unlistedVuln: r() < (inCatalog ? 0.04 : 0.16) };
});

// Catalog record: carries a component name and an affected version range. Name matching
// alone is not proof; the version range and a backported fix are separate checks.
export const RECORDS = [];
for (const b of INVENTORY) {
  if (!b.inCatalog) continue;
  for (let k = 0, n = Math.floor(r() * 2.6); k < n; k++) {
    const low = 1 + Math.floor(r() * 8), high = low + 1 + Math.floor(r() * 4);
    RECORDS.push({ component: b, low, high, severity: SEVERITY[Math.floor(r() * 4)],
      inRange: b.version >= low && b.version < high, backported: r() < 0.14,
      published: Math.floor(r() * 80) });
  }
}

// Elimination chain: three steps take raw name matches down to real impact.
export function chain() {
  const steps = [], step = (name, list, keep) => {
    const kept = list.filter(keep);
    steps.push({ name, entering: list.length, dropped: list.length - kept.length,
      left: kept.length });
    return kept;
  };
  let l = step("name match", RECORDS, () => true);
  l = step("version range", l, (k) => k.inRange);
  l = step("backported fix", l, (k) => !k.backported);
  return { steps, real: l };
}

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 { steps, real } = chain();
  const unlisted = INVENTORY.filter((b) => b.unlistedVuln);
  console.log(`model (seed ${SEED}): ${COMPONENTS} components (${DIRECT} direct), ` +
    `${INVENTORY.filter((b) => !b.inCatalog).length} not in the catalog at all; ` +
    `${RECORDS.length} catalog records`);
  const y1 = gen([24, 9, 9, 9]);
  console.log("\n" + y1(["elimination step", "entering", "dropped", "left"]));
  for (const a of steps) console.log(y1([a.name, a.entering, a.dropped, a.left]));

  const affected = [...new Set(real.map((k) => k.component))];
  const falsePos = steps[0].entering - steps[2].left;
  console.log(`\nthree numbers: ${real.length} real matches (${affected.length} components), ` +
    `${falsePos} false positives, ${unlisted.length} unlisted vulnerabilities are never seen at all`);
  console.log(`${unlisted.filter((b) => !b.inCatalog).length} of the unlisted vulnerabilities ` +
    `sit in a component the catalog does not recognize at all`);

  const y2 = gen([22, 9, 9, 10, 13, 15]);
  console.log("\n" + y2(["affected set", "comps", "records", "invoked", "not invoked",
    "upstream fix"]));
  for (const [name, sel] of [["direct", (b) => b.direct], ["transitive", (b) => !b.direct]]) {
    const l = affected.filter(sel);
    console.log(y2([name, l.length, real.filter((k) => sel(k.component)).length,
      l.filter((b) => b.invoked).length, l.filter((b) => !b.invoked).length,
      l.filter((b) => b.hasUpstreamFix).length]));
  }
}
```

```
model (seed 20260323): 210 components (24 direct), 49 not in the catalog at all; 139 catalog records

elimination step         entering  dropped     left
name match                    139        0      139
version range                 139      102       37
backported fix                 37        6       31

three numbers: 31 real matches (30 components), 108 false positives, 12 unlisted vulnerabilities are never seen at all
6 of the unlisted vulnerabilities sit in a component the catalog does not recognize at all

affected set              comps  records   invoked  not invoked   upstream fix
direct                        5        5         4            1              3
transitive                   25       26        14           11             19
```

The elimination chain separates two different types of false positive. **The version-range
check alone wipes out 102 records, 73 percent of the raw matches.** These are items that
amount to nothing more than "we have this component and its name appears in a record"; the
version in use is outside the affected range. A matching that looks only at the name reports
139 findings, and three-quarters of the report falls away with a single field comparison.

The second step removes the false positive the range check **fails** to eliminate: 6 records
appear within the version range, but the build in use has backported the fix. These items can
only be separated by looking at what code the component actually carries; the version string
is not enough. After three steps, 139 raw matches leave **31 real matches**, across 30
components.

The third number is the catalog's own limit. **12 unlisted vulnerabilities appear in no
matching at all**, and 6 of them sit in a component the catalog does not recognize at all.
This is not a configuration mistake: matching only finds what has been put on record, and 49
of the inventory's components are not in the catalog. **A dependency scan's coverage list is
how many components the catalog recognizes**; unless that number is reported, "zero findings"
cannot be told apart from "never looked".

The second table splits the 30 affected components along two axes. Only 5 are direct
dependencies; 25 are transitive, and their fix path is different. A direct component is
upgraded with a single line in the manifest file; a transitive component first needs a new
version of its **parent component**, and that version exists for 19 items, not for 6. Of two
items carrying the same severity, one's fix depends on a single commit, the other's on someone
else's release. The second axis is invocation: 12 of the 30 affected components are installed
in the process and never run.

These two axes do not fit into a single severity field. Severity describes the vulnerability's
own weight and knows nothing about the component's place in this codebase. Of three items
carrying the same severity value, one closes with a single-line upgrade, one waits on someone
else's release, and one falls out of the queue because it never runs at all. The information
that tells these three apart is not in the catalog's record; it is in the inventory.

## Blocking Policy and the Stalled Pipeline

```js
// policy.mjs — the same record set passes through three blocking policies; what is
// measured is how many builds get blocked and how much of that is wasted. Durations are MODELED.
import { INVENTORY, RECORDS, chain } from "./matching.mjs";

const BUILDS = 80, MATCH_MIN = 2, BLOCK_MIN = 30;
const REAL = new Set(chain().real);
const SEVERE = new Set(["high", "critical"]);

// A finding blocks EVERY build until it is fixed. Fix time depends on the path:
// a direct dependency is one line, a transitive one waits on its parent's new version.
const fixTime = (k) => !REAL.has(k) ? 2
  : k.component.direct ? 2 : k.component.hasUpstreamFix ? 7 : 45;

const POLICY = [
  { name: "name + severity", keep: (k) => SEVERE.has(k.severity) },
  { name: "+ version range", keep: (k) => SEVERE.has(k.severity) && k.inRange },
  { name: "+ invoked", keep: (k) => SEVERE.has(k.severity) && k.inRange && k.component.invoked },
];

function measure(p) {
  const blocking = RECORDS.filter(p.keep), days = new Map();
  for (const k of blocking) {
    for (let y = k.published; y < Math.min(BUILDS, k.published + fixTime(k)); y++) {
      if (!days.has(y)) days.set(y, []);
      days.get(y).push(k);
    }
  }
  return { name: p.name, blocking: blocking.length,
    real: blocking.filter((k) => REAL.has(k)).length,
    falsePos: blocking.filter((k) => !REAL.has(k)).length,
    notInvoked: blocking.filter((k) => !k.component.invoked).length,
    long: blocking.filter((k) => fixTime(k) === 45).length,
    blocked: days.size, min: BUILDS * MATCH_MIN + days.size * BLOCK_MIN,
    falseBlocked: [...days.values()].filter((l) => !l.some((k) => REAL.has(k))).length };
}

const gen = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
  : String(v).padEnd(B[0]))).join("");
const y = gen([18, 11, 9, 9, 13, 12, 14, 11]);
console.log(`${BUILDS} builds; a finding blocks every build until it is fixed. Matching costs ` +
  `${MATCH_MIN}, a blocked build costs ${BLOCK_MIN} model minutes each`);
console.log(y(["policy", "blocking", "real", "false+", "not invoked", "blocked",
  "false blocked", "total min"]));
const s = POLICY.map(measure);
for (const o of s) {
  console.log(y([o.name, o.blocking, o.real, o.falsePos, o.notInvoked, o.blocked, o.falseBlocked,
    o.min]));
}

// A vulnerability in a component that is never invoked carries the same severity, but a
// different fix path.
const notInvoked = [...REAL].filter((k) => !k.component.invoked);
const nonSevere = [...REAL].filter((k) => k.component.invoked && !SEVERE.has(k.severity));
console.log(`\nthe version-range check drops blocking records from ${s[0].blocking} to ` +
  `${s[1].blocking}, blocked builds from ${s[0].blocked} to ${s[1].blocked}, and leaves no ` +
  `real vulnerability out`);
console.log(`the invocation filter removes ${s[1].notInvoked} more records, blocked builds ` +
  `${s[2].blocked}; everything it removes sits in a component that is never invoked, no ` +
  `invoked vulnerability is dropped`);
console.log(`the remaining ${s[2].blocked} blocked builds trace to ${s[2].long} of the ` +
  `${s[2].blocking} records the last policy blocks: a vulnerability sitting in a transitive ` +
  `component with no fixed upstream version blocks the pipeline for 45 builds`);
console.log(`${notInvoked.length} not-invoked and ${nonSevere.length} non-severe real ` +
  `vulnerabilities remain in the queue as work items; ${INVENTORY.filter((b) => b.unlistedVuln).length} ` +
  `unlisted vulnerabilities never appear in any of the three policies`);
```

```
80 builds; a finding blocks every build until it is fixed. Matching costs 2, a blocked build costs 30 model minutes each
policy               blocking     real   false+  not invoked     blocked false blocked  total min
name + severity            65       11       54           28          78             8       2500
+ version range            13       11        2            7          70             0       2260
+ invoked                   6        6        0            0          57             0       1870

the version-range check drops blocking records from 65 to 13, blocked builds from 78 to 70, and leaves no real vulnerability out
the invocation filter removes 7 more records, blocked builds 57; everything it removes sits in a component that is never invoked, no invoked vulnerability is dropped
the remaining 57 blocked builds trace to 1 of the 6 records the last policy blocks: a vulnerability sitting in a transitive component with no fixed upstream version blocks the pipeline for 45 builds
12 not-invoked and 13 non-severe real vulnerabilities remain in the queue as work items; 12 unlisted vulnerabilities never appear in any of the three policies
```

The first row shows what happens when a dependency scan is placed directly as a gate in the
pipeline. A policy that looks only at name and severity counts 65 records as blocking, 54 of
which are false positives, and **78 of 80 builds are stopped**. The pipeline runs for only two
builds. Once the version-range check is added, blocking records drop to 13, builds stopped for
nothing drop to zero, and **not a single real vulnerability is left out**: this is a narrowing
with no cost, because every eliminated item was already wrong.

The third row is a narrowing that has a cost, and it must be read carefully. The invocation
filter removes 7 more records, and blocked builds drop to 57. The seven items removed are
**not wrong** — every one of them is a real vulnerability. What is different is their
reachability: the component is installed in the process, never invoked from the code path, so
there is no call that leads to that vulnerability. These items are not erased from the report;
they turn into a work item that does not stop the pipeline and close on the component's next
upgrade. What separates them is reachability, not severity, and **the severity field carries
none of this information**.

The last row writes down where the real cost sits. Even after every false positive has been
eliminated, 57 builds still stop, and the source is **a single one** of the six blocking
records: one vulnerability, sitting in a transitive component whose fixed upstream version has
not yet been published, locks the pipeline for 45 builds. There is nothing a threshold setting
can do here. The decision is how long the gate holds this item: blocking indefinitely stalls
the pipeline, letting it through with an exception record carries the vulnerability into
production. **The number of blocked builds is a measure of the fix path, not of the scan.**

The pipeline-minutes column reads differently in this check than in the other two. The
matching itself costs two minutes per build; of the total 2,500 minutes, 160 go to the scan,
the rest to the stalled pipeline. In static analysis, most of the cost was review time; in
dynamic scanning, it was run time; here, 94 percent of the cost is builds the gate holds. All
three checks have a "pipeline minutes" column, but what fills that column is different in
each.

## Summary

- Name matching is not proof: 102 of the 139 raw matches are eliminated at the version-range
  check, 6 at the backported-fix check, leaving 31 real matches.
- Catalog coverage is the source of the false negatives: the catalog does not recognize 49 of
  the 210 components, and 12 unlisted vulnerabilities appear in no matching at all, 6 of them
  in a component the catalog does not recognize.
- 25 of the 30 affected components are transitive, and their fix depends on their parent
  component's new version; 6 items have no such version, so the same severity means two
  separate fix paths.
- Blocking on name and severity alone stops 78 of 80 builds; the version-range check brings
  that down to 70 and leaves no real vulnerability out.
- The invocation filter takes 7 real items outside the gate and brings blocked builds down to
  57; these items are not wrong, they change priority because they are unreachable and remain
  in the queue as work items.
- The remaining 57 blocked builds trace to a single record: a transitive component with no
  fixed upstream version locks the pipeline for 45 builds, and this is not a
  scan-configuration problem.

## Next Step

All three checks so far looked at what the code does: which call is dangerous, which endpoint
responds, which component is affected. One more thing sits in the repository and none of them
sees it — values **written directly into the code**. When an access key is accidentally added
to a file, the build passes, the tests pass, all three scans come back clean. This item also
has a property of its own: the commit that removes it does not delete it from the repository,
it keeps sitting in the history. The next lesson runs pattern matching against a fictional
repository history, measures the false-positive rate, and compares the cost of scanning only
the current state against scanning the entire history.
