---
title: 'Software Bill of Materials'
source: 'https://academia.sh/en/courses/ci-cd/software-bill-of-materials'
course: 'Continuous Integration and Delivery'
language: en
updated: '2026-09-13T05:08:19+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

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

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

```js
// 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: internal-library-measurement@1.0\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.

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

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