@blamejs/exceptd-skills 0.19.33 → 0.19.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/exceptd.js +895 -2828
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +170 -76
  8. package/lib/collectors/cicd-pipeline-compromise.js +113 -136
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +198 -211
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +130 -118
  20. package/lib/collectors/scan-excludes.js +33 -139
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -155
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +39 -113
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +88 -236
  37. package/lib/playbook-runner.js +759 -2107
  38. package/lib/prefetch.js +101 -376
  39. package/lib/refresh-external.js +199 -633
  40. package/lib/refresh-network.js +78 -311
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +85 -146
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +28 -27
  48. package/lib/upstream-check-cli.js +36 -29
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +52 -121
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +78 -286
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -413
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +242 -242
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +29 -28
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +21 -31
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +26 -57
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +63 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +62 -81
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +83 -198
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +7 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +7 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +7 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +63 -148
  114. package/scripts/release.js +69 -234
  115. package/scripts/run-e2e-scenarios.js +26 -73
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -141
@@ -1,33 +1,9 @@
1
1
  "use strict";
2
2
  /**
3
- * scripts/check-manifest-snapshot.js
4
- *
5
- * CI gate. Captures the current public skill surface (skill name +
6
- * version + triggers + data_deps + atlas_refs + attack_refs +
7
- * framework_gaps) from manifest.json and compares it to the committed
8
- * manifest-snapshot.json baseline.
9
- *
10
- * The skill surface is the public contract this repo offers downstream
11
- * AI assistants: skill names that downstream prompts may reference,
12
- * trigger keywords that downstream skill-matchers index on, and the
13
- * data files that skills rely on. Removing a skill or trigger keyword
14
- * silently breaks every consumer that pinned that surface.
15
- *
16
- * Exit codes:
17
- * 0 — no breaking changes (additive changes printed but not failing)
18
- * 1 — breaking changes detected
19
- * 2 — script-level error (missing baseline, IO failure, etc.)
20
- *
21
- * Operators see this gate in SECURITY.md / CONTRIBUTING.md as a CI
22
- * promise: "removed skills, removed triggers, or removed data deps fail
23
- * the build before they reach main."
24
- *
25
- * Usage:
26
- * node scripts/check-manifest-snapshot.js
27
- *
28
- * Regenerate the baseline after an intentional removal:
29
- * node scripts/refresh-manifest-snapshot.js
30
- * git add manifest-snapshot.json && git commit
3
+ * CI gate: diffs the public skill surface in manifest.json — name, version,
4
+ * triggers, data_deps, ref arrays — against the committed manifest-snapshot.json.
5
+ * Exit 0 when nothing broke (additive changes print and still pass), 1 on a
6
+ * breaking change, 2 on a script-level error such as a missing baseline.
31
7
  */
32
8
 
33
9
  const fs = require("fs");
@@ -39,9 +15,7 @@ const MANIFEST_PATH = path.join(ROOT, "manifest.json");
39
15
  const SNAPSHOT_PATH = path.join(ROOT, "manifest-snapshot.json");
40
16
 
41
17
  function captureSurface(manifest) {
42
- // Public surface = the set of facts downstream consumers may have
43
- // pinned against. NOT included: sha256 / signature / signed_at —
44
- // those change every commit and are not a public contract.
18
+ // Only what a consumer pins against: sha256, signature and signed_at change every commit.
45
19
  const skills = (manifest.skills || []).map(s => ({
46
20
  name: s.name,
47
21
  version: s.version || null,
@@ -63,37 +37,66 @@ function captureSurface(manifest) {
63
37
  };
64
38
  }
65
39
 
40
+ // Absent and corrupt are different states and must not collapse into one.
41
+ //
42
+ // ABSENT (key not in the object) is the stale baseline this gate exists to
43
+ // report on: a snapshot committed before a surface field existed carries no key
44
+ // for it, and an absent field is an empty surface, not a crash. Reading it
45
+ // unguarded threw a TypeError that the CLI's outer catch turned into exit 2 with
46
+ // a stack trace instead of an additive-change report.
47
+ //
48
+ // PRESENT-BUT-NOT-AN-ARRAY (a string, an object, null) is corruption. Coercing
49
+ // it to [] would report every live entry as additive and exit 0 — a gate that
50
+ // passes without checking anything. It raises instead, and the outer catch turns
51
+ // that into exit 2 naming the skill and the field.
52
+ function asArray(value, skillName, field) {
53
+ if (value === undefined) return [];
54
+ if (Array.isArray(value)) return value;
55
+ throw new Error(
56
+ `${skillName}: ${field} is ${value === null ? "null" : typeof value}, not an array. ` +
57
+ "The baseline or manifest is corrupt, not merely stale — regenerate the baseline " +
58
+ "with `node scripts/refresh-manifest-snapshot.js` and re-check the manifest."
59
+ );
60
+ }
61
+
62
+ // captureSurface() always writes `skills`, so unlike the per-field case above
63
+ // there is no legitimate historical baseline without it: absent and non-array
64
+ // are both corruption here, and treating either as [] would report every skill
65
+ // as added and exit 0.
66
+ function skillList(surface, which) {
67
+ const skills = surface && surface.skills;
68
+ if (Array.isArray(skills)) return skills;
69
+ throw new Error(
70
+ `${which}.skills is ${skills === null ? "null" : typeof skills}, not an array. ` +
71
+ "Regenerate the baseline with `node scripts/refresh-manifest-snapshot.js`."
72
+ );
73
+ }
74
+
66
75
  function diff(baseline, current) {
67
76
  const breaking = [];
68
77
  const additive = [];
69
78
 
70
- const bSkills = new Map(baseline.skills.map(s => [s.name, s]));
71
- const cSkills = new Map(current.skills.map(s => [s.name, s]));
79
+ const bSkills = new Map(skillList(baseline, "baseline").map(s => [s.name, s]));
80
+ const cSkills = new Map(skillList(current, "current").map(s => [s.name, s]));
72
81
 
73
- // Removed skills are breaking.
74
82
  for (const name of bSkills.keys()) {
75
83
  if (!cSkills.has(name)) {
76
84
  breaking.push(`removed skill: ${name}`);
77
85
  }
78
86
  }
79
87
 
80
- // Added skills are additive.
81
88
  for (const name of cSkills.keys()) {
82
89
  if (!bSkills.has(name)) {
83
90
  additive.push(`added skill: ${name}`);
84
91
  }
85
92
  }
86
93
 
87
- // For each skill present in both, diff the pinned facts.
88
94
  for (const [name, b] of bSkills) {
89
95
  const c = cSkills.get(name);
90
96
  if (!c) continue;
91
97
 
92
- // version downgrades are breaking; bumps are additive.
93
98
  if (b.version && c.version && b.version !== c.version) {
94
- // Use a simple lexicographic compare — semver isn't enforced
95
- // upstream and the manifest version field is informational. The
96
- // operator should bump, not unbump.
99
+ // Lexicographic, not semver: nothing upstream enforces a semver shape.
97
100
  if (c.version < b.version) {
98
101
  breaking.push(`${name}: version downgraded ${b.version} -> ${c.version}`);
99
102
  } else {
@@ -102,45 +105,44 @@ function diff(baseline, current) {
102
105
  }
103
106
 
104
107
  // Removed trigger keywords break downstream skill matchers.
105
- const removedTriggers = b.triggers.filter(t => !c.triggers.includes(t));
108
+ const bTriggers = asArray(b.triggers, name, "baseline triggers");
109
+ const cTriggers = asArray(c.triggers, name, "triggers");
110
+ const removedTriggers = bTriggers.filter(t => !cTriggers.includes(t));
106
111
  if (removedTriggers.length > 0) {
107
112
  breaking.push(`${name}: removed trigger keywords: ${removedTriggers.join(", ")}`);
108
113
  }
109
- const addedTriggers = c.triggers.filter(t => !b.triggers.includes(t));
114
+ const addedTriggers = cTriggers.filter(t => !bTriggers.includes(t));
110
115
  if (addedTriggers.length > 0) {
111
116
  additive.push(`${name}: added trigger keywords: ${addedTriggers.join(", ")}`);
112
117
  }
113
118
 
114
- // Removed data deps break the skill at load time. Additions are fine.
115
- const removedDeps = b.data_deps.filter(d => !c.data_deps.includes(d));
119
+ // Removed data deps break the skill at load time.
120
+ const bDeps = asArray(b.data_deps, name, "baseline data_deps");
121
+ const cDeps = asArray(c.data_deps, name, "data_deps");
122
+ const removedDeps = bDeps.filter(d => !cDeps.includes(d));
116
123
  if (removedDeps.length > 0) {
117
124
  breaking.push(`${name}: removed data deps: ${removedDeps.join(", ")}`);
118
125
  }
119
- const addedDeps = c.data_deps.filter(d => !b.data_deps.includes(d));
126
+ const addedDeps = cDeps.filter(d => !bDeps.includes(d));
120
127
  if (addedDeps.length > 0) {
121
128
  additive.push(`${name}: added data deps: ${addedDeps.join(", ")}`);
122
129
  }
123
130
 
124
- // Removed ATLAS/ATT&CK/framework refs are surface narrowing.
125
- // Per AGENTS.md rule #4 (no orphaned controls) and #12 (external
126
- // data version pinning), narrowing the cited surface is a
127
- // deliberate decision worth surfacing in CI. Treat as breaking;
128
- // the operator can refresh the baseline alongside the intent.
131
+ // Narrowing the cited surface is deliberate (AGENTS.md #4, #12), so removal is breaking.
129
132
  for (const field of ["atlas_refs", "attack_refs", "framework_gaps", "rfc_refs", "cwe_refs", "d3fend_refs", "dlp_refs"]) {
130
- const removed = b[field].filter(r => !c[field].includes(r));
133
+ const bRefs = asArray(b[field], name, `baseline ${field}`);
134
+ const cRefs = asArray(c[field], name, field);
135
+ const removed = bRefs.filter(r => !cRefs.includes(r));
131
136
  if (removed.length > 0) {
132
137
  breaking.push(`${name}: removed ${field}: ${removed.join(", ")}`);
133
138
  }
134
- const added = c[field].filter(r => !b[field].includes(r));
139
+ const added = cRefs.filter(r => !bRefs.includes(r));
135
140
  if (added.length > 0) {
136
141
  additive.push(`${name}: added ${field}: ${added.join(", ")}`);
137
142
  }
138
143
  }
139
144
  }
140
145
 
141
- // ATLAS pinned-version change is breaking per AGENTS.md rule #12
142
- // (never silently inherit version changes). The operator must update
143
- // the baseline alongside the audit of TTP ID changes.
144
146
  if (baseline.atlas_version && current.atlas_version &&
145
147
  baseline.atlas_version !== current.atlas_version) {
146
148
  breaking.push(
@@ -171,33 +173,16 @@ function formatDiff(result) {
171
173
  }
172
174
 
173
175
  /**
174
- * Verify the on-disk snapshot still hashes to the value recorded in its
175
- * .sha256 sidecar. The sidecar is the only thing that catches a hand-edit
176
- * of manifest-snapshot.json that bypassed refresh-manifest-snapshot.js
177
- * (the surface diff alone is defeated by editing manifest.json AND the
178
- * baseline in lockstep — e.g. to hide a removed skill/trigger). The
179
- * sidecar pins the baseline's exact bytes so that lockstep edit no longer
180
- * produces a matching pair.
181
- *
182
- * Pairing invariant: refresh-manifest-snapshot.js always writes BOTH the
183
- * snapshot and its sidecar, and package.json `files` ships them together.
184
- * So a snapshot present WITHOUT its sidecar is not a benign legacy state
185
- * for any tree the current gate runs against (CI/predeploy run on the
186
- * committed tree, where both are present; the shipped tarball ships both).
187
- * An absent sidecar next to a present snapshot is the integrity-evasion
188
- * shape — treat it as a failure, symmetric with the present-but-mismatch
189
- * failure. Returns { ok, error } so callers can surface a hard exit.
190
- *
191
- * @param {string} root repo root containing the snapshot + sidecar
192
- * @returns {{ok: boolean, error: (string|null)}}
176
+ * Verify the snapshot still hashes to its .sha256 sidecar. The surface diff
177
+ * alone is defeated by editing manifest.json and the baseline in lockstep; the
178
+ * sidecar pins the baseline's exact bytes so that pair no longer matches.
193
179
  */
194
180
  function checkSnapshotIntegrity(root) {
195
181
  const snapshotPath = path.join(root, "manifest-snapshot.json");
196
182
  const shaPath = path.join(root, "manifest-snapshot.sha256");
197
183
 
198
184
  if (!fs.existsSync(snapshotPath)) {
199
- // No snapshot at all — the caller's baseline-read handles this as a
200
- // distinct error. Nothing for the integrity check to anchor against.
185
+ // Nothing to anchor against; the caller's baseline read reports this.
201
186
  return { ok: true, error: null };
202
187
  }
203
188
 
@@ -253,11 +238,7 @@ if (require.main === module) {
253
238
  process.exit(2);
254
239
  }
255
240
 
256
- // Integrity gate: the snapshot must hash to its recorded sidecar value,
257
- // and the sidecar must be present whenever the snapshot is. A missing
258
- // sidecar next to a present snapshot is itself a failure — it is the
259
- // only thing that would have caught a lockstep hand-edit of manifest.json
260
- // + the baseline, so allowing it to be deleted re-opens that bypass.
241
+ // Before the diff: a tampered baseline must not be diffed as though trustworthy.
261
242
  const integrity = checkSnapshotIntegrity(ROOT);
262
243
  if (!integrity.ok) {
263
244
  console.error("[check-manifest-snapshot] " + integrity.error);
@@ -1,21 +1,8 @@
1
1
  "use strict";
2
2
  /**
3
- * scripts/check-sbom-currency.js
4
- *
5
- * Predeploy gate: assert sbom.cdx.json is current against the live skill +
6
- * catalog counts. Drift means an SBOM regen was forgotten — operators
7
- * downloading the tarball would see counts that disagree with the actual
8
- * manifest.json and data/*.json contents.
9
- *
10
- * Anchors to ROOT in this order of preference:
11
- * 1. `--root <dir>` on argv (testability — staged tempdir layouts).
12
- * 2. `EXCEPTD_ROOT` environment variable.
13
- * 3. `path.join(__dirname, '..')` — the running script's parent dir.
14
- *
15
- * Exit 0 on current SBOM, 1 on any drift (catalog count, skill count, or
16
- * CycloneDX-format mismatch).
17
- *
18
- * No external dependencies. Node 24 stdlib only.
3
+ * Predeploy gate: sbom.cdx.json must be current against the live skill and
4
+ * catalog counts, the recorded file hashes and the shipped-file set — drift
5
+ * means the tarball ships an SBOM that disagrees with its own contents.
19
6
  */
20
7
 
21
8
  const fs = require("fs");
@@ -31,16 +18,10 @@ function resolveRoot(argv) {
31
18
  return path.join(__dirname, "..");
32
19
  }
33
20
 
34
- // Entry count for a data/*.json catalog: keys minus the _meta sentinel. The
35
- // catalogs are objects keyed by entry id (CVE-…, CWE-…, T…, AML.T…, D3-…,
36
- // RFC-…) with a single _meta block, so the live entry total is the key count
37
- // excluding _meta.
21
+ // Live entry total for a data/*.json catalog, or null when the file is absent so
22
+ // a `--root` pointed at a partial tree skips that token instead of crashing.
38
23
  function catalogEntryCount(dataDir, file) {
39
24
  const p = path.join(dataDir, file);
40
- // A --root pointed at a partial tree (no such catalog file) skips that
41
- // token's check rather than crashing — catalog PRESENCE is asserted by
42
- // the cardinality check above and the per-component hash check below,
43
- // not by the description parser.
44
25
  if (!fs.existsSync(p)) return null;
45
26
  const j = JSON.parse(fs.readFileSync(p, "utf8"));
46
27
  if (Array.isArray(j)) return j.length;
@@ -50,18 +31,13 @@ function catalogEntryCount(dataDir, file) {
50
31
  return 0;
51
32
  }
52
33
 
53
- // Files/prefixes that refresh-sbom's expandAllowlist excludes from the shipped
54
- // file: component inventory. Kept in sync with scripts/refresh-sbom.js
55
- // (SELF_EXCLUDED + DERIVABLE_PREFIXES): the SBOM never hashes itself, and the
56
- // pre-computed index cache under data/_indexes/ is regenerated/test-mutated, so
57
- // it carries no per-file component. The completeness check below must apply the
58
- // SAME exclusions or it would demand a component for a file the generator never
59
- // emits one for.
34
+ // Mirrors scripts/refresh-sbom.js SELF_EXCLUDED + DERIVABLE_PREFIXES: the
35
+ // completeness check must apply the SAME exclusions or it demands a component
36
+ // the generator never emits.
60
37
  const SBOM_SELF_EXCLUDED = new Set(["sbom.cdx.json"]);
61
38
  const SBOM_DERIVABLE_PREFIXES = ["data/_indexes/"];
62
39
  // Mirrors refresh-sbom's ALWAYS_SHIPPED: npm adds package.json to every tarball
63
- // without it appearing in `files`, so the completeness check has to expect a
64
- // component for it the same way the generator now emits one.
40
+ // without it appearing in `files`, so a component is expected for it.
65
41
  const SBOM_ALWAYS_SHIPPED = ["package.json", "sources/README.md"];
66
42
 
67
43
  function sbomIsDerivable(rel) {
@@ -70,9 +46,7 @@ function sbomIsDerivable(rel) {
70
46
  );
71
47
  }
72
48
 
73
- // Recursively list every regular file under absDir, returned as absolute paths.
74
- // Mirrors refresh-sbom's walkFiles (which is root-agnostic — it walks whatever
75
- // absolute dir it is given).
49
+ // Mirrors refresh-sbom's walkFiles, walking whatever absolute dir it is handed.
76
50
  function walkFilesAbs(absDir) {
77
51
  const out = [];
78
52
  let entries;
@@ -86,15 +60,9 @@ function walkFilesAbs(absDir) {
86
60
  return out;
87
61
  }
88
62
 
89
- // Root-aware allowlist expansion: the shipped-file set that must each have a
90
- // file: component, computed against the TARGET tree (`root`), not the running
91
- // script's source repo. refresh-sbom's exported expandAllowlist is bound to its
92
- // own REPO_ROOT (it joins/relativizes against __dirname/..), so under a `--root`
93
- // target the completeness check would otherwise validate the SOURCE repo's file
94
- // list against the TARGET SBOM — the wrong tree. Replicating the expansion here
95
- // (same SELF_EXCLUDED + DERIVABLE_PREFIXES exclusions, same dedupe+sort) keeps
96
- // the gate honest under `--root` without reaching across into the generator's
97
- // module-level root.
63
+ // The shipped-file set that must each have a file: component, computed against
64
+ // the TARGET tree. refresh-sbom's expandAllowlist is bound to its own REPO_ROOT,
65
+ // so it would validate the source repo's file list against the target SBOM.
98
66
  function expandAllowlistAt(allowlist, root) {
99
67
  const abs = [];
100
68
  for (const entry of [...allowlist, ...SBOM_ALWAYS_SHIPPED]) {
@@ -114,11 +82,8 @@ function expandAllowlistAt(allowlist, root) {
114
82
  return rel;
115
83
  }
116
84
 
117
- // The description string embeds per-catalog ENTRY counts as free text, e.g.
118
- // "11 catalogs (439 CVEs / 177 CWEs / 805 ATT&CK + ICS / 170 ATLAS /
119
- // 468 D3FEND / 8888 RFCs)". Each token maps to one data/*.json catalog whose
120
- // live entry count must match. `label` is the regex-escaped text that follows
121
- // the number in the description.
85
+ // The description embeds per-catalog ENTRY counts as free text ("… 177 CWEs /
86
+ // 805 ATT&CK + ICS …"). `label` is the regex-escaped text after the number.
122
87
  const DESCRIPTION_ENTRY_TOKENS = [
123
88
  { file: "cve-catalog.json", label: "CVEs" },
124
89
  { file: "cwe-catalog.json", label: "CWEs" },
@@ -161,14 +126,8 @@ function checkSbomCurrency(root) {
161
126
  errors.push("SBOM is not CycloneDX 1.6");
162
127
  }
163
128
 
164
- // The SBOM ships per-catalog entry counts and a skill count embedded as free
165
- // text in metadata.component.description (propagated verbatim from
166
- // package.json). The numeric properties above only cover catalog/skill
167
- // CARDINALITY (file count + skill count), so a catalog's entry total can
168
- // drift past the count baked into the description while the dedicated SBOM
169
- // gate still passes. Parse each token out of the description and assert it
170
- // against the live entry count so a stale published-SBOM description fails
171
- // the gate.
129
+ // The numeric properties above cover only CARDINALITY, so an entry total can
130
+ // drift in the description while those still agree.
172
131
  const description =
173
132
  (sbom.metadata && sbom.metadata.component && sbom.metadata.component.description) || "";
174
133
  for (const { file, label } of DESCRIPTION_ENTRY_TOKENS) {
@@ -188,7 +147,6 @@ function checkSbomCurrency(root) {
188
147
  );
189
148
  }
190
149
  }
191
- // The skill count is embedded in the same description string ("N skills").
192
150
  const skillMatch = description.match(/(\d+)\s+skills\b/);
193
151
  if (!skillMatch) {
194
152
  errors.push(
@@ -200,15 +158,9 @@ function checkSbomCurrency(root) {
200
158
  );
201
159
  }
202
160
 
203
- // The "N catalogs" and "N jurisdictions" free-text counts in the same
204
- // description string were never validated — only the per-catalog entry tokens
205
- // and the skill count were. Pin them to the live values so a stale
206
- // description (e.g. after an auto-refresh changed a count) fails the gate.
207
161
  const catalogMatch = description.match(/(\d+)\s+catalogs?\b/i);
208
162
  if (!catalogMatch) {
209
- // Symmetric with the entry/skill tokens: absence fails CLOSED. A reworded
210
- // description (or an auto-refresh that dropped the token) must not silently
211
- // skip the count check — that is the asymmetric-absent fail-open class.
163
+ // Absence fails CLOSED: a reworded description must not skip the check.
212
164
  errors.push(
213
165
  "SBOM description is missing the catalog-count token (N catalogs) — regenerate via `npm run refresh-sbom`"
214
166
  );
@@ -220,21 +172,16 @@ function checkSbomCurrency(root) {
220
172
  const liveJurisdictions = (() => {
221
173
  try {
222
174
  const gf = JSON.parse(fs.readFileSync(path.join(dataDir, "global-frameworks.json"), "utf8"));
223
- // Non-underscore top-level keys — the canonical jurisdiction count the
224
- // README badge and catalog-summaries use.
175
+ // Non-underscore top-level keys — the count the README badge reports.
225
176
  return Object.keys(gf).filter((k) => !k.startsWith("_")).length;
226
177
  } catch {
227
178
  return null;
228
179
  }
229
180
  })();
230
181
  const jurisdictionMatch = description.match(/(\d+)\s+jurisdictions?\b/i);
231
- // Only enforce the jurisdiction token when the live source exists — a partial
232
- // `--root` tree without global-frameworks.json (liveJurisdictions === null)
233
- // skips the check rather than failing, matching catalogEntryCount's null-skip.
234
- // When the source IS present, absence of the token fails CLOSED (the
235
- // description token is the SBOM's only jurisdiction-count assertion — there is
236
- // no structured jurisdiction property — so a dropped token would otherwise
237
- // leave the count entirely unvalidated).
182
+ // A partial `--root` tree without global-frameworks.json skips the check. Where
183
+ // the source IS present the token is the SBOM's only jurisdiction assertion —
184
+ // no structured property carries it — so a dropped token fails CLOSED.
238
185
  if (liveJurisdictions !== null) {
239
186
  if (!jurisdictionMatch) {
240
187
  errors.push(
@@ -247,15 +194,9 @@ function checkSbomCurrency(root) {
247
194
  }
248
195
  }
249
196
 
250
- // Component-level cross-check (defense-in-depth). In normal operation
251
- // refresh-sbom emits NO per-skill "skill:" components — skill drift is caught
252
- // by the file:skills/<name>/skill.md and file:manifest.json content hashes in
253
- // the file: component pass below (a bumped or renamed skill changes those
254
- // bytes). This branch is therefore not exercised by a clean SBOM, but it is
255
- // retained as a tamper guard: a forged or buggy SBOM that injected a skill
256
- // component with a stale version (or a skill name no longer in the manifest)
257
- // is still caught here. Vendor components are validated against
258
- // vendor/blamejs/_PROVENANCE.json.
197
+ // A clean SBOM carries no "skill:" components — skill drift shows up in the
198
+ // file: hashes. This branch is the tamper guard for a forged SBOM that injects
199
+ // one with a stale version.
259
200
  const components = Array.isArray(sbom.components) ? sbom.components : [];
260
201
  const skillByName = new Map(
261
202
  (manifest.skills || []).map((s) => [s.name, s])
@@ -297,27 +238,17 @@ function checkSbomCurrency(root) {
297
238
  }
298
239
  }
299
240
 
300
- // v0.13.9: per-file SHA-256 integrity check. For every CycloneDX
301
- // component whose bom-ref begins with "file:", confirm the recorded
302
- // SHA-256 hash matches the live bytes on disk. Catches the class of
303
- // release-ordering bug where sbom.cdx.json was regenerated BEFORE the
304
- // final sign-all pass — the recorded manifest.json hash drifted from
305
- // the signed-and-committed bytes, but the count-based check above
306
- // could not see it. Codex P2 flag on PR #48 surfaced one instance;
307
- // this gate makes it unreachable.
241
+ // Every "file:" component's recorded hash must match the live bytes. This is
242
+ // what catches an SBOM regenerated BEFORE the final sign-all, where the
243
+ // recorded manifest.json hash drifts from the signed-and-committed bytes.
308
244
  let fileComponentsChecked = 0;
309
245
  const rootResolved = path.resolve(root);
310
246
  for (const comp of components) {
311
247
  const bomRef = typeof comp["bom-ref"] === "string" ? comp["bom-ref"] : "";
312
248
  if (!bomRef.startsWith("file:")) continue;
313
249
  const relPath = bomRef.slice("file:".length);
314
- // Codex P2 on PR #49: refuse bom-ref entries that escape the repo
315
- // root. The earlier implementation trusted `relPath` verbatim, so a
316
- // tampered or carelessly-edited sbom.cdx.json with `file:../outside`
317
- // would read + hash a path OUTSIDE the checkout — the gate would
318
- // either report "exists, hash matches" (silently weakening the
319
- // integrity guarantee) or "does not exist" without ever flagging the
320
- // attempted escape. Refuse early.
250
+ // A `file:../outside` bom-ref would otherwise hash a path outside the
251
+ // checkout and report "exists, hash matches" — never the escape.
321
252
  if (relPath.includes("..") || path.isAbsolute(relPath)) {
322
253
  errors.push(
323
254
  `SBOM file component "${relPath}" rejected: path must be repo-relative without ".." segments (path-traversal guard)`
@@ -325,9 +256,8 @@ function checkSbomCurrency(root) {
325
256
  continue;
326
257
  }
327
258
  const absPath = path.resolve(rootResolved, relPath);
328
- // Defense-in-depth: even if the textual check above passed, the
329
- // resolved path must still live under the root. Symlinks or future
330
- // changes to the textual filter would surface here.
259
+ // The resolved path must also land under root — a symlink, or a loosening of
260
+ // the textual filter above, surfaces here.
331
261
  const rel = path.relative(rootResolved, absPath);
332
262
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
333
263
  errors.push(
@@ -341,17 +271,8 @@ function checkSbomCurrency(root) {
341
271
  );
342
272
  continue;
343
273
  }
344
- // v0.13.12: verify SHA-256 AND SHA3-512 when present. SHA-256 is
345
- // the universal-tool contract (CycloneDX 1.6 default, Anchore /
346
- // Trivy / Dependency-Track / GitHub Dependency Graph). SHA3-512
347
- // is the SHA-3 family hedge, matching the existing key-fingerprint
348
- // pattern (lib/verify.js). Both must agree with the live bytes;
349
- // a mismatch on either fires the same drift error. A missing
350
- // SHA-256 is a hard error (the universal contract is the floor);
351
- // a missing SHA3-512 surfaces as a downgrade-attack warning so an
352
- // operator who intentionally strips the second hash from an
353
- // SBOM (post-quantum posture relaxation) sees it in the gate
354
- // output, not in the JSON downstream.
274
+ // SHA-256 is the universal-tool contract (CycloneDX 1.6 default, read by
275
+ // Anchore / Trivy / Dependency-Track); SHA3-512 is the SHA-3 family hedge.
355
276
  const sha256Entry = (comp.hashes || []).find((h) => h && h.alg === "SHA-256");
356
277
  const sha3Entry = (comp.hashes || []).find((h) => h && h.alg === "SHA3-512");
357
278
  if (!sha256Entry || typeof sha256Entry.content !== "string") {
@@ -367,12 +288,8 @@ function checkSbomCurrency(root) {
367
288
  `SBOM file component "${relPath}" SHA-256 drift: recorded ${sha256Entry.content.slice(0, 12)}…, live ${liveSha256.slice(0, 12)}… — re-sign skills (\`node $(exceptd path)/lib/sign.js sign-all\` from a contributor checkout) and then \`npm run refresh-sbom\`, in that order (sbom must regenerate AFTER the final sign).`,
368
289
  );
369
290
  }
370
- // Codex P1 on PR #52: the dual-hash contract requires SHA3-512 to be
371
- // PRESENT, not just verified when present. An attacker (or a careless
372
- // sbom-generator regression) that strips the SHA3-512 column would
373
- // silently pass the gate under an `if (sha3Entry)` guard, defeating
374
- // the downgrade defense the dual-hash design is supposed to provide.
375
- // Refuse absence as a hard error.
291
+ // Absence is a hard error, never an `if (sha3Entry)` guard: stripping the
292
+ // SHA3-512 column would otherwise pass and defeat the downgrade defense.
376
293
  if (!sha3Entry || typeof sha3Entry.content !== "string") {
377
294
  errors.push(
378
295
  `SBOM file component "${relPath}" lacks a SHA3-512 hash entry — the dual-hash contract (SHA-256 + SHA3-512) requires both algorithms on every file: component. Regenerate via \`npm run refresh-sbom\` (v0.13.12+).`
@@ -388,20 +305,12 @@ function checkSbomCurrency(root) {
388
305
  fileComponentsChecked++;
389
306
  }
390
307
 
391
- // Completeness + bundle-digest integrity. The per-file pass above verifies
392
- // every RECORDED file: component, but never checked that every SHIPPED file
393
- // (the package.json.files expansion) actually HAS a component — a
394
- // newly-shipped file would ship unhashed and silent. And the aggregate
395
- // bundle digest in metadata.component.hashes[] was never recomputed. Reuse
396
- // refresh-sbom's exact allowlist expansion + digest so the gate can't drift
397
- // from the generator.
308
+ // Completeness plus the aggregate bundle digest. The per-file pass verifies
309
+ // every RECORDED component, so without this a newly-shipped file carrying no
310
+ // component would go unhashed and silent.
398
311
  try {
399
- // bundleDigest operates purely on the file: component objects (no tree
400
- // walk), so it is root-agnostic and safe to reuse from the generator. The
401
- // allowlist expansion, by contrast, MUST run against the target `root`
402
- // (expandAllowlistAt below) — refresh-sbom's exported expandAllowlist is
403
- // pinned to its own source-repo REPO_ROOT and would validate the wrong tree
404
- // under `--root`.
312
+ // bundleDigest reads only the file: component objects, so it is
313
+ // root-agnostic; the allowlist expansion is not, hence expandAllowlistAt.
405
314
  const { bundleDigest } = require("./refresh-sbom");
406
315
  const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
407
316
  const expected = expandAllowlistAt(pkg.files || [], root);
@@ -416,16 +325,11 @@ function checkSbomCurrency(root) {
416
325
  );
417
326
  }
418
327
  }
419
- // Recompute the aggregate bundle digest from the file: components' recorded
420
- // SHA-256 hashes and compare to metadata.component.hashes[] (the per-file
421
- // pass already tied each recorded hash to live bytes).
328
+ // Recomputed from the recorded per-file SHA-256s, already tied to live bytes.
422
329
  const compHashes = (sbom.metadata && sbom.metadata.component && sbom.metadata.component.hashes) || [];
423
330
  const recorded = (compHashes.find((h) => h && h.alg === "SHA-256") || {}).content;
424
331
  if (fileComps.length) {
425
- // A missing aggregate digest is NOT benign: it is the bundle-as-a-whole
426
- // verification anchor. An absent one must fail the gate (symmetric with
427
- // the per-file SHA3-512 hard-error that defeats downgrade/strip attacks) —
428
- // previously an absent digest silently skipped the comparison entirely.
332
+ // The bundle-as-a-whole anchor: absence fails rather than skipping.
429
333
  if (!recorded) {
430
334
  errors.push(
431
335
  "SBOM metadata.component.hashes lacks a SHA-256 bundle digest — the aggregate verification anchor is missing; run `npm run refresh-sbom`"
@@ -459,9 +363,7 @@ function main() {
459
363
  if (!result.ok) {
460
364
  for (const e of result.errors) process.stderr.write(e + "\n");
461
365
  process.stderr.write("Run `npm run refresh-sbom` to regenerate sbom.cdx.json.\n");
462
- // v0.11.13 pattern: set exitCode + return so buffered stdout/stderr
463
- // writes drain before the event loop exits. process.exit() can
464
- // truncate piped output (CI log capture, JSON consumers).
366
+ // process.exitCode, not process.exit() — the exit can truncate a piped write.
465
367
  process.exitCode = 1;
466
368
  return;
467
369
  }