Lesson 19 / 21
Software Bill of Materials
The components inside a build artifact are produced as an inventory and measured with three numbers: the direct, transitive, and build-time components the inventory sees, the three classes it never sees at all, and how many queries the response takes when a defect advisory arrives.
Published Updated
In this lesson
- Writes the dependency tree to disk, produces the inventory, and counts its coverage: direct, transitive, build time
- Finds the components the inventory cannot see by walking the output tree, and separates what the gate wrongly holds
- Compares the cost of responding to a defect advisory across two paths: a single query versus opening repositories one by one
Contents
The previous lesson measured that a secret can stay sealed in the repository: the key sits in one place, the subjects that can decrypt it are counted, rotation rewrites a fixed number of records. The configuration entering the repository is now declared — but what is declared is only the configuration. What is inside the output the pipeline produces is still written down nowhere.
This lesson produces that declaration. The measure is three numbers: how many components the inventory sees, how many it can never see at all, and how many queries the response takes when a defect advisory arrives. M22/K02 counted the defects scanning caught and missed; that count is not repeated. The object here is the inventory itself — as an output the pipeline produces.
RD19. The fictional regional measurement network is four services and shares a pool of 64
components; the tree is written to disk with node and is a model. RD20. The inventory
generator reads only the manifest files: manifest, resolved, and build. RD21. There are three
undeclared paths — vendored into source, embedded in the binary, fetched at runtime; the
generator’s seed 20260214 is visible.
Producing the Inventory
// measurement-network/inventory.mjs — a fictional dependency tree is written to disk, inventory is produced import { mkdirSync, rmSync, writeFileSync, readFileSync, statSync } from "node:fs"; import { join, dirname } from "node:path"; const ROOT = join(process.env.TMPDIR ?? "/tmp", "measurement-network-inventory"); rmSync(ROOT, { recursive: true, force: true }); const generator = (seed) => () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; const r = generator(20260214); // seed visible const pick = (d) => d[Math.floor(r() * d.length)]; // Fictional regional measurement network: four services. The pool is shared, the same component appears in multiple services. const POOL = Array.from({ length: 64 }, (_, i) => `component-${String(i + 1).padStart(2, "0")}`); const VERSION = ["1.4", "2.0", "2.3", "2.6", "3.1", "3.4"]; const SERVICE = [ // vendored/embedded/runtime: undeclared components { name: "collector", direct: 9, tool: 5, vendored: 2, embedded: 1, runtime: 1, ownCopy: 1 }, { name: "verifier", direct: 7, tool: 4, vendored: 1, embedded: 1, runtime: 0, ownCopy: 1 }, { name: "billing", direct: 12, tool: 6, vendored: 2, embedded: 2, runtime: 2, ownCopy: 1 }, { name: "workorder", direct: 6, tool: 4, vendored: 1, embedded: 0, runtime: 1, ownCopy: 0 }, ]; const write = (path, content) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content); }; for (const s of SERVICE) { const taken = new Set(); const fresh = () => { let a; do { a = pick(POOL); } while (taken.has(a)); taken.add(a); return a; }; const direct = {}, resolved = {}, tools = {}; for (let i = 0; i < s.direct; i++) { const name = fresh(); resolved[name] = { version: (direct[name] = pick(VERSION)), requiredBy: "" }; for (let k = 0, n = Math.floor(r() * 4); k < n; k++) // transitive children resolved[fresh()] = { version: pick(VERSION), requiredBy: name }; } for (let i = 0; i < s.tool; i++) tools[fresh()] = pick(VERSION); write(join(ROOT, s.name, "manifest.json"), JSON.stringify({ service: s.name, direct }, null, 1)); write(join(ROOT, s.name, "resolved.json"), JSON.stringify(resolved, null, 1)); write(join(ROOT, s.name, "build.json"), JSON.stringify({ tools }, null, 1)); for (let i = 0; i < s.vendored; i++) // third party vendored into source write(join(ROOT, s.name, "source", `k${i}.js`), `// vendored: ${fresh()}@${pick(VERSION)}\n`); for (let i = 0; i < s.ownCopy; i++) // own code, not third party write(join(ROOT, s.name, "source", `i${i}.js`), `// vendored: [email protected]\n`); for (let i = 0; i < s.embedded; i++) // embedded inside the binary write(join(ROOT, s.name, "output", "embedded", `g${i}.bin`), Buffer.concat( [Buffer.from(`EMBEDDED:${fresh()}@${pick(VERSION)}\n`), Buffer.alloc(2048, "x")])); const fetched = Array.from({ length: s.runtime }, () => `${fresh()}@${pick(VERSION)}`); write(join(ROOT, s.name, "output", "downloads.json"), JSON.stringify({ fetched }, null, 1)); write(join(ROOT, s.name, "output", "body.js"), `// ${s.name} build output\n`); } // The inventory generator reads ONLY the declared manifests; files and bytes read are counted. let file = 0, byte = 0; const read = (y) => { file++; byte += statSync(y).size; return JSON.parse(readFileSync(y)); }; const inventory = []; for (const s of SERVICE) { const manifest = read(join(ROOT, s.name, "manifest.json")).direct; const resolved = read(join(ROOT, s.name, "resolved.json")); for (const [name, k] of Object.entries(resolved)) inventory.push({ service: s.name, name, version: k.version, kind: name in manifest ? "direct" : "transitive", requiredBy: k.requiredBy }); for (const [name, ver] of Object.entries(read(join(ROOT, s.name, "build.json")).tools)) inventory.push({ service: s.name, name, version: ver, kind: "build", requiredBy: "(build)" }); } const inventoryPath = join(ROOT, "inventory.json"); write(inventoryPath, JSON.stringify(inventory, null, 1)); write(join(ROOT, "cost.json"), JSON.stringify({ file, byte })); const count = (t) => inventory.filter((k) => k.kind === t).length; console.log(`seed 20260214; services ${SERVICE.length}, pool ${POOL.length} components`); console.log(`manifest files read to produce the inventory ${file}, ${byte} bytes`); console.log(`inventory records ${inventory.length} -> direct ${count("direct")}, ` + `transitive ${count("transitive")}, build time ${count("build")}`); console.log(`inventory file ${statSync(inventoryPath).size} bytes, ` + `unique components ${new Set(inventory.map((k) => k.name)).size}`);
seed 20260214; services 4, pool 64 components manifest files read to produce the inventory 12, 7674 bytes inventory records 107 -> direct 34, transitive 54, build time 19 inventory file 13511 bytes, unique components 56
The four services declare 34 direct dependencies in total. The inventory produces 107 records: alongside that 34 come 54 transitive components and 19 build-time tools. The ratio between what is declared and what is carried is right here — the team writes 34 names, 88 components enter the output. A gate that looks only at direct dependencies sees 34 of the declared 88 runtime components (39%).
The cost of production is written in quantities independent of the environment: 12 manifest files, 7,674 bytes read, 107 records, a 13,511-byte inventory. Duration is not printed: duration depends on the machine, not on the number of files read.
Behind the 107 records stand 56 unique components: the same component appears across multiple services. The ability to answer “which services are affected” from a single file rests on this.
What the Inventory Does Not See
The generator reads the manifest files. A manifest describes intent: what was meant to be pulled in. The output tree, in turn, carries the result. The two do not have to be the same.
RD22. Build-time tools do not run in production; they do not enter the runtime component set.
RD23. The auditor walks the tree and counts every file carrying a vendored: or EMBEDDED:
header as undeclared; a copy of its own code carries the same header too.
// measurement-network/coverage.mjs — what the inventory does not see: the output tree is walked, compared against the manifest import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs"; import { join } from "node:path"; const ROOT = join(process.env.TMPDIR ?? "/tmp", "measurement-network-inventory"); const inventory = JSON.parse(readFileSync(join(ROOT, "inventory.json"))); const cost = JSON.parse(readFileSync(join(ROOT, "cost.json"))); // what the generator read const SERVICE = ["collector", "verifier", "billing", "workorder"]; const walk = (root, sub = "") => readdirSync(join(root, sub), { withFileTypes: true }) .flatMap((g) => g.isDirectory() ? walk(root, join(sub, g.name)) : [join(sub, g.name)]); // The auditor reads not the manifest files, but the tree itself. let walkedFile = 0, walkedByte = 0; const finding = []; for (const s of SERVICE) { for (const path of walk(join(ROOT, s))) { if (path.endsWith(".json")) continue; // manifest files are not the auditor's input const full = join(ROOT, s, path); walkedFile++; walkedByte += statSync(full).size; const head = readFileSync(full).subarray(0, 120).toString("latin1"); const k = head.match(/vendored: (\S+)@(\S+)/); if (k) finding.push({ service: s, name: k[1], version: k[2], how: "vendored into source" }); const g = head.match(/EMBEDDED:(\S+)@(\S+)/); if (g) finding.push({ service: s, name: g[1], version: g[2], how: "embedded in binary" }); } const { fetched } = JSON.parse(readFileSync(join(ROOT, s, "output", "downloads.json"))); for (const record of fetched) { const [name, version] = record.split("@"); finding.push({ service: s, name, version, how: "fetched at runtime" }); } } // A declared runtime component: build tools do not run in production, they are not in the runtime set. const declared = new Set(inventory.filter((k) => k.kind !== "build").map((k) => `${k.service}|${k.name}`)); const thirdParty = (name) => name.startsWith("component-"); const undeclared = finding.filter((b) => thirdParty(b.name) && !declared.has(`${b.service}|${b.name}`)); const wronglyHeld = finding.filter((b) => !thirdParty(b.name)); const count = (how) => undeclared.filter((b) => b.how === how).length; const declaredRuntime = inventory.filter((k) => k.kind !== "build").length; const runtimeTotal = declaredRuntime + undeclared.length; console.log(`runtime components the inventory sees ${declaredRuntime}, ` + `build time ${inventory.length - declaredRuntime}`); console.log(`the inventory does not see ${undeclared.length} -> ` + `vendored into source ${count("vendored into source")}, ` + `embedded in binary ${count("embedded in binary")}, fetched at runtime ${count("fetched at runtime")}`); console.log(`coverage ${(100 * declaredRuntime / runtimeTotal).toFixed(0)}% ` + `(${declaredRuntime}/${runtimeTotal} runtime components)`); const COLUMN = [23, 26, 7, 7, 0]; const row = (s) => console.log(s.map((v, i) => String(v).padEnd(COLUMN[i])).join("")); console.log(""); row(["gate", "reads", "holds", "alone", "wrongly holds"]); row(["reads manifest files", `${cost.file} files / ${cost.byte} bytes`, 0, 0, 0]); row(["walks the output tree", `${walkedFile} files / ${walkedByte} bytes`, undeclared.length, undeclared.length, wronglyHeld.length]); console.log(`wrongly held ${wronglyHeld.length} records: ` + `${wronglyHeld.map((b) => b.service).join(", ")} -> ${wronglyHeld[0].name}`); // The actual runtime set is carried into the next block: declared + undeclared. writeFileSync(join(ROOT, "actual.json"), JSON.stringify([ ...inventory.filter((k) => k.kind !== "build") .map(({ service, name, version }) => ({ service, name, version, visible: true })), ...undeclared.map(({ service, name, version }) => ({ service, name, version, visible: false })), ]));
runtime components the inventory sees 88, build time 19 the inventory does not see 14 -> vendored into source 6, embedded in binary 4, fetched at runtime 4 coverage 86% (88/102 runtime components) gate reads holds alone wrongly holds reads manifest files 12 files / 7674 bytes 0 0 0 walks the output tree 17 files / 8715 bytes 14 14 3 wrongly held 3 records: collector, verifier, billing -> internal-library-measurement
Of the 102 running components, 88 are visible in the inventory; 14 are invisible — 6 vendored into source, 4 embedded in the binary, 4 fetched at runtime. Coverage is 86%. The missing 14% is the measurement’s most expensive part, because the inventory reports these components not as “absent,” but by never showing them at all.
The table sets the two gates side by side. The gate that reads manifest files holds zero undeclared components; it cannot, because where it looks is the manifest itself. Its marginal value as a gate is zero, and it has no need to stand as a gate: producing the inventory is enough, the authority to permit passage earns it nothing. This is the gate that is better off removed.
The gate that walks the output tree holds 14 of the 14, and holds all of them only by itself — its marginal value is the entirety of what it holds. The reading cost is small: 12 files and 7,674 bytes against 17 files and 8,715 bytes — a different read, not a bigger one. The real cost is wrongful holding: 3 of the 17 findings are copies of its own code. One in every six findings is a wasted review, and the behavior this ratio produces is a familiar one — the list gets marked “known” wholesale once, and the next real finding stays under the same mark.
When an Advisory Arrives
The inventory’s value is measured not at the moment it is produced, but at the moment it is asked. The question always takes the same form: versions below such-and-such a component’s such-and-such release are exposed — do we have it, where?
RD24. A defect advisory is a component name and a threshold version; versions below the
threshold count as exposed. The advisory draw’s seed 884301 is visible.
// measurement-network/advisory.mjs — the response when a defect advisory arrives: the inventoried path and the uninventoried path import { readFileSync } from "node:fs"; import { join } from "node:path"; const ROOT = join(process.env.TMPDIR ?? "/tmp", "measurement-network-inventory"); const SERVICE = ["collector", "verifier", "billing", "workorder"]; const VERSION = ["1.4", "2.0", "2.3", "2.6", "3.1", "3.4"]; const read = (name) => JSON.parse(readFileSync(join(ROOT, name))); const inventory = read("inventory.json"), cost = read("cost.json"); const actual = read("actual.json"); // previous block's audit: declared + undeclared const generator = (seed) => () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; const r = generator(884301); // the advisory draw's seed is visible const NAMES = [...new Set(actual.map((k) => k.name))].sort(); const ADVISORY = Array.from({ length: 9 }, () => ({ name: NAMES[Math.floor(r() * NAMES.length)], threshold: VERSION[1 + Math.floor(r() * (VERSION.length - 1))], // versions below this are exposed })); const affected = (set, d) => new Set(set .filter((k) => k.name === d.name && VERSION.indexOf(k.version) < VERSION.indexOf(d.threshold)) .map((k) => k.service)); console.log(`actual runtime records ${actual.length} ` + `(invisible ${actual.filter((k) => !k.visible).length}), advisories ${ADVISORY.length}; seed 884301`); console.log(""); console.log("advisory".padEnd(20) + "inventory answer".padEnd(18) + "actual".padEnd(14) + "status"); let correct = 0, incomplete = 0, impactful = 0; for (const d of ADVISORY) { const answer = affected(actual.filter((k) => k.visible), d), real = affected(actual, d); if (real.size) impactful++; answer.size === real.size ? correct++ : incomplete++; console.log(`${d.name} < ${d.threshold}`.padEnd(20) + `${answer.size} service(s)`.padEnd(18) + `${real.size} service(s)`.padEnd(14) + (answer.size === real.size ? "complete" : "incomplete")); } const DECLARE_FILES = SERVICE.length * 3; // manifest, resolved, build per repo console.log(""); console.log("path".padEnd(28) + "per advisory".padEnd(28) + `for ${ADVISORY.length} advisories`); console.log("query the inventory file".padEnd(28) + `1 query / ${inventory.length} records`.padEnd(28) + `1 build (${cost.file} files) + ${ADVISORY.length} queries`); console.log("open repos one by one".padEnd(28) + `${SERVICE.length} repos / ${DECLARE_FILES} files`.padEnd(28) + `${ADVISORY.length * SERVICE.length} repo openings, ${ADVISORY.length * DECLARE_FILES} files`); console.log(""); console.log(`advisories with an affected service ${impactful}/${ADVISORY.length}; ` + `inventory answered fully ${correct}, incompletely ${incomplete}`);
actual runtime records 102 (invisible 14), advisories 9; seed 884301 advisory inventory answer actual status component-47 < 2.6 0 service(s) 1 service(s) incomplete component-49 < 3.4 0 service(s) 1 service(s) incomplete component-59 < 2.3 0 service(s) 0 service(s) complete component-41 < 2.0 0 service(s) 0 service(s) complete component-12 < 2.0 0 service(s) 0 service(s) complete component-12 < 2.3 0 service(s) 0 service(s) complete component-05 < 3.1 1 service(s) 1 service(s) complete component-58 < 2.3 0 service(s) 0 service(s) complete component-15 < 2.6 1 service(s) 2 service(s) incomplete path per advisory for 9 advisories query the inventory file 1 query / 107 records 1 build (12 files) + 9 queries open repos one by one 4 repos / 12 files 36 repo openings, 108 files advisories with an affected service 4/9; inventory answered fully 6, incompletely 3
Five of the nine advisories affect no service at all. The most frequent answer is “we do not have it,” and the inventory’s value lies primarily here: that answer arrives in a single query. To reach the same answer, the inventory-less path opens four repositories, reads twelve manifest files for every advisory — 36 repository openings and 108 files for nine advisories.
This sets the direction of the time the gate adds. Producing the inventory adds a stage to the pipeline, and that stage reads 12 files on every change; against that, the answer drops from four repository openings per advisory to a single query. The gate’s cost is per change, its payoff is per advisory; the ratio is set by advisory frequency, not by the gate itself.
The last line is the price of coverage. The inventory answers 6 of 9 advisories fully, 3
incompletely. In two, the answer is “0 services,” the actual is “1 service”; the advisory gets
closed as irrelevant. The third is worse: for component-15, the inventory says “1 service,” the
actual is “2 services.” A zero answer sometimes gets questioned, a number does not — the work
looks done. This is the price of the 14 invisible components, and it comes back the moment the
gate that walks the output tree is removed.
Summary
- The four services declare 34 direct dependencies; the inventory produces 107 records — 34 direct, 54 transitive, 19 build-time tools. A gate that looks only at direct dependencies sees 34 of the declared 88 runtime components.
- Production cost is written independent of environment: 12 manifest files, 7,674 bytes read, 107 records, a 13,511-byte inventory, 56 unique components.
- Of the 102 running components, 88 are visible in the inventory, 14 are invisible: 6 vendored into source, 4 embedded in the binary, 4 fetched at runtime. Coverage is 86%.
- The gate that reads manifest files holds zero undeclared components and can be removed as a gate; the gate that walks the output tree holds 14, holding all of them only by itself, at the cost of wrongly holding 3 of its 17 findings.
- The answer drops from 4 repository openings per advisory to 1 query: 36 repository openings against a single build plus 9 queries for 9 advisories. The inventory answers 6 advisories fully, 3 incompletely; one of the incomplete ones says “1” instead of “0.”
Next Step
The inventory now exists, its coverage is counted and queryable. But there is no link at all between this 13,511-byte file and the output it describes. The inventory is a separate file: another inventory could be placed next to it, a record inside it could be changed, an entirely different build artifact could be paired with the same inventory. None of the 107 records says that the bytes it describes are actually those bytes.
The same gap exists at the chain’s other links too: between the source commit and the build artifact, between the build artifact and what gets deployed. The next lesson binds these links with a real key pair and turns two questions into numbers: at which point in the chain verification happens determines how many forged outputs get through, and how many subjects hold that key?
To keep your progress and take notes, Log in
My notes
Log in to take notes.