@blamejs/exceptd-skills 0.18.6 → 0.18.8

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 (63) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bin/exceptd.js +364 -119
  3. package/data/_indexes/_meta.json +22 -3
  4. package/data/cve-catalog.json +25 -0
  5. package/data/playbooks/framework.json +2 -2
  6. package/data/playbooks/post-quantum-migration.json +1 -1
  7. package/lib/auto-discovery.js +30 -10
  8. package/lib/collectors/ai-api.js +9 -2
  9. package/lib/collectors/cicd-pipeline-compromise.js +24 -5
  10. package/lib/collectors/cred-stores.js +17 -4
  11. package/lib/collectors/crypto.js +9 -2
  12. package/lib/collectors/hardening.js +9 -2
  13. package/lib/collectors/library-author.js +29 -5
  14. package/lib/collectors/mcp.js +9 -2
  15. package/lib/collectors/runtime.js +9 -2
  16. package/lib/collectors/sbom.js +28 -15
  17. package/lib/collectors/scan-excludes.js +25 -0
  18. package/lib/collectors/secrets.js +40 -4
  19. package/lib/cve-curation.js +84 -8
  20. package/lib/lint-skills.js +75 -3
  21. package/lib/playbook-runner.js +443 -50
  22. package/lib/prefetch.js +22 -2
  23. package/lib/refresh-external.js +32 -1
  24. package/lib/refresh-network.js +235 -26
  25. package/lib/schemas/cve-catalog.schema.json +5 -0
  26. package/lib/scoring.js +141 -21
  27. package/lib/sign.js +107 -29
  28. package/lib/source-advisories.js +23 -5
  29. package/lib/source-ghsa.js +25 -1
  30. package/lib/source-osv.js +26 -1
  31. package/lib/upstream-check.js +1 -1
  32. package/lib/validate-cve-catalog.js +30 -4
  33. package/lib/validate-indexes.js +135 -29
  34. package/lib/validate-playbooks.js +19 -7
  35. package/lib/validate-vendor.js +69 -8
  36. package/lib/verify.js +23 -6
  37. package/manifest.json +53 -53
  38. package/orchestrator/dispatcher.js +14 -3
  39. package/orchestrator/index.js +100 -20
  40. package/orchestrator/scanner.js +8 -0
  41. package/package.json +1 -1
  42. package/sbom.cdx.json +130 -130
  43. package/scripts/audit-cross-skill.js +1 -1
  44. package/scripts/bootstrap.js +1 -0
  45. package/scripts/build-indexes.js +84 -11
  46. package/scripts/check-agents-md-collectors.js +41 -13
  47. package/scripts/check-changelog-extract.js +4 -4
  48. package/scripts/check-codebase-patterns.js +19 -5
  49. package/scripts/check-manifest-snapshot.js +74 -30
  50. package/scripts/check-sbom-currency.js +25 -5
  51. package/scripts/check-test-count.js +26 -7
  52. package/scripts/check-test-coverage.js +44 -4
  53. package/scripts/check-version-tags.js +27 -8
  54. package/scripts/predeploy.js +1 -1
  55. package/scripts/refresh-manifest-snapshot.js +14 -4
  56. package/scripts/refresh-reverse-refs.js +7 -1
  57. package/scripts/refresh-sbom.js +1 -1
  58. package/scripts/release.js +3 -3
  59. package/scripts/run-e2e-scenarios.js +18 -8
  60. package/scripts/validate-vendor-online.js +28 -2
  61. package/scripts/verify-shipped-tarball.js +65 -6
  62. package/sources/validators/cve-validator.js +17 -1
  63. package/vendor/blamejs/_PROVENANCE.json +4 -2
@@ -43,10 +43,15 @@ const FRAMEWORK_GAPS_PATH = path.join(REPO_ROOT, 'data', 'framework-control-gaps
43
43
  // one of these, the entry must carry an `iocs` block per AGENTS.md Hard Rule
44
44
  // #14. Surfaced as a warning by default so drafts and pre-IoC entries don't
45
45
  // break patch-class compatibility; promoted to an error under --strict.
46
+ // The host-based entries anchor on scheme + host so a lookalike that merely
47
+ // embeds the host as a label (`exploit-db.com.attacker.example`,
48
+ // `not-exploit-db.com`, `evil.com/?ref=packetstormsecurity.com`) is no longer
49
+ // misclassified as a public-exploit source. Verified zero classification
50
+ // change across the live catalog's verification URLs.
46
51
  const PUBLIC_EXPLOIT_URL_PATTERNS = [
47
- /github\.com\/.+\/(exploits?|poc|pocs)\b/i,
48
- /\bexploit-?db\.com\b/i,
49
- /\bpacketstormsecurity\.com\b/i,
52
+ /^https?:\/\/(?:[\w-]+\.)*github\.com\/.+\/(?:exploits?|pocs?)\b/i,
53
+ /^https?:\/\/(?:[\w-]+\.)*exploit-?db\.com(?:[/:?#]|$)/i,
54
+ /^https?:\/\/(?:[\w-]+\.)*packetstormsecurity\.com(?:[/:?#]|$)/i,
50
55
  /\bmetasploit\b/i,
51
56
  /\/poc\//i,
52
57
  /-poc\b/i,
@@ -215,6 +220,17 @@ function isUsableDate(value) {
215
220
  }
216
221
  const d = new Date(value + 'T00:00:00Z');
217
222
  if (Number.isNaN(d.getTime())) return { ok: false, reason: 'unparseable' };
223
+ // Reject impossible calendar dates (Feb 30, Apr 31, Feb 29 in a non-leap
224
+ // year). `new Date('2026-02-30T00:00:00Z')` does NOT throw — it silently
225
+ // rolls over to March 2 and reports a valid getTime() — so a malformed KEV
226
+ // deadline would pass this, the only date-validity guard. Require the parsed
227
+ // Y-M-D to round-trip back to the input components.
228
+ const [yStr, mStr, dStr] = value.split('-');
229
+ if (d.getUTCFullYear() !== Number(yStr) ||
230
+ d.getUTCMonth() + 1 !== Number(mStr) ||
231
+ d.getUTCDate() !== Number(dStr)) {
232
+ return { ok: false, reason: `impossible calendar date ${value}` };
233
+ }
218
234
  const year = Number(value.slice(0, 4));
219
235
  if (year < MIN_VALID_YEAR || year > MAX_VALID_YEAR) {
220
236
  return {
@@ -262,7 +278,17 @@ function additionalChecks(key, entry, ctx) {
262
278
  },
263
279
  ];
264
280
  for (const { field, set, file } of REF_FIELDS) {
265
- if (!set) continue; // catalog absent — skip silently (defense-in-depth)
281
+ if (!set) {
282
+ // A reference catalog is part of a valid tree, so its absence is not a
283
+ // "defense-in-depth skip" — it means the ref family CANNOT be validated,
284
+ // and an orphaned id would ship unchecked. Surface it (promoted to a hard
285
+ // error under --strict, like an unresolved ref) for any entry that
286
+ // actually carries the field, rather than passing silently.
287
+ if (entry[field] && (Array.isArray(entry[field]) ? entry[field].length > 0 : typeof entry[field] === 'object' && Object.keys(entry[field]).length > 0)) {
288
+ warnings.push(`${key}: cannot validate ${field} — ${file} catalog is absent, so its ids go unchecked (an error under --strict).`);
289
+ }
290
+ continue;
291
+ }
266
292
  const refs = entry[field];
267
293
  // Most ref fields are arrays of id strings (atlas_refs, cwe_refs, ...),
268
294
  // but framework_control_gaps is a map keyed BY control id. An
@@ -36,6 +36,122 @@ function sha256(buf) {
36
36
  return crypto.createHash("sha256").update(buf).digest("hex");
37
37
  }
38
38
 
39
+ /**
40
+ * Confirm every derived index OUTPUT exists, is a real file, and parses as
41
+ * JSON. Returns an array of human-readable issue strings (empty = all good).
42
+ * Source-hash drift alone does NOT catch a deleted/truncated/corrupted output,
43
+ * so this runs in addition to the source-hash comparison.
44
+ */
45
+ function verifyOutputs(idxDir, outputs) {
46
+ const issues = [];
47
+ // O_NOFOLLOW + a single descriptor: the open atomically refuses a symlinked
48
+ // index path (ELOOP) and every subsequent stat/read rides the same fd, so
49
+ // there is no existsSync->lstat->readFileSync TOCTOU on the path. Where
50
+ // O_NOFOLLOW is unavailable the flag degrades to 0 (plain open).
51
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
52
+ for (const file of outputs) {
53
+ const abs = path.join(idxDir, file);
54
+ let fd;
55
+ try {
56
+ fd = fs.openSync(abs, fs.constants.O_RDONLY | O_NOFOLLOW);
57
+ } catch (e) {
58
+ if (e.code === "ELOOP") {
59
+ issues.push(`derived index file is a symbolic link: data/_indexes/${file}`);
60
+ } else if (e.code === "ENOENT") {
61
+ issues.push(`derived index file missing: data/_indexes/${file} — run npm run build-indexes`);
62
+ } else {
63
+ issues.push(`derived index file unreadable: data/_indexes/${file} (${e.message})`);
64
+ }
65
+ continue;
66
+ }
67
+ try {
68
+ // readFileSync(fd) loops read() to EOF — a single readSync may return
69
+ // fewer than st.size bytes (short read on a network/FUSE-backed fd),
70
+ // leaving the tail NUL-filled and truncating the JSON. Reading via the
71
+ // open fd keeps the open→fstat ordering TOCTOU-free.
72
+ JSON.parse(fs.readFileSync(fd, "utf8"));
73
+ } catch (e) {
74
+ issues.push(`derived index file does not parse: data/_indexes/${file} (${e.message})`);
75
+ } finally {
76
+ fs.closeSync(fd);
77
+ }
78
+ }
79
+ return issues;
80
+ }
81
+
82
+ /**
83
+ * Compare a live source set against the recorded source_hashes table and return
84
+ * { drift, missing } message arrays. Pure (no I/O of its own beyond the injected
85
+ * absFn + fs reads of the named sources) so it is unit-testable for the
86
+ * fail-closed edge cases:
87
+ * - a source that vanished between discovery and hashing (TOCTOU) is REPORTED
88
+ * missing, never an unhandled ENOENT crash;
89
+ * - a non-string recorded hash (corrupted _meta.json) is REPORTED as drift,
90
+ * never a TypeError from .slice() of a non-string.
91
+ *
92
+ * @param {Set<string>|string[]} liveSources source paths discovered live
93
+ * @param {Record<string,string>} recorded the _meta.json source_hashes table
94
+ * @param {(p:string)=>string} absFn resolves a source path to absolute
95
+ * @returns {{ drift: string[], missing: string[] }}
96
+ */
97
+ function checkSourceHashes(liveSources, recorded, absFn) {
98
+ const drift = [];
99
+ const missing = [];
100
+ const recordedKeys = new Set(Object.keys(recorded || {}));
101
+ for (const p of liveSources) {
102
+ if (!recordedKeys.has(p)) {
103
+ missing.push(`new source not in index: ${p}`);
104
+ continue;
105
+ }
106
+ const abs = absFn(p);
107
+ // F16 — also check listed-but-symlinked sources. A source can disappear or
108
+ // be swapped between discovery and hashing (TOCTOU); resolve its type and
109
+ // read it defensively so a vanished or newly-symlinked file is REPORTED as
110
+ // missing, never an unhandled ENOENT that crashes the whole validation.
111
+ // Open through a single O_NOFOLLOW descriptor and hash the bytes from THAT
112
+ // descriptor — no path stat-then-read, so there is no check-then-use window.
113
+ // O_NOFOLLOW refuses a symlinked leaf at open (ELOOP) on POSIX; an absent
114
+ // file (ENOENT) is reported missing instead of crashing; the fstat type
115
+ // check refuses a dir/junction/fifo on every platform.
116
+ let live;
117
+ {
118
+ let fd;
119
+ try { fd = fs.openSync(abs, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); }
120
+ catch (e) {
121
+ if (e.code === 'ENOENT') missing.push(`source file disappeared between discovery and hashing: ${p}`);
122
+ else if (e.code === 'ELOOP') missing.push(`source ${p} is a symbolic link — refusing to follow`);
123
+ else missing.push(`source ${p} could not be opened: ${e.code || e.message}`);
124
+ continue;
125
+ }
126
+ try {
127
+ if (!fs.fstatSync(fd).isFile()) {
128
+ missing.push(`source ${p} is not a regular file (symlink / dir / fifo) — refusing`);
129
+ continue;
130
+ }
131
+ live = sha256(fs.readFileSync(fd));
132
+ } catch (e) {
133
+ missing.push(`source ${p} could not be read: ${e.code || e.message}`);
134
+ continue;
135
+ } finally { fs.closeSync(fd); }
136
+ }
137
+ const recordedHash = recorded[p];
138
+ if (typeof recordedHash !== 'string') {
139
+ // A null/non-string recorded hash means a corrupted _meta.json
140
+ // source_hashes map — report it as drift rather than crashing on
141
+ // .slice() of a non-string (fail-closed, not fail-open).
142
+ drift.push(`hash drift: ${p} (recorded entry is not a string: ${JSON.stringify(recordedHash)}, live ${live.slice(0, 12)}…)`);
143
+ } else if (live !== recordedHash) {
144
+ drift.push(`hash drift: ${p} (recorded ${recordedHash.slice(0, 12)}…, live ${live.slice(0, 12)}…)`);
145
+ }
146
+ }
147
+ for (const p of recordedKeys) {
148
+ if (!(liveSources instanceof Set ? liveSources.has(p) : liveSources.includes(p))) {
149
+ missing.push(`stale source in index (file removed): ${p}`);
150
+ }
151
+ }
152
+ return { drift, missing };
153
+ }
154
+
39
155
  function main() {
40
156
  if (!fs.existsSync(META)) {
41
157
  console.error("[validate-indexes] data/_indexes/_meta.json missing — run `npm run build-indexes`.");
@@ -92,39 +208,29 @@ function main() {
92
208
  }
93
209
  for (const s of manifest.skills) liveSources.add(s.path);
94
210
 
95
- const drift = [];
96
- const missing = [];
97
- const recordedKeys = new Set(Object.keys(recorded));
211
+ const { drift, missing } = checkSourceHashes(liveSources, recorded, ABS);
98
212
 
99
- for (const p of liveSources) {
100
- if (!recordedKeys.has(p)) {
101
- missing.push(`new source not in index: ${p}`);
102
- continue;
103
- }
104
- const abs = ABS(p);
105
- // F16 — also check listed-but-symlinked sources. lstatSync on a missing
106
- // file throws; mirror the existsSync semantics by guarding it.
107
- if (fs.existsSync(abs)) {
108
- const st = fs.lstatSync(abs);
109
- if (st.isSymbolicLink()) {
110
- missing.push(`source ${p} is a symbolic link — refusing to follow`);
111
- continue;
112
- }
113
- }
114
- const live = sha256(fs.readFileSync(abs));
115
- if (live !== recorded[p]) {
116
- drift.push(`hash drift: ${p} (recorded ${recorded[p].slice(0, 12)}…, live ${live.slice(0, 12)}…)`);
117
- }
118
- }
119
- for (const p of recordedKeys) {
120
- if (!liveSources.has(p)) {
121
- missing.push(`stale source in index (file removed): ${p}`);
122
- }
213
+ // Verify the derived index OUTPUTS themselves exist and parse. Source-hash
214
+ // drift alone does not catch a deleted/truncated/corrupted OUTPUT: a clean
215
+ // source tree with a missing index file would otherwise pass as "current".
216
+ // _meta.outputs is the canonical list build-indexes records; an absent list
217
+ // FAILS (rather than silently skipping the check) so an old _meta can't pass.
218
+ // Absent `outputs` list (an older build that predates this field) is itself a
219
+ // staleness condition — fold it into the unified STALE report rather than
220
+ // early-returning, so a concurrent source-hash drift still surfaces too.
221
+ const outputs = Array.isArray(meta.outputs) ? meta.outputs : null;
222
+ if (!outputs || outputs.length === 0) {
223
+ missing.push(
224
+ "data/_indexes/_meta.json has no `outputs` list — rebuild with " +
225
+ "`npm run build-indexes` so derived-index integrity can be confirmed"
226
+ );
227
+ } else {
228
+ for (const issue of verifyOutputs(IDX_DIR, outputs)) missing.push(issue);
123
229
  }
124
230
 
125
231
  const issues = [...drift, ...missing];
126
232
  if (issues.length === 0) {
127
- console.log(`[validate-indexes] indexes current — ${recordedKeys.size} sources hashed at ${meta.generated_at}.`);
233
+ console.log(`[validate-indexes] indexes current — ${Object.keys(recorded).length} sources hashed at ${meta.generated_at}.`);
128
234
  return;
129
235
  }
130
236
 
@@ -136,4 +242,4 @@ function main() {
136
242
 
137
243
  if (require.main === module) main();
138
244
 
139
- module.exports = { main };
245
+ module.exports = { main, verifyOutputs, checkSourceHashes };
@@ -248,7 +248,11 @@ function loadContext() {
248
248
  const cve = readJson(CVE_PATH);
249
249
  const cwe = readJson(CWE_PATH);
250
250
  const d3 = readJson(D3FEND_PATH);
251
- const attack = readJsonIfExists(ATTACK_PATH);
251
+ // Required, like the other ref catalogs (cwe / d3fend). It is committed and
252
+ // always present; loading it optionally let attack_ref validation skip
253
+ // silently when it was absent — the asymmetry that allowed an unresolvable
254
+ // attack_ref to ship. Its absence must fail loud.
255
+ const attack = readJson(ATTACK_PATH);
252
256
 
253
257
  // Closed controlled-vocabulary enums sourced from the schema so the
254
258
  // hard-error enum checks in checkCrossRefs stay in lockstep with the
@@ -266,11 +270,16 @@ function loadContext() {
266
270
  .jurisdiction_obligations.items.properties.clock_starts.enum || null;
267
271
  frameworksEnum =
268
272
  schema.properties.domain.properties.frameworks_in_scope.items.enum || null;
269
- } catch {
270
- // If the schema shape changes, fall back to no closed-vocab enforcement
271
- // here (the generic validator still emits warnings).
272
- clockStartsEnum = null;
273
- frameworksEnum = null;
273
+ } catch (e) {
274
+ // The playbook schema is a committed, required file. Swallowing a read error
275
+ // here silently disabled the clock_starts / frameworks_in_scope closed-vocab
276
+ // checks, so a typo in those fields could ship. Fail loud instead.
277
+ throw new Error(`validate-playbooks: cannot read playbook schema ${SCHEMA_PATH} — ${e && e.message}. The closed-vocabulary checks must not silently disable.`);
278
+ }
279
+ // A successful parse with the wrong shape (enum path moved/renamed) would also
280
+ // leave the enums null and disable the checks. Treat that as fatal too.
281
+ if (!Array.isArray(clockStartsEnum) || !Array.isArray(frameworksEnum)) {
282
+ throw new Error(`validate-playbooks: playbook schema ${SCHEMA_PATH} did not yield the clock_starts / frameworks_in_scope enums (shape changed). Refusing to validate with the closed-vocab checks silently disabled — fix the schema path expressions in loadContext().`);
274
283
  }
275
284
 
276
285
  return {
@@ -521,7 +530,10 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
521
530
  // here at error severity.
522
531
  if (meta.air_gap_mode === true) {
523
532
  const look = phases.look || {};
524
- const netSourceRe = /(https?:\/\/|gh api|gh release|curl |wget |fetch )/;
533
+ // Case-insensitive + word-bounded so `HTTPS://`, `Curl`, and `fetch(` (no
534
+ // trailing space) still flag a network source — otherwise an artifact could
535
+ // ship under air_gap_mode with no offline alternative and run incomplete.
536
+ const netSourceRe = /(https?:\/\/|\bgh (?:api|release)\b|\bcurl\b|\bwget\b|\bfetch\b)/i;
525
537
  for (const [i, art] of (look.artifacts || []).entries()) {
526
538
  if (!art || typeof art !== 'object') continue;
527
539
  if (typeof art.source === 'string' && netSourceRe.test(art.source)) {
@@ -37,15 +37,23 @@ function main() {
37
37
  const prov = JSON.parse(fs.readFileSync(PROV, "utf8"));
38
38
  const issues = [];
39
39
 
40
- // License file.
41
- if (prov.license_file && prov.license_sha256) {
42
- const p = path.join(ROOT, "vendor", "blamejs", prov.license_file);
43
- if (!fs.existsSync(p)) {
44
- issues.push(`missing license file: ${prov.license_file}`);
40
+ // License file. A recorded license_file with NO license_sha256 is an
41
+ // unverifiable integrity claim, not a skip: stripping the hash from the
42
+ // manifest must not silently disable the LICENSE-text check (the
43
+ // absent-field-fails-open class). Only a manifest that records no
44
+ // license_file at all is exempt.
45
+ if (prov.license_file) {
46
+ if (!prov.license_sha256) {
47
+ issues.push(`license_file recorded (${prov.license_file}) without license_sha256 — integrity unverifiable`);
45
48
  } else {
46
- const live = sha256(fs.readFileSync(p));
47
- if (live !== prov.license_sha256) {
48
- issues.push(`LICENSE drift: recorded ${prov.license_sha256.slice(0, 12)}…, live ${live.slice(0, 12)}…`);
49
+ const p = path.join(ROOT, "vendor", "blamejs", prov.license_file);
50
+ if (!fs.existsSync(p)) {
51
+ issues.push(`missing license file: ${prov.license_file}`);
52
+ } else {
53
+ const live = sha256(fs.readFileSync(p));
54
+ if (live !== prov.license_sha256) {
55
+ issues.push(`LICENSE drift: recorded ${prov.license_sha256.slice(0, 12)}…, live ${live.slice(0, 12)}…`);
56
+ }
49
57
  }
50
58
  }
51
59
  }
@@ -57,10 +65,43 @@ function main() {
57
65
  issues.push(`missing vendored file: ${info.vendored_path}`);
58
66
  continue;
59
67
  }
68
+ // A files[] entry recorded without vendored_sha256 is an unverifiable
69
+ // integrity claim — surface it as a clean issue rather than crashing on
70
+ // `undefined.slice()` while formatting a drift message (the absent-field
71
+ // class, symmetric with the license path above).
72
+ if (!info.vendored_sha256) {
73
+ issues.push(`${info.vendored_path} recorded without vendored_sha256 — integrity unverifiable`);
74
+ continue;
75
+ }
60
76
  const live = sha256(fs.readFileSync(p));
61
77
  if (live !== info.vendored_sha256) {
62
78
  issues.push(`drift in ${info.vendored_path}: recorded ${info.vendored_sha256.slice(0, 12)}…, live ${live.slice(0, 12)}…`);
63
79
  }
80
+ // Offline upstream-pin cross-check. The vendored_sha256 compare above is
81
+ // self-attesting — it only proves the file matches its OWN recorded hash,
82
+ // never that the bytes match blamejs@<pin> upstream. The full upstream
83
+ // verification (scripts/validate-vendor-online.js) needs the network and
84
+ // is not a predeploy gate, so a hand-edited upstream_sha256_at_pin
85
+ // advertising a pin that never existed otherwise passes every
86
+ // automatically-run gate.
87
+ //
88
+ // For a file recorded with NO strip rules, the vendored bytes are
89
+ // byte-identical to upstream by definition, so upstream_sha256_at_pin MUST
90
+ // equal vendored_sha256. A divergence is a forged or internally
91
+ // inconsistent pin claim, and it is provable OFFLINE — no fetch required.
92
+ // This closes the upstream side of the integrity check for every
93
+ // unmodified vendored file (the common case) inside the existing gate.
94
+ const stripped = Array.isArray(info.stripped) ? info.stripped : [];
95
+ if (stripped.length === 0) {
96
+ if (!info.upstream_sha256_at_pin) {
97
+ issues.push(`${info.vendored_path} has no strip rules but records no upstream_sha256_at_pin — upstream pin claim unverifiable`);
98
+ } else if (info.upstream_sha256_at_pin !== info.vendored_sha256) {
99
+ issues.push(
100
+ `${info.vendored_path} records no strip rules, so its upstream_sha256_at_pin must equal vendored_sha256, ` +
101
+ `but upstream_sha256_at_pin is ${String(info.upstream_sha256_at_pin).slice(0, 12)}… vs vendored ${info.vendored_sha256.slice(0, 12)}… — forged or inconsistent upstream pin`
102
+ );
103
+ }
104
+ }
64
105
  // Smoke-check the vendored module loads.
65
106
  try {
66
107
  require(p);
@@ -69,6 +110,26 @@ function main() {
69
110
  }
70
111
  }
71
112
 
113
+ // On-disk inventory cross-check (BJS-04): the loop above is one-directional
114
+ // (manifest → disk). A .js file PRESENT in vendor/blamejs/ but ABSENT from
115
+ // _PROVENANCE.json would ship in the tarball and be require()'d by lib code
116
+ // while NOTHING verifies its integrity — an unregistered or smuggled-in
117
+ // vendored module. Flag every on-disk .js that the manifest does not record.
118
+ const registered = new Set(
119
+ Object.values(prov.files || {}).map((info) => path.basename(info.vendored_path))
120
+ );
121
+ const vendorDir = path.join(ROOT, "vendor", "blamejs");
122
+ try {
123
+ for (const f of fs.readdirSync(vendorDir)) {
124
+ if (!f.endsWith(".js")) continue;
125
+ if (!registered.has(f)) {
126
+ issues.push(`unregistered vendored file: vendor/blamejs/${f} is on disk but absent from _PROVENANCE.json — its integrity is not verified`);
127
+ }
128
+ }
129
+ } catch (err) {
130
+ issues.push(`cannot read vendor/blamejs/ for the inventory cross-check: ${err.message}`);
131
+ }
132
+
72
133
  if (issues.length === 0) {
73
134
  const fileCount = Object.keys(prov.files || {}).length;
74
135
  console.log(`[validate-vendor] vendor tree current — ${fileCount} file(s) validated against pin ${prov.pinned_commit?.slice(0, 12) || "?"}.`);
package/lib/verify.js CHANGED
@@ -369,8 +369,11 @@ function verifyManifestSignature(manifest) {
369
369
  // Previously the pin only fired at the CLI tail of `node lib/verify.js`,
370
370
  // letting a coordinated attacker who swapped keys/public.pem authenticate
371
371
  // against the attacker key without any divergence surfaced through the
372
- // library API. Honors KEYS_ROTATED=1 for legitimate rotations; missing
373
- // pin file remains warn-and-continue (legacy compat).
372
+ // library API. Honors KEYS_ROTATED=1 for legitimate rotations. A MISSING pin
373
+ // file is now rejected too: keys/EXPECTED_FINGERPRINT ships in the tarball and
374
+ // is committed to the repo, so its absence is not a legacy state — it is the
375
+ // signature a key-substitution attack leaves when it strips the pin to hide a
376
+ // swapped keys/public.pem.
374
377
  const liveFp = publicKeyFingerprint(publicKey);
375
378
  const pinResult = checkExpectedFingerprint(liveFp);
376
379
  if (pinResult.status === 'mismatch' && !pinResult.rotationOverride) {
@@ -382,6 +385,17 @@ function verifyManifestSignature(manifest) {
382
385
  actual: pinResult.actual,
383
386
  };
384
387
  }
388
+ if (pinResult.status === 'no-pin') {
389
+ // A missing pin fails closed unconditionally — there is nothing to override.
390
+ // KEYS_ROTATED only applies to a fingerprint MISMATCH (a new key + a new
391
+ // pin); a legitimate rotation updates the pin in place, it never removes it,
392
+ // so an absent pin is treated as tampering and restoring it is the only fix.
393
+ return {
394
+ status: 'invalid',
395
+ reason: `key-pin absent: keys/EXPECTED_FINGERPRINT is missing, so a swapped keys/public.pem cannot be detected. The pin ships in the tarball and is committed — restore it from the package or version control.`,
396
+ pin_absent: true,
397
+ };
398
+ }
385
399
  let signatureBytes;
386
400
  try {
387
401
  signatureBytes = Buffer.from(sig.signature_base64, 'base64');
@@ -451,10 +465,13 @@ function loadManifestValidated() {
451
465
  { code: 'EXCEPTD_MANIFEST_UNSIGNED' }
452
466
  );
453
467
  } else if (sigResult.status === 'no-key') {
454
- // Surfaced separately so the warning matches the missing-key path
455
- // that verifyAll() already handles — don't fail here, the verifyAll
456
- // entry point will emit a no_key result of its own.
457
- console.warn(`[verify] WARN: cannot verify manifest_signature — ${sigResult.reason}.`);
468
+ // The documented contract is "return a VERIFIED manifest or throw" — never
469
+ // an unverified one. verifyAll()/verifyOne() short-circuit on a missing key
470
+ // BEFORE reaching here, so this fires only for a direct
471
+ // loadManifestValidated() caller, which must not silently receive an
472
+ // unauthenticated manifest: an attacker who deletes keys/public.pem could
473
+ // otherwise smuggle a rewritten manifest past a library consumer.
474
+ throw new Error(`[verify] manifest_signature verification FAILED — ${sigResult.reason}. Cannot verify the manifest without keys/public.pem; refusing to return an unauthenticated manifest.`);
458
475
  }
459
476
  return manifest;
460
477
  }