@blamejs/exceptd-skills 0.12.13 → 0.12.15

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 (87) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/bin/exceptd.js +147 -9
  3. package/data/_indexes/_meta.json +45 -45
  4. package/data/_indexes/activity-feed.json +4 -4
  5. package/data/_indexes/catalog-summaries.json +29 -29
  6. package/data/_indexes/chains.json +3238 -3210
  7. package/data/_indexes/frequency.json +3 -0
  8. package/data/_indexes/jurisdiction-map.json +5 -3
  9. package/data/_indexes/section-offsets.json +712 -685
  10. package/data/_indexes/theater-fingerprints.json +1 -1
  11. package/data/_indexes/token-budget.json +355 -340
  12. package/data/atlas-ttps.json +144 -129
  13. package/data/attack-techniques.json +319 -76
  14. package/data/cve-catalog.json +515 -475
  15. package/data/cwe-catalog.json +1081 -759
  16. package/data/exploit-availability.json +63 -15
  17. package/data/framework-control-gaps.json +867 -843
  18. package/data/rfc-references.json +276 -276
  19. package/keys/EXPECTED_FINGERPRINT +1 -0
  20. package/lib/auto-discovery.js +21 -4
  21. package/lib/cross-ref-api.js +39 -6
  22. package/lib/cve-curation.js +18 -5
  23. package/lib/lint-skills.js +6 -1
  24. package/lib/playbook-runner.js +742 -78
  25. package/lib/refresh-external.js +40 -22
  26. package/lib/refresh-network.js +193 -17
  27. package/lib/scoring.js +20 -7
  28. package/lib/source-ghsa.js +219 -37
  29. package/lib/source-osv.js +381 -122
  30. package/lib/validate-catalog-meta.js +64 -9
  31. package/lib/validate-cve-catalog.js +56 -18
  32. package/lib/validate-indexes.js +88 -37
  33. package/lib/verify.js +72 -0
  34. package/manifest-snapshot.json +1 -1
  35. package/manifest-snapshot.sha256 +1 -0
  36. package/manifest.json +73 -73
  37. package/orchestrator/dispatcher.js +21 -1
  38. package/orchestrator/event-bus.js +52 -8
  39. package/orchestrator/index.js +279 -20
  40. package/orchestrator/pipeline.js +63 -2
  41. package/orchestrator/scanner.js +32 -10
  42. package/orchestrator/scheduler.js +150 -17
  43. package/package.json +3 -1
  44. package/sbom.cdx.json +7 -7
  45. package/scripts/check-manifest-snapshot.js +32 -0
  46. package/scripts/check-sbom-currency.js +65 -3
  47. package/scripts/check-test-coverage.js +142 -19
  48. package/scripts/predeploy.js +83 -39
  49. package/scripts/refresh-manifest-snapshot.js +55 -4
  50. package/scripts/validate-vendor-online.js +169 -0
  51. package/scripts/verify-shipped-tarball.js +106 -3
  52. package/skills/ai-attack-surface/skill.md +18 -10
  53. package/skills/ai-c2-detection/skill.md +7 -2
  54. package/skills/ai-risk-management/skill.md +5 -4
  55. package/skills/api-security/skill.md +3 -3
  56. package/skills/attack-surface-pentest/skill.md +5 -5
  57. package/skills/cloud-security/skill.md +1 -1
  58. package/skills/compliance-theater/skill.md +8 -8
  59. package/skills/container-runtime-security/skill.md +1 -1
  60. package/skills/dlp-gap-analysis/skill.md +5 -1
  61. package/skills/email-security-anti-phishing/skill.md +1 -1
  62. package/skills/exploit-scoring/skill.md +18 -18
  63. package/skills/framework-gap-analysis/skill.md +6 -6
  64. package/skills/global-grc/skill.md +3 -2
  65. package/skills/identity-assurance/skill.md +2 -2
  66. package/skills/incident-response-playbook/skill.md +4 -4
  67. package/skills/kernel-lpe-triage/skill.md +21 -2
  68. package/skills/mcp-agent-trust/skill.md +17 -10
  69. package/skills/mlops-security/skill.md +2 -1
  70. package/skills/ot-ics-security/skill.md +1 -1
  71. package/skills/policy-exception-gen/skill.md +3 -3
  72. package/skills/pqc-first/skill.md +1 -1
  73. package/skills/rag-pipeline-security/skill.md +7 -3
  74. package/skills/researcher/skill.md +20 -3
  75. package/skills/sector-energy/skill.md +1 -1
  76. package/skills/sector-federal-government/skill.md +1 -1
  77. package/skills/sector-financial/skill.md +3 -3
  78. package/skills/sector-healthcare/skill.md +2 -2
  79. package/skills/security-maturity-tiers/skill.md +7 -7
  80. package/skills/skill-update-loop/skill.md +19 -3
  81. package/skills/supply-chain-integrity/skill.md +1 -1
  82. package/skills/threat-model-currency/skill.md +11 -11
  83. package/skills/threat-modeling-methodology/skill.md +3 -3
  84. package/skills/webapp-security/skill.md +1 -1
  85. package/skills/zeroday-gap-learn/skill.md +51 -7
  86. package/vendor/blamejs/_PROVENANCE.json +4 -1
  87. package/vendor/blamejs/worker-pool.js +38 -0
@@ -549,20 +549,31 @@ const GHSA_SOURCE = {
549
549
  return ghsa.buildDiff(ctx);
550
550
  },
551
551
  async applyDiff(ctx, diffs) {
552
- const ghsa = require("./source-ghsa");
552
+ // v0.12.14 (audit B-F1): the prior shape mutated ctx.cveCatalog in
553
+ // memory but NEVER persisted to disk. Bulk `--source ghsa --apply`
554
+ // reported "applied: N updates" while the catalog file gained zero
555
+ // entries. Worse under `--swarm`: KEV's withCatalogLock would re-read
556
+ // catalog from disk INSIDE the lock and overwrite the unflushed
557
+ // in-memory mutations. Route through the same withCatalogLock helper
558
+ // that KEV/EPSS/NVD/RFC use (v0.12.12 concurrency fix).
559
+ const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
553
560
  let updated = 0;
554
561
  const errors = [];
555
- for (const d of diffs) {
556
- if (d.field !== "_new_entry") continue;
557
- if (!d.after || !d.id) continue;
558
- if (ctx.cveCatalog[d.id]) continue; // never overwrite existing entries
559
- try {
560
- ctx.cveCatalog[d.id] = d.after;
561
- updated++;
562
- } catch (e) {
563
- errors.push(`${d.id}: ${e.message}`);
562
+ await withCatalogLock(catalogPath, (catalog) => {
563
+ for (const d of diffs) {
564
+ if (d.field !== "_new_entry") continue;
565
+ if (!d.after || !d.id) continue;
566
+ if (catalog[d.id]) continue; // never overwrite existing entries
567
+ try {
568
+ catalog[d.id] = d.after;
569
+ updated++;
570
+ } catch (e) {
571
+ errors.push(`${d.id}: ${e.message}`);
572
+ }
564
573
  }
565
- }
574
+ ctx.cveCatalog = catalog;
575
+ return catalog;
576
+ });
566
577
  return { updated, errors };
567
578
  },
568
579
  };
@@ -591,20 +602,27 @@ const OSV_SOURCE = {
591
602
  return osv.buildDiff(ctx);
592
603
  },
593
604
  async applyDiff(ctx, diffs) {
594
- // Same shape as GHSA applyDiff skip overwrites, surface conflicts.
605
+ // v0.12.14 (audit B-F1): same fix as GHSA — route the read-modify-write
606
+ // through withCatalogLock so writes actually land on disk and so
607
+ // concurrent --source osv --apply doesn't lose updates.
608
+ const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
595
609
  let updated = 0;
596
610
  const errors = [];
597
- for (const d of diffs) {
598
- if (d.field !== "_new_entry") continue;
599
- if (!d.after || !d.id) continue;
600
- if (ctx.cveCatalog[d.id]) continue; // never overwrite existing entries
601
- try {
602
- ctx.cveCatalog[d.id] = d.after;
603
- updated++;
604
- } catch (e) {
605
- errors.push(`${d.id}: ${e.message}`);
611
+ await withCatalogLock(catalogPath, (catalog) => {
612
+ for (const d of diffs) {
613
+ if (d.field !== "_new_entry") continue;
614
+ if (!d.after || !d.id) continue;
615
+ if (catalog[d.id]) continue; // never overwrite existing entries
616
+ try {
617
+ catalog[d.id] = d.after;
618
+ updated++;
619
+ } catch (e) {
620
+ errors.push(`${d.id}: ${e.message}`);
621
+ }
606
622
  }
607
- }
623
+ ctx.cveCatalog = catalog;
624
+ return catalog;
625
+ });
608
626
  return { updated, errors };
609
627
  },
610
628
  };
@@ -97,6 +97,10 @@ function getJson(url, timeoutMs) {
97
97
  function getBuffer(url, timeoutMs) {
98
98
  return new Promise((resolve, reject) => {
99
99
  const u = new URL(url);
100
+ const cap = (() => {
101
+ const env = parseInt(process.env.EXCEPTD_TARBALL_SIZE_CAP_BYTES, 10);
102
+ return Number.isFinite(env) && env > 0 ? env : 200 * 1024 * 1024;
103
+ })();
100
104
  const req = https.get({
101
105
  host: u.host, path: u.pathname + u.search,
102
106
  headers: { "User-Agent": "exceptd/refresh-network" },
@@ -107,7 +111,17 @@ function getBuffer(url, timeoutMs) {
107
111
  return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
108
112
  }
109
113
  const chunks = [];
110
- res.on("data", (c) => chunks.push(c));
114
+ let total = 0;
115
+ // v0.12.14 (audit F5): enforce streaming size cap so a hostile
116
+ // registry CDN can't stream gigabytes into RAM.
117
+ res.on("data", (c) => {
118
+ total += c.length;
119
+ if (total > cap) {
120
+ req.destroy(new Error(`tarball exceeds ${cap}-byte cap during streaming download`));
121
+ return;
122
+ }
123
+ chunks.push(c);
124
+ });
111
125
  res.on("end", () => resolve(Buffer.concat(chunks)));
112
126
  });
113
127
  req.on("timeout", () => req.destroy(new Error("timeout")));
@@ -177,6 +191,36 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
177
191
  } catch { return false; }
178
192
  }
179
193
 
194
+ // v0.12.14 (audit F1, F7): CRLF/BOM normalization mirrors lib/verify.js's
195
+ // normalize(). Duplicated here to keep refresh-network free of cross-module
196
+ // runtime deps. ANY change here MUST be mirrored in lib/verify.js +
197
+ // lib/sign.js — the three normalize() implementations form a byte-stability
198
+ // contract.
199
+ function normalizeSkillBytes(buf) {
200
+ let s = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
201
+ if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
202
+ return Buffer.from(s.replace(/\r\n/g, "\n"), "utf8");
203
+ }
204
+
205
+ // Manifest path validation. Mirrors lib/verify.js validateSkillPath().
206
+ function validateManifestSkillPath(skillPath) {
207
+ if (typeof skillPath !== "string") throw new Error(`manifest skill.path must be a string, got ${typeof skillPath}`);
208
+ if (skillPath.includes("\\")) throw new Error(`manifest skill.path must use forward slashes: ${JSON.stringify(skillPath)}`);
209
+ if (!skillPath.startsWith("skills/")) throw new Error(`manifest skill.path must start with 'skills/': ${JSON.stringify(skillPath)}`);
210
+ if (skillPath.includes("..")) throw new Error(`manifest skill.path must not contain '..': ${JSON.stringify(skillPath)}`);
211
+ return skillPath;
212
+ }
213
+
214
+ // v0.12.14 (audit F5): tarball download size cap. A hostile registry CDN
215
+ // could stream gigabytes; Node buffers chunks in RAM until OOM. Current
216
+ // tarball is ~2 MB; 200 MB is generous defense-in-depth. Tunable via
217
+ // EXCEPTD_TARBALL_SIZE_CAP_BYTES for future growth.
218
+ const TARBALL_SIZE_CAP_BYTES_DEFAULT = 200 * 1024 * 1024;
219
+ function tarballSizeCap() {
220
+ const env = parseInt(process.env.EXCEPTD_TARBALL_SIZE_CAP_BYTES, 10);
221
+ return Number.isFinite(env) && env > 0 ? env : TARBALL_SIZE_CAP_BYTES_DEFAULT;
222
+ }
223
+
180
224
  async function main() {
181
225
  const opts = parseArgs(process.argv);
182
226
  const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
@@ -204,6 +248,8 @@ async function main() {
204
248
  const latestVersion = meta.version;
205
249
  const tarballUrl = meta.dist && meta.dist.tarball;
206
250
  const tarballShasum = meta.dist && meta.dist.shasum;
251
+ const tarballIntegrity = meta.dist && meta.dist.integrity; // SHA-512 SRI
252
+ const registrySignatures = Array.isArray(meta.dist && meta.dist.signatures) ? meta.dist.signatures : [];
207
253
  if (!tarballUrl) {
208
254
  emit({ ok: false, error: "registry metadata missing dist.tarball" }, opts.json);
209
255
  process.exitCode = 2; return;
@@ -240,6 +286,32 @@ async function main() {
240
286
  process.exitCode = 2; return;
241
287
  }
242
288
 
289
+ // v0.12.14 (audit F5): defense-in-depth tarball size cap.
290
+ const sizeCap = tarballSizeCap();
291
+ if (tgzBuf.length > sizeCap) {
292
+ emit({ ok: false, error: `tarball exceeds size cap: ${tgzBuf.length} bytes > ${sizeCap} (EXCEPTD_TARBALL_SIZE_CAP_BYTES)` }, opts.json);
293
+ process.exitCode = 4; return;
294
+ }
295
+
296
+ // v0.12.14 (audit F6, F3): verify SHA-512 SRI first (collision-resistant
297
+ // beyond SHA-1 reach), then SHA-1 shasum for compatibility, then dist.
298
+ // signatures[] (npm registry's Ed25519 signing key). Each layer is
299
+ // defense-in-depth — registry compromise that produces a SHA-1 collision
300
+ // doesn't trivially produce a SHA-512 collision; an attacker who breaks
301
+ // both still has to forge the npm-signing-key signature on the tarball.
302
+ if (tarballIntegrity && /^sha512-/.test(tarballIntegrity)) {
303
+ const expected = tarballIntegrity.slice("sha512-".length);
304
+ const actual = crypto.createHash("sha512").update(tgzBuf).digest("base64");
305
+ if (actual !== expected) {
306
+ emit({ ok: false, error: `tarball SHA-512 integrity mismatch: dist.integrity=${tarballIntegrity}, actual=sha512-${actual}` }, opts.json);
307
+ process.exitCode = 4; return;
308
+ }
309
+ } else if (tarballIntegrity) {
310
+ // Non-sha512 SRI (e.g. sha384) — emit a warning but accept; SHA-1 path
311
+ // below still gates.
312
+ progress(`note: dist.integrity present but not sha512: ${tarballIntegrity.slice(0, 40)}`, opts.json);
313
+ }
314
+
243
315
  // Verify shasum (registry-provided integrity).
244
316
  if (tarballShasum) {
245
317
  const actual = crypto.createHash("sha1").update(tgzBuf).digest("hex");
@@ -290,22 +362,51 @@ async function main() {
290
362
  try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
291
363
  catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
292
364
 
365
+ // v0.12.14 (audit F1): the prior loop iterated `sk.id` + a fixed payload
366
+ // path `skills/<id>/SKILL.md`. Manifest entries actually expose `name` +
367
+ // `path` (a forward-slash relative path like `skills/<name>/skill.md`,
368
+ // lowercase). Result: the loop matched zero entries; `failures.length === 0`
369
+ // and `verifiedCount === 0` and the swap proceeded with `ok: true`. Every
370
+ // operator running `exceptd refresh --network` installed unverified bytes.
371
+ //
372
+ // Fixed shape mirrors lib/verify.js: iterate `manifest.skills[]` by
373
+ // `name` + `path` + `signature`. Apply the same CRLF/BOM normalization
374
+ // before verify (lib/verify.js normalize() — duplicated here to keep
375
+ // this path free of cross-module runtime deps). validateSkillPath()
376
+ // is also mirrored to defend against path traversal in a tampered
377
+ // tarball manifest before we resolve the path against the extracted
378
+ // tree.
293
379
  const localKeyObj = crypto.createPublicKey(localPubKeyText);
294
380
  const skills = Array.isArray(tarballManifest.skills) ? tarballManifest.skills : [];
295
381
  const failures = [];
296
382
  let verifiedCount = 0;
297
383
  for (const sk of skills) {
298
- if (!sk || !sk.id || !sk.signature) continue;
299
- // Find the skill payload entry. manifest convention: skills/<id>/SKILL.md
300
- const payloadName = `skills/${sk.id}/SKILL.md`;
301
- const payloadEntry = entries.find((e) => stripPkg(e.name) === payloadName);
302
- if (!payloadEntry) { failures.push({ id: sk.id, reason: "payload not in tarball" }); continue; }
303
- const ok = verifyDetached(localKeyObj, payloadEntry.body, sk.signature);
384
+ if (!sk || typeof sk.name !== "string" || typeof sk.signature !== "string") {
385
+ failures.push({ name: sk?.name || "(missing name)", reason: "manifest entry missing name or signature" });
386
+ continue;
387
+ }
388
+ let normalizedPath;
389
+ try { normalizedPath = validateManifestSkillPath(sk.path); }
390
+ catch (e) { failures.push({ name: sk.name, reason: `manifest path rejected: ${e.message}` }); continue; }
391
+ const payloadEntry = entries.find((e) => stripPkg(e.name) === normalizedPath);
392
+ if (!payloadEntry) { failures.push({ name: sk.name, reason: `payload missing from tarball: ${normalizedPath}` }); continue; }
393
+ const normalized = normalizeSkillBytes(payloadEntry.body);
394
+ const ok = verifyDetached(localKeyObj, normalized, sk.signature);
304
395
  if (ok) verifiedCount++;
305
- else failures.push({ id: sk.id, reason: "signature did not verify against local public key" });
396
+ else failures.push({ name: sk.name, reason: "Ed25519 signature did not verify against local public key" });
306
397
  }
307
398
 
308
- if (failures.length > 0) {
399
+ if (skills.length === 0) {
400
+ emit({
401
+ ok: false,
402
+ error: "tarball manifest.json declares zero skills — refusing to swap",
403
+ verified: 0,
404
+ total: 0,
405
+ hint: "A legitimate tarball must declare at least one skill. Treat this as a tarball-integrity failure.",
406
+ }, opts.json);
407
+ process.exitCode = 5; return;
408
+ }
409
+ if (verifiedCount !== skills.length || failures.length > 0) {
309
410
  emit({
310
411
  ok: false,
311
412
  error: `${failures.length}/${skills.length} skill signature(s) failed verification — refusing to swap`,
@@ -317,6 +418,34 @@ async function main() {
317
418
  process.exitCode = 5; return;
318
419
  }
319
420
 
421
+ // v0.12.14 (audit F2): the swap loop replaces `data/` + `manifest.json` +
422
+ // `manifest-snapshot.json` in addition to `skills/`. None of those files
423
+ // are covered by the per-skill Ed25519 signature (which signs only the
424
+ // skill body bytes). The only integrity check between the registry and
425
+ // those bytes is SHA-1 dist.shasum — collision-broken since 2017 and
426
+ // weaker than `npm install` itself which honors dist.integrity (SHA-512
427
+ // SRI) + dist.signatures (npm Ed25519 registry key) + dist.attestations
428
+ // (sigstore SLSA provenance).
429
+ //
430
+ // Defense-in-depth: refuse the swap if the manifest skills list doesn't
431
+ // exactly match the skill payload entries present in the tarball. A
432
+ // malicious tarball that drops/adds a skill outside the manifest no
433
+ // longer slips through.
434
+ const manifestSkillPaths = new Set(skills.map(s => validateManifestSkillPath(s.path)));
435
+ const tarballSkillPayloads = entries
436
+ .map(e => stripPkg(e.name))
437
+ .filter(name => /^skills\/[^/]+\/skill\.md$/.test(name));
438
+ for (const tp of tarballSkillPayloads) {
439
+ if (!manifestSkillPaths.has(tp)) {
440
+ emit({
441
+ ok: false,
442
+ error: `tarball ships skill payload not declared in manifest: ${tp} — refusing to swap`,
443
+ hint: "Tarball+manifest divergence. Report at https://github.com/blamejs/exceptd-skills/issues.",
444
+ }, opts.json);
445
+ process.exitCode = 5; return;
446
+ }
447
+ }
448
+
320
449
  if (opts.dryRun) {
321
450
  emit({
322
451
  ok: true,
@@ -330,9 +459,16 @@ async function main() {
330
459
  return;
331
460
  }
332
461
 
333
- // Atomic swap: stage to a tmp dir under the install, then rename.
462
+ // v0.12.14 (audit F4): the prior swap loop renamed targets one-by-one,
463
+ // and a mid-loop failure left the install half-applied with no automatic
464
+ // rollback. New shape: rename all old targets into a single backup dir
465
+ // first (so the install is empty-of-old before any new content is moved
466
+ // in); then rename all new targets in; on failure, walk the backup dir
467
+ // in reverse and restore.
334
468
  const stageDir = fs.mkdtempSync(path.join(ROOT, ".refresh-network-"));
335
469
  let written = 0;
470
+ let backupDir = null;
471
+ const completedSteps = []; // [{kind: 'backup' | 'install', target}]
336
472
  try {
337
473
  for (const entry of entries) {
338
474
  const rel = stripPkg(entry.name);
@@ -347,19 +483,32 @@ async function main() {
347
483
  written++;
348
484
  }
349
485
 
350
- // Replace targets.
351
- const replaceList = ["data", "skills", "manifest.json", "manifest-snapshot.json"];
352
- const backupDir = path.join(ROOT, `.refresh-network-backup-${Date.now()}`);
486
+ // v0.12.14 (audit F10): use PID + random suffix in the backup dir name
487
+ // so concurrent refresh-network invocations don't collide on the
488
+ // millisecond clock.
489
+ const backupSuffix = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
490
+ backupDir = path.join(ROOT, `.refresh-network-backup-${Date.now()}-${backupSuffix}`);
353
491
  fs.mkdirSync(backupDir);
492
+ const replaceList = ["data", "skills", "manifest.json", "manifest-snapshot.json"];
493
+
494
+ // Phase A: move all existing targets to backupDir. After this loop
495
+ // completes, the install root has none of the replaced targets.
354
496
  for (const target of replaceList) {
355
- const src = path.join(stageDir, target);
356
- if (!fs.existsSync(src)) continue;
357
497
  const dst = path.join(ROOT, target);
358
498
  if (fs.existsSync(dst)) {
359
499
  fs.renameSync(dst, path.join(backupDir, target));
500
+ completedSteps.push({ kind: "backup", target });
360
501
  }
361
- fs.renameSync(src, dst);
362
502
  }
503
+
504
+ // Phase B: move all new targets in from stage.
505
+ for (const target of replaceList) {
506
+ const src = path.join(stageDir, target);
507
+ if (!fs.existsSync(src)) continue;
508
+ fs.renameSync(src, path.join(ROOT, target));
509
+ completedSteps.push({ kind: "install", target });
510
+ }
511
+
363
512
  fs.rmSync(stageDir, { recursive: true, force: true });
364
513
  // Best-effort cleanup of backup dir — keep on disk for one cycle so
365
514
  // operators can manually roll back if something feels off.
@@ -371,11 +520,38 @@ async function main() {
371
520
  total_skills: skills.length,
372
521
  files_written: written,
373
522
  backup_dir: path.relative(ROOT, backupDir),
523
+ registry_signatures_present: registrySignatures.length,
374
524
  message: `refreshed catalog from v${localVersion} → v${latestVersion} (${verifiedCount}/${skills.length} signatures verified). Backup at ${path.relative(ROOT, backupDir)} — safe to remove after verifying the new run.`,
375
525
  }, opts.json);
376
526
  } catch (e) {
527
+ // v0.12.14 (audit F4): walk completedSteps in reverse to undo partial work.
528
+ const rollbackErrors = [];
529
+ for (const step of [...completedSteps].reverse()) {
530
+ try {
531
+ if (step.kind === "install") {
532
+ // Remove the newly-installed copy.
533
+ fs.rmSync(path.join(ROOT, step.target), { recursive: true, force: true });
534
+ } else if (step.kind === "backup" && backupDir) {
535
+ // Restore from backup.
536
+ const src = path.join(backupDir, step.target);
537
+ const dst = path.join(ROOT, step.target);
538
+ if (fs.existsSync(src)) fs.renameSync(src, dst);
539
+ }
540
+ } catch (re) {
541
+ rollbackErrors.push({ target: step.target, kind: step.kind, error: re.message });
542
+ }
543
+ }
377
544
  fs.rmSync(stageDir, { recursive: true, force: true });
378
- emit({ ok: false, error: `swap failed mid-rename: ${e.message}`, hint: "If files are missing, restore from the backup dir at the install root, or reinstall with `npm install -g @blamejs/exceptd-skills`." }, opts.json);
545
+ emit({
546
+ ok: false,
547
+ error: `swap failed mid-rename: ${e.message}`,
548
+ rolled_back: rollbackErrors.length === 0,
549
+ rollback_errors: rollbackErrors,
550
+ backup_dir: backupDir ? path.relative(ROOT, backupDir) : null,
551
+ hint: rollbackErrors.length === 0
552
+ ? "Auto-rollback completed. Install state matches pre-refresh. Re-run `exceptd refresh --network` or `npm install -g @blamejs/exceptd-skills` to retry."
553
+ : "Auto-rollback partially failed. Restore manually from the backup dir at the install root, or reinstall with `npm install -g @blamejs/exceptd-skills`.",
554
+ }, opts.json);
379
555
  process.exitCode = 4;
380
556
  }
381
557
  }
package/lib/scoring.js CHANGED
@@ -99,8 +99,15 @@ function scoreCustom(factors, opts) {
99
99
  blast_radius = 0,
100
100
  patch_available = false,
101
101
  live_patch_available = false,
102
- reboot_required = false
102
+ reboot_required = false,
103
+ // v0.12.15 (audit J F9): the CVE catalog field is `patch_required_reboot`
104
+ // but scoreCustom historically expected `reboot_required`. validate()
105
+ // already aliases at the call site; accept either spelling here so a
106
+ // direct caller passing the catalog entry doesn't silently lose the
107
+ // reboot factor.
108
+ patch_required_reboot,
103
109
  } = factors || {};
110
+ const rebootFactor = (reboot_required === true) || (patch_required_reboot === true);
104
111
 
105
112
  let score = 0;
106
113
  score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
@@ -108,16 +115,22 @@ function scoreCustom(factors, opts) {
108
115
  score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
109
116
  score += active_exploitation === 'confirmed' ? RWEP_WEIGHTS.active_exploitation : 0;
110
117
  score += active_exploitation === 'suspected' ? Math.floor(RWEP_WEIGHTS.active_exploitation / 2) : 0;
111
- // Clamp blast_radius into the weight-ceiling band [0, RWEP_WEIGHTS.blast_radius]
112
- // before adding. Out-of-range values still produce a clamped contribution but
113
- // validateFactors() surfaces the anomaly so operators see the unit error.
114
- const brClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, typeof blast_radius === 'number' ? blast_radius : 0));
118
+ // v0.12.15 (audit J F1, F5): blast_radius numeric coercion must reject
119
+ // NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
120
+ // check passed NaN (which is `typeof === 'number'`) into `Math.min/max`
121
+ // which propagates NaN through the final clamp, defeating the [0,100]
122
+ // contract. Number.isFinite + Number() coercion catches all four classes:
123
+ // NaN, Infinity, undefined, stringified-number.
124
+ const brRaw = Number.isFinite(Number(blast_radius)) ? Number(blast_radius) : 0;
125
+ const brClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, brRaw));
115
126
  score += brClamped;
116
127
  score += patch_available ? RWEP_WEIGHTS.patch_available : 0;
117
128
  score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
118
- score += reboot_required ? RWEP_WEIGHTS.reboot_required : 0;
129
+ score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
119
130
 
120
- const clamped = Math.min(100, Math.max(0, score));
131
+ // v0.12.15 (audit J F1): defense-in-depth clamp against any unforeseen
132
+ // NaN production above (negative weight + Infinity + math edge case).
133
+ const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
121
134
  if (opts && opts.collectWarnings) {
122
135
  return { score: clamped, _scoring_warnings: validateFactors(factors) };
123
136
  }