@blamejs/exceptd-skills 0.19.32 → 0.19.34

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 (127) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +8 -8
  4. package/data/_indexes/activity-feed.json +2 -2
  5. package/data/_indexes/catalog-summaries.json +7 -7
  6. package/data/_indexes/chains.json +60118 -0
  7. package/data/attack-techniques.json +267 -7
  8. package/data/cve-catalog.json +9991 -3
  9. package/data/cwe-catalog.json +109 -2
  10. package/data/framework-control-gaps.json +578 -3
  11. package/data/zeroday-lessons.json +8330 -1
  12. package/lib/auto-discovery.js +56 -286
  13. package/lib/canonical-eq.js +7 -40
  14. package/lib/citation-resolve.js +22 -70
  15. package/lib/collectors/ai-api.js +20 -54
  16. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  17. package/lib/collectors/citation-hygiene.js +72 -210
  18. package/lib/collectors/containers.js +41 -130
  19. package/lib/collectors/cred-stores.js +31 -115
  20. package/lib/collectors/crypto-codebase.js +55 -138
  21. package/lib/collectors/crypto.js +24 -54
  22. package/lib/collectors/hardening.js +20 -78
  23. package/lib/collectors/kernel.js +16 -46
  24. package/lib/collectors/library-author.js +57 -206
  25. package/lib/collectors/mcp.js +24 -70
  26. package/lib/collectors/runtime.js +24 -86
  27. package/lib/collectors/sbom.js +34 -106
  28. package/lib/collectors/scan-excludes.js +31 -138
  29. package/lib/collectors/secrets.js +62 -178
  30. package/lib/cross-ref-api.js +39 -123
  31. package/lib/currency-severity.js +8 -27
  32. package/lib/cve-batch.js +13 -21
  33. package/lib/cve-cli.js +13 -20
  34. package/lib/cve-curation.js +72 -239
  35. package/lib/cve-regression-watcher.js +29 -152
  36. package/lib/cvss.js +13 -54
  37. package/lib/doctor-bucketing.js +3 -19
  38. package/lib/exit-codes.js +10 -42
  39. package/lib/flag-suggest.js +7 -25
  40. package/lib/framework-gap.js +35 -114
  41. package/lib/gap-detectors.js +37 -159
  42. package/lib/id-validation.js +9 -30
  43. package/lib/job-queue.js +13 -36
  44. package/lib/lint-skills.js +64 -232
  45. package/lib/playbook-runner.js +693 -2095
  46. package/lib/prefetch.js +100 -376
  47. package/lib/refresh-external.js +199 -627
  48. package/lib/refresh-network.js +75 -307
  49. package/lib/rfc-cli.js +23 -68
  50. package/lib/scoring.js +77 -145
  51. package/lib/sign.js +43 -229
  52. package/lib/source-advisories.js +43 -194
  53. package/lib/source-ghsa.js +37 -120
  54. package/lib/source-osv.js +94 -266
  55. package/lib/ttp-mapper.js +14 -24
  56. package/lib/upstream-check-cli.js +10 -28
  57. package/lib/upstream-check.js +19 -44
  58. package/lib/validate-catalog-meta.js +17 -61
  59. package/lib/validate-cve-catalog.js +43 -119
  60. package/lib/validate-indexes.js +25 -76
  61. package/lib/validate-package.js +16 -62
  62. package/lib/validate-playbooks.js +69 -275
  63. package/lib/validate-vendor.js +16 -49
  64. package/lib/verify.js +56 -286
  65. package/lib/version-pins.js +5 -34
  66. package/lib/worker-pool.js +11 -30
  67. package/lib/xml-tokenizer.js +47 -152
  68. package/manifest.json +53 -53
  69. package/orchestrator/dispatcher.js +17 -68
  70. package/orchestrator/event-bus.js +11 -74
  71. package/orchestrator/index.js +138 -412
  72. package/orchestrator/pipeline.js +28 -85
  73. package/orchestrator/scanner.js +34 -138
  74. package/orchestrator/scheduler.js +20 -84
  75. package/package.json +2 -2
  76. package/sbom.cdx.json +253 -253
  77. package/scripts/audit-catalog-gaps.js +9 -62
  78. package/scripts/audit-cross-skill.js +5 -31
  79. package/scripts/audit-perf.js +6 -16
  80. package/scripts/backfill-theater-test.js +7 -64
  81. package/scripts/bootstrap.js +12 -44
  82. package/scripts/build-indexes.js +40 -154
  83. package/scripts/builders/activity-feed.js +4 -14
  84. package/scripts/builders/catalog-summaries.js +3 -10
  85. package/scripts/builders/currency.js +7 -20
  86. package/scripts/builders/cwe-chains.js +7 -30
  87. package/scripts/builders/did-ladders.js +6 -13
  88. package/scripts/builders/frequency.js +5 -19
  89. package/scripts/builders/jurisdiction-clocks.js +6 -25
  90. package/scripts/builders/recipes.js +6 -14
  91. package/scripts/builders/section-offsets.js +13 -51
  92. package/scripts/builders/stale-content.js +7 -28
  93. package/scripts/builders/summary-cards.js +8 -29
  94. package/scripts/builders/theater-fingerprints.js +12 -27
  95. package/scripts/builders/token-budget.js +4 -31
  96. package/scripts/check-agents-md-collectors.js +11 -54
  97. package/scripts/check-catalog-gap-budget.js +15 -32
  98. package/scripts/check-changelog-extract.js +18 -48
  99. package/scripts/check-codebase-patterns-currency.js +6 -22
  100. package/scripts/check-codebase-patterns.js +50 -143
  101. package/scripts/check-epss-consistency.js +9 -64
  102. package/scripts/check-framework-gap-coverage.js +13 -31
  103. package/scripts/check-manifest-snapshot.js +13 -73
  104. package/scripts/check-sbom-currency.js +44 -142
  105. package/scripts/check-test-count.js +15 -52
  106. package/scripts/check-test-coverage.js +66 -197
  107. package/scripts/check-test-subjects.js +21 -62
  108. package/scripts/check-ttp-references.js +14 -38
  109. package/scripts/check-ttp-upstream.js +8 -40
  110. package/scripts/check-version-bump.js +9 -61
  111. package/scripts/check-version-tags.js +20 -121
  112. package/scripts/predeploy.js +38 -184
  113. package/scripts/refresh-manifest-snapshot.js +16 -38
  114. package/scripts/refresh-mitre-atlas.js +3 -8
  115. package/scripts/refresh-mitre-attack.js +1 -8
  116. package/scripts/refresh-mitre-d3fend.js +3 -9
  117. package/scripts/refresh-mitre-ics-attack.js +3 -8
  118. package/scripts/refresh-reverse-refs.js +27 -94
  119. package/scripts/refresh-rfc-index.js +2 -10
  120. package/scripts/refresh-sbom.js +31 -161
  121. package/scripts/refresh-upstream-catalogs.js +40 -137
  122. package/scripts/release.js +69 -232
  123. package/scripts/run-e2e-scenarios.js +24 -71
  124. package/scripts/sync-manifest-metadata.js +10 -34
  125. package/scripts/sync-package-description.js +8 -17
  126. package/scripts/validate-vendor-online.js +13 -44
  127. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,35 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
- * scripts/refresh-reverse-refs.js — rebuild reverse references in
4
- * data/{atlas-ttps,cwe-catalog,d3fend-catalog,rfc-references}.json from
5
- * the manifest.json forward direction.
6
- *
7
- * Background. Each skill in manifest.json declares forward references via
8
- * atlas_refs / cwe_refs / d3fend_refs / rfc_refs. The four catalogs above
9
- * carry a denormalised reverse field per entry (`exceptd_skills` for
10
- * atlas-ttps, `skills_referencing` for the other three) listing every
11
- * skill that points at that entry. The reverse field drifts whenever a
12
- * skill adds or removes a forward ref without the catalog being updated
13
- * in lockstep — this script rebuilds the reverse direction from the
14
- * forward source of truth so the two never disagree.
15
- *
16
- * Behaviour. For each catalog file:
17
- * 1. Walk every skill's relevant forward-ref array in manifest.json.
18
- * 2. For every catalog entry, list every skill that references it.
19
- * 3. Sort the resulting skill list and write it back into the per-entry
20
- * reverse field. All other fields are preserved untouched.
21
- *
22
- * The script is idempotent: a second run produces no further changes.
23
- *
24
- * The script does NOT touch playbooks_referencing — that field carries
25
- * playbook ids (data/playbooks/*.json), not skill names; it has its own
26
- * source of truth and is out of scope for this refresh.
27
- *
28
- * Run: node scripts/refresh-reverse-refs.js
29
- * npm run refresh-reverse-refs
30
- *
31
- * Exit code: 0 always (script is unconditionally write-mode). Use
32
- * tests/reverse-ref-drift.test.js as the read-only drift detector.
3
+ * Rebuilds the denormalised reverse-reference fields in the data catalogs from the
4
+ * forward direction, which is the source of truth: a skill's atlas_refs / cwe_refs
5
+ * / d3fend_refs / rfc_refs, and a CVE's forward refs. Every other field is
6
+ * preserved and a second run changes nothing. `playbooks_referencing` is out of
7
+ * scope — it carries playbook ids, not skill names.
8
+ * Write-mode only, exit 0 always; the read-only drift detector is
9
+ * tests/reverse-ref-drift.test.js.
33
10
  */
34
11
 
35
12
  'use strict';
@@ -43,17 +20,12 @@ const CVE_CATALOG_PATH = path.join(REPO_ROOT, 'data', 'cve-catalog.json');
43
20
  const DATA_DIR = path.join(REPO_ROOT, 'data');
44
21
 
45
22
  /* Per-catalog config:
46
- * file relative path under data/
47
- * forwardField source-collection[].* array name
48
- * reverseField per-entry reverse field name in the catalog
49
- * source 'manifest.skills' (default) — walk every skill's forward ref
50
- * 'cve.entries' — walk every CVE's forward ref (added in
51
- * v0.12.32); contributes CVE-IDs (skipping `_draft: true`
52
- * entries so the reverse direction tracks operator-queryable
53
- * truth, not in-progress curation state)
54
- * entryKey field on the source object used as the reverse-list value
55
- * ('name' for skills; '<self-id>' for CVE entries via the
56
- * map key, so the helper substitutes the iterating key)
23
+ * file relative path under data/
24
+ * forwardField array (or dict, with forwardFieldShape) on the source object
25
+ * reverseField per-entry field this script overwrites in the catalog
26
+ * source 'manifest.skills' walks every skill; 'cve.entries' every
27
+ * non-draft CVE
28
+ * entryKey field used as the reverse-list value; null means the key itself
57
29
  */
58
30
  const CATALOGS = [
59
31
  {
@@ -84,12 +56,7 @@ const CATALOGS = [
84
56
  source: 'manifest.skills',
85
57
  entryKey: 'name',
86
58
  },
87
- // v0.12.32: CVE → CWE reverse direction. CWE entries declare
88
- // `evidence_cves` as the operator-facing "which CVEs land here" index;
89
- // previously hand-maintained and drifted whenever a new CVE landed
90
- // without the matching CWE's evidence_cves being updated. Now mirrors
91
- // `cve.cwe_refs` → `cwe.evidence_cves` automatically. Drafts excluded
92
- // (they're invisible to default consumers anyway).
59
+ // `evidence_cves` mirrors cve.cwe_refs: which CVEs land on this CWE.
93
60
  {
94
61
  file: 'cwe-catalog.json',
95
62
  forwardField: 'cwe_refs',
@@ -97,14 +64,8 @@ const CATALOGS = [
97
64
  source: 'cve.entries',
98
65
  entryKey: null, // value is the iterating CVE id
99
66
  },
100
- // v0.12.40: CVE → framework-gap reverse direction. Resolved 137
101
- // directional mismatches between cve.framework_control_gaps (dict-keyed
102
- // by gap-id) and gap.evidence_cves (array of CVE ids). The forward
103
- // shape on the CVE side is an OBJECT not an array — keys are the gap
104
- // ids, values are per-CVE narrative. The reverse direction (which CVEs
105
- // cite this gap) is a simple set of CVE ids on the gap entry. The
106
- // helper handles the dict-keyed forward field via the
107
- // `forwardFieldShape: 'object-keys'` flag.
67
+ // The forward side here is an OBJECT, not an array: keys are gap ids, values
68
+ // the per-CVE narrative, hence forwardFieldShape. The reverse is a set of ids.
108
69
  {
109
70
  file: 'framework-control-gaps.json',
110
71
  forwardField: 'framework_control_gaps',
@@ -113,12 +74,7 @@ const CATALOGS = [
113
74
  source: 'cve.entries',
114
75
  entryKey: null, // value is the iterating CVE id
115
76
  },
116
- // v0.13.0: ATLAS / ATT&CK back-edge — every ATLAS TTP and ATT&CK
117
- // technique gets a `cve_refs` array carrying the CVE ids that cite it.
118
- // Pre-v0.13 these catalogs had only forward refs (CVE → TTP); operators
119
- // reading an ATLAS / ATT&CK entry could not see which CVEs cite it
120
- // without grepping the whole catalog. These back-edges make the
121
- // relationship symmetric: a citation is readable from either end.
77
+ // `cve_refs` back-edges: an ATLAS or ATT&CK entry shows which CVEs cite it.
122
78
  {
123
79
  file: 'atlas-ttps.json',
124
80
  forwardField: 'atlas_refs',
@@ -152,16 +108,9 @@ function buildReverseIndex(skills, forwardField) {
152
108
  return index;
153
109
  }
154
110
 
155
- // v0.12.32: build a reverse index keyed by catalog ID from the CVE
156
- // catalog's forward refs. Each CVE entry has cwe_refs / attack_refs
157
- // arrays; the reverse side is the CVE ID, indexed by the catalog entry.
158
- // Draft entries are skipped — drafts are invisible to default consumers
159
- // via cross-ref-api, so the reverse direction should track operator-
160
- // queryable truth, not in-progress curation state.
161
- //
162
- // v0.12.40: forwardFieldShape parameter handles the
163
- // CVE.framework_control_gaps case where the forward field is a dict
164
- // (gap-id → narrative) rather than an array.
111
+ // catalogEntryId -> Set<cveId>. Drafts are skipped: they are invisible to default
112
+ // consumers through cross-ref-api. Pass forwardFieldShape 'object-keys' when the
113
+ // forward field is a dict.
165
114
  function buildCveReverseIndex(cveCatalog, forwardField, forwardFieldShape) {
166
115
  const index = new Map();
167
116
  for (const [cveId, entry] of Object.entries(cveCatalog)) {
@@ -221,14 +170,9 @@ function rebuildCatalog(cfg, manifest, cveCatalog) {
221
170
  }
222
171
  }
223
172
 
224
- // Surface forward refs that point at catalog entries that don't exist.
225
- // Informational only — orphans never change the exit code (this script is
226
- // unconditionally write-mode, exit 0 always; see the file header). The
227
- // failing gates for the two signals this script can surface live
228
- // elsewhere: orphan forward refs are hard-errored by lib/lint-skills.js
229
- // ref-resolution ("<id> not present in data/<catalog>"), and reverse-field
230
- // drift is hard-failed by tests/reverse-ref-drift.test.js. Do not wire
231
- // this script itself as a "reverse refs clean?" check — it always passes.
173
+ // Orphans — forward refs naming a catalog entry that does not exist — are
174
+ // reported, never fatal. Never wire this script up as a "reverse refs clean?"
175
+ // check: the failing gates are lib/lint-skills.js and reverse-ref-drift.test.js.
232
176
  for (const id of index.keys()) {
233
177
  if (!seenIds.has(id)) orphans.push(id);
234
178
  }
@@ -249,15 +193,8 @@ function rebuildCatalog(cfg, manifest, cveCatalog) {
249
193
  };
250
194
  }
251
195
 
252
- /**
253
- * v0.13.0: playbook `fed_by` reverse direction. Every playbook declares
254
- * `feeds_into[]` listing playbook ids it chains TO; `fed_by[]` is the
255
- * symmetric "which playbooks chain into me." Pre-v0.13 operators reading
256
- * a playbook couldn't see what fed it without grepping every other
257
- * playbook's feeds_into. The reverse field lives on the playbook's
258
- * top-level under `_meta.fed_by` (paired with the existing _meta.feeds_into
259
- * for shape symmetry).
260
- */
196
+ // `_meta.fed_by` is the symmetric counterpart of `_meta.feeds_into`: which
197
+ // playbooks chain INTO this one. Writes each playbook file in place.
261
198
  function rebuildPlaybookReverse() {
262
199
  const playbooksDir = path.join(DATA_DIR, 'playbooks');
263
200
  if (!fs.existsSync(playbooksDir)) return { file: 'playbooks/*.json', source: 'playbook.feeds_into', reverseField: 'fed_by', changed: 0, added: 0, removed: 0, unchanged: 0, orphans: [] };
@@ -271,12 +208,8 @@ function rebuildPlaybookReverse() {
271
208
  if (!data || !data._meta || !data._meta.id) continue;
272
209
  playbookEntries.push({ file: f, filePath, data });
273
210
  }
274
- // Build reverse index: target-id -> Set<source-id>
275
- // feeds_into entries are objects with `playbook_id` + `condition`.
276
- // The fed_by reverse is a simple array of source playbook ids (the
277
- // condition is per-edge; preserved on feeds_into, not duplicated on
278
- // fed_by — operators read fed_by to find candidates, then look at the
279
- // source playbook's feeds_into for the gating condition).
211
+ // targetId -> Set<sourceId>. A feeds_into entry carries `playbook_id` +
212
+ // `condition`; fed_by keeps only the ids, so the condition is not duplicated.
280
213
  const index = new Map();
281
214
  for (const { data } of playbookEntries) {
282
215
  const meta = data._meta;
@@ -1,16 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  /**
4
- * scripts/refresh-rfc-index.js
5
- *
6
- * Thin per-type wrapper for the RFC refresher. Logic lives in
7
- * scripts/refresh-upstream-catalogs.js#refreshRfc. Use this entry when
8
- * you want to refresh only the RFC catalog without touching ATT&CK /
9
- * ATLAS / D3FEND.
10
- *
11
- * node scripts/refresh-rfc-index.js [--dry-run]
12
- *
13
- * Wired as `npm run refresh-rfc-index`.
4
+ * Refreshes only the RFC catalog, leaving ATT&CK / ATLAS / D3FEND untouched.
5
+ * Logic lives in scripts/refresh-upstream-catalogs.js#refreshRfc.
14
6
  */
15
7
  const { refreshRfc } = require("./refresh-upstream-catalogs.js");
16
8
  const dry = process.argv.includes("--dry-run");
@@ -1,50 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
- * scripts/refresh-sbom.js — regenerate sbom.cdx.json.
4
- *
5
- * The exceptd repository is zero-runtime-dependency by design (see
6
- * package.json `dependencies: {}`). The SBOM therefore documents the
7
- * project as an application component with an empty `components` array
8
- * and pulls live surface counts from manifest.json + data/*.json so the
9
- * artifact never silently drifts when the surface changes.
10
- *
11
- * Generated fields:
12
- * - bomFormat / specVersion CycloneDX 1.6
13
- * - serialNumber urn:uuid v4 derived from a stable
14
- * hash of (project name + version +
15
- * bundle digest), so identical content
16
- * reproduces the identical UUID and a
17
- * rerun that changed nothing is a no-op
18
- * rather than a spurious diff.
19
- * - metadata.timestamp the release date this version's
20
- * CHANGELOG heading declares — NOT the
21
- * wall-clock moment of generation, which
22
- * would make the artifact irreproducible
23
- * - metadata.tools this script itself, version pulled
24
- * from package.json at refresh time
25
- * - metadata.component application entry for exceptd-skills,
26
- * including a hashes[] bundle digest
27
- * that operators can recompute from
28
- * the per-file component list (see
29
- * `bundleDigest` below for the exact
30
- * canonical-input rule)
31
- * - metadata.properties catalog count, skill count, dataflow
32
- * inputs, and the per-skill Ed25519
33
- * integrity claim (lib/sign.js)
34
- * - components vendored libraries + a `type: file`
35
- * component per shipped file in the
36
- * package.json `files` allowlist, each
37
- * carrying its SHA-256 hash. Lets
38
- * CycloneDX-aware vuln scanners verify
39
- * individual files against the bundle
40
- * without re-deriving the canonical
41
- * list themselves.
42
- * - dependencies [] — nothing to depend on
43
- *
44
- * Run: node scripts/refresh-sbom.js
45
- * npm run refresh-sbom
46
- *
47
- * No external dependencies. Node 24 stdlib only.
3
+ * Regenerates sbom.cdx.json, a CycloneDX 1.6 bundle. Every generated field is
4
+ * deterministic — identical content reproduces an identical SBOM, which is what
5
+ * makes the currency gate's regenerate-and-compare mean anything.
48
6
  */
49
7
 
50
8
  'use strict';
@@ -71,9 +29,7 @@ function listDataCatalogs(dir) {
71
29
  .sort();
72
30
  }
73
31
 
74
- /* RFC 4122 v4 UUID derived deterministically from a seed string so a
75
- * given (project, version, timestamp) triple maps to a stable UUID
76
- * across observers. Uses crypto.randomUUID() fallback if no seed. */
32
+ /* RFC 4122 v4 UUID derived from a seed, so one seed maps to one UUID. */
77
33
  function uuidV4FromSeed(seed) {
78
34
  const hash = crypto.createHash('sha256').update(seed).digest();
79
35
  const b = Buffer.from(hash.subarray(0, 16));
@@ -103,19 +59,7 @@ function loadVendorProvenance() {
103
59
  }
104
60
  }
105
61
 
106
- /* Recursively expand a `package.json.files` allowlist entry into the
107
- * concrete file list that npm pack would ship. The allowlist accepts
108
- * either a file path or a directory path (with trailing slash convention
109
- * inside this repo); directories expand to every regular file beneath
110
- * them. Returned paths are POSIX-style relative to REPO_ROOT so the
111
- * SHA-256 input is stable across operating systems.
112
- *
113
- * Mirrors npm's pack-time inclusion rules at the level of fidelity this
114
- * SBOM needs (a deeper match — .npmignore, package-lock fields, npm-CLI
115
- * defaults — is intentionally out of scope: any divergence here surfaces
116
- * as a SHA mismatch on the predeploy verify-shipped-tarball gate, which
117
- * is the authoritative consumer-side check).
118
- */
62
+ /* Every regular file beneath absDir, in name order for a stable inventory. */
119
63
  function walkFiles(absDir) {
120
64
  const out = [];
121
65
  const entries = fs.readdirSync(absDir, { withFileTypes: true });
@@ -130,42 +74,18 @@ function walkFiles(absDir) {
130
74
  return out;
131
75
  }
132
76
 
133
- /* Files that cannot have a stable SHA inside the SBOM they belong to.
134
- * `sbom.cdx.json` is the obvious self-reference: hashing it would always
135
- * be stale the moment the SBOM gets written back. The bundle digest in
136
- * metadata.component.hashes[] covers everything ELSE that ships and is
137
- * the operator's verification anchor for the bundle as a whole. */
77
+ /* No stable SHA inside the SBOM that contains it: the hash is stale on write. */
138
78
  const SELF_EXCLUDED = new Set(['sbom.cdx.json']);
139
79
 
140
- /* Path prefixes whose contents are derivable / cache-class artifacts.
141
- * `data/_indexes/` is the pre-computed index cache that ships in the
142
- * tarball but is regenerated by `npm run build-indexes`. The test suite
143
- * deliberately mutates these files (build-incremental.test.js,
144
- * indexes-v070.test.js), so per-file SHA verification would race against
145
- * any test run that touches the cache between refresh-sbom and the
146
- * verification gate. The bundle digest at metadata.component.hashes[] is
147
- * computed from a SBOM-generation-time snapshot of all OTHER files; the
148
- * cache is excluded from the per-file inventory entirely. Predeploy's
149
- * `Pre-computed indexes freshness` gate is the authoritative consumer-
150
- * side check for the cache. */
80
+ /* Derivable cache artifacts, kept out of the per-file inventory: the test suite
81
+ * mutates data/_indexes/, so a pinned per-file hash would race any run that
82
+ * touches the cache. Predeploy's index-freshness gate covers them instead. */
151
83
  const DERIVABLE_PREFIXES = ['data/_indexes/'];
152
84
 
153
- /* Files npm puts in every tarball regardless of the `files` allowlist.
154
- * package.json is never listed in `files` (npm adds it unconditionally), so
155
- * expanding the allowlist alone left the one file that declares the bin
156
- * entrypoint, the engines floor and the dependency set outside the hashed
157
- * inventory — the SBOM described 247 of the 268 files an operator receives and
158
- * said nothing about the gap. It is not derivable and not self-referential, so
159
- * nothing but the omission kept it out. README.md and LICENSE get the same
160
- * unconditional treatment from npm but are already named in `files`, so a
161
- * union covers the general rule without double-counting them. sources/README.md
162
- * is here for the same reason: npm collects README files it finds, `files` never
163
- * names that directory, and it shipped unhashed.
164
- *
165
- * This list is maintained by hand, which is only safe because something else
166
- * checks it: lib/validate-package.js compares the real `npm pack` output against
167
- * this SBOM, so a file npm decides to ship that is missing here fails a gate
168
- * instead of shipping silently. */
85
+ /* Files npm ships whatever the `files` allowlist says, unioned in so they are
86
+ * hashed too. The list is hand-maintained, which is only safe because
87
+ * lib/validate-package.js compares the real `npm pack` output against this SBOM
88
+ * — a shipped file missing here fails a gate. */
169
89
  const ALWAYS_SHIPPED = ['package.json', 'sources/README.md'];
170
90
 
171
91
  function isDerivable(rel) {
@@ -184,9 +104,9 @@ function expandAllowlist(allowlist) {
184
104
  abs.push(full);
185
105
  }
186
106
  }
187
- // dedupe + sort by relative POSIX path for deterministic output;
188
- // strip self-referential entries (see SELF_EXCLUDED) and derivable cache
189
- // entries (see DERIVABLE_PREFIXES).
107
+ // Deduped and sorted by POSIX-relative path, so the SHA-256 input is the same
108
+ // on every operating system. npm's pack rules are matched only as far as this
109
+ // SBOM needs; verify-shipped-tarball is the authoritative check.
190
110
  const rel = Array.from(new Set(abs.map((a) => toPosixRel(a))))
191
111
  .filter((r) => !SELF_EXCLUDED.has(r))
192
112
  .filter((r) => !isDerivable(r))
@@ -195,22 +115,10 @@ function expandAllowlist(allowlist) {
195
115
  }
196
116
 
197
117
  /* metadata.timestamp — the release date this version's CHANGELOG heading
198
- * declares, as an ISO-8601 instant.
199
- *
200
- * The field has to be deterministic: the SBOM-currency gate compares the
201
- * committed artifact against a freshly generated one, so a wall-clock value
202
- * would differ on every run and the comparison could never mean anything.
203
- * The previous approach kept determinism by folding the bundle hash into a
204
- * date offset, but the offset was a full uint32 of SECONDS — a 136-year
205
- * spread — so the field routinely landed a century or more in the future
206
- * (the shipped value read 2147-11-20). Deterministic, and untrue: consumers
207
- * that sort or age-check SBOMs read that as a real creation date.
208
- *
209
- * The CHANGELOG heading is both deterministic and true. Its format is already
210
- * enforced by scripts/check-changelog-extract.js, which the release flow runs
211
- * before this script, so the date is guaranteed present by the time a release
212
- * regenerates the SBOM. Refusing is deliberate when it is absent — inventing a
213
- * placeholder is what produced the 2147 date in the first place. */
118
+ * declares, as an ISO-8601 instant. It must be deterministic for the currency
119
+ * gate's regenerate-and-compare, and true because consumers age-check on it, so
120
+ * a missing heading throws rather than synthesizing a value.
121
+ * scripts/check-changelog-extract.js enforces the heading format. */
214
122
  function releaseTimestamp(version) {
215
123
  const changelogPath = path.join(REPO_ROOT, 'CHANGELOG.md');
216
124
  const text = fs.readFileSync(changelogPath, 'utf8');
@@ -240,22 +148,9 @@ function sha256File(absPath) {
240
148
  .digest('hex');
241
149
  }
242
150
 
243
- // v0.13.12 — emit SHA3-512 alongside SHA-256 for every file: component.
244
- // CycloneDX 1.6 supports multiple hash entries per component. Rationale
245
- // mirrors the existing key-fingerprint emission in lib/verify.js:
246
- //
247
- // - SHA-256 stays as the universal-tool contract (Anchore / Trivy /
248
- // Dependency-Track / GitHub Dependency Graph all parse it).
249
- // - SHA3-512 is the SHA-3 family (Keccak / sponge), different
250
- // mathematical foundation. Hedges against future SHA-2 weaknesses
251
- // and aligns with the project's PQ posture (ML-KEM / ML-DSA both
252
- // internally hash with SHA-3).
253
- //
254
- // check-sbom-currency.js verifies BOTH when present and refuses if a
255
- // SHA3-512 entry is recorded but its content drifts from the live
256
- // bytes — so a downgrade attack that drops SHA3-512 from the recorded
257
- // SBOM (leaving only SHA-256) is observable as a missing-hash error,
258
- // not a silent acceptance.
151
+ // SHA3-512 sits alongside SHA-256 on every file component: a different
152
+ // construction to fall back on. check-sbom-currency.js verifies both, so
153
+ // dropping the SHA3-512 entry reads as a missing-hash error, not acceptance.
259
154
  function sha3_512File(absPath) {
260
155
  return crypto
261
156
  .createHash('sha3-512')
@@ -281,12 +176,9 @@ function fileComponents(allowlist) {
281
176
  return out;
282
177
  }
283
178
 
284
- /* Bundle digest = SHA-256 over a deterministic newline-delimited
285
- * "<sha256>\t<relpath>\n" stream of every shipped file, sorted by
286
- * relpath. The same input shape an operator would assemble from the
287
- * components[] list (`type: file` entries) lets them recompute and
288
- * compare without trusting the SBOM's stored value blindly.
289
- */
179
+ /* SHA-256 over a "<sha256>\t<relpath>\n" stream of every shipped file, sorted by
180
+ * relpath — the same input an operator assembles from the `type: file` entries
181
+ * in components[], so the stored digest can be recomputed rather than trusted. */
290
182
  function bundleDigest(fileComps) {
291
183
  const sorted = [...fileComps].sort((a, b) =>
292
184
  a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
@@ -337,25 +229,12 @@ function buildSbom() {
337
229
  const fileComps = fileComponents(Array.isArray(pkg.files) ? pkg.files : []);
338
230
  const bundleSha = bundleDigest(fileComps);
339
231
 
340
- // Sort the union of vendor + file components by bom-ref for
341
- // deterministic regeneration.
232
+ // Sorted by bom-ref so regeneration is byte-identical.
342
233
  const allComponents = [...vendoredComponents, ...fileComps].sort((a, b) =>
343
234
  a['bom-ref'] < b['bom-ref'] ? -1 : a['bom-ref'] > b['bom-ref'] ? 1 : 0,
344
235
  );
345
236
 
346
- // v0.13.0: derive both serialNumber and metadata.timestamp from the
347
- // bundle content hash, not wall-clock. Pre-v0.13 every refresh produced
348
- // a new UUID + timestamp even when the bundle content was byte-identical,
349
- // so the SBOM-currency gate produced noisy diffs and the predeploy
350
- // comparison could not rely on stable byte-identity. The comment at the
351
- // top of this file says "stable across observers" — the implementation
352
- // contradicted it. Now: identical content → identical SBOM.
353
- //
354
- // The synthetic timestamp uses the bundle SHA folded into a date string
355
- // anchored at the Unix epoch + a deterministic offset; this is NOT a
356
- // real audit timestamp (the `metadata.lifecycles[]` block carries the
357
- // intended-lifecycle phase for that). Operators wanting the wall-clock
358
- // time of a refresh should read the file's mtime or refresh-report.json.
237
+ // Seeded from the bundle content, not the clock: a no-op refresh is a no-op.
359
238
  const seed = `${pkg.name}@${pkg.version}@${bundleSha}`;
360
239
  const serialNumber = 'urn:uuid:' + uuidV4FromSeed(seed);
361
240
  const timestamp = releaseTimestamp(pkg.version);
@@ -379,9 +258,6 @@ function buildSbom() {
379
258
  },
380
259
  ],
381
260
  component: {
382
- // Switch from project: scheme to pkg:npm scheme post-v0.9.0 — the
383
- // package is now published on npm with provenance attestation, so
384
- // the CycloneDX bom-ref should reflect the canonical PURL.
385
261
  'bom-ref': `pkg:npm/${pkg.name}@${pkg.version}`,
386
262
  type: 'application',
387
263
  name: pkg.name,
@@ -389,10 +265,7 @@ function buildSbom() {
389
265
  description: pkg.description,
390
266
  licenses: [{ license: { id: 'Apache-2.0' } }],
391
267
  purl: `pkg:npm/${pkg.name.replace(/@/g, '%40')}@${pkg.version}`,
392
- // Bundle digest over every shipped file (see bundleDigest above
393
- // for the canonical-input rule). Operators can recompute this
394
- // from the per-file components[] list and compare without
395
- // re-deriving package.json.files themselves.
268
+ // Recomputable from components[]; bundleDigest above states the input rule.
396
269
  hashes: [{ alg: 'SHA-256', content: bundleSha }],
397
270
  externalReferences: [
398
271
  { type: 'distribution', url: `https://www.npmjs.com/package/${pkg.name}/v/${pkg.version}` },
@@ -416,11 +289,8 @@ function buildSbom() {
416
289
  name: 'exceptd:integrity:method',
417
290
  value: 'Ed25519 per-skill (lib/sign.js)',
418
291
  },
419
- // An operator verifying the bundle holds the SBOM, not this script.
420
- // Without these two properties the only record of what the inventory
421
- // deliberately omits was a source comment they never see, so a partial
422
- // inventory was indistinguishable from a complete one. State the
423
- // uncovered prefix and the check that covers it instead.
292
+ // An operator verifying the bundle holds the SBOM, not this script: these
293
+ // two properties say the inventory is partial by design, and what covers it.
424
294
  {
425
295
  name: 'exceptd:integrity:uncovered:prefix',
426
296
  value: DERIVABLE_PREFIXES.join(','),