Lesson 02 / 11
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.
Published Updated
In this lesson
- 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
Contents
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
// 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
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.