@blamejs/exceptd-skills 0.12.11 → 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 (91) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/bin/exceptd.js +299 -48
  3. package/data/_indexes/_meta.json +49 -48
  4. package/data/_indexes/activity-feed.json +13 -5
  5. package/data/_indexes/catalog-summaries.json +51 -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 +339 -0
  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 +505 -47
  23. package/lib/lint-skills.js +217 -15
  24. package/lib/playbook-runner.js +1224 -183
  25. package/lib/prefetch.js +121 -8
  26. package/lib/refresh-external.js +261 -95
  27. package/lib/refresh-network.js +208 -18
  28. package/lib/schemas/manifest.schema.json +16 -0
  29. package/lib/scoring.js +83 -7
  30. package/lib/sign.js +112 -3
  31. package/lib/source-ghsa.js +219 -37
  32. package/lib/source-osv.js +381 -122
  33. package/lib/validate-catalog-meta.js +64 -9
  34. package/lib/validate-cve-catalog.js +213 -7
  35. package/lib/validate-indexes.js +88 -37
  36. package/lib/validate-playbooks.js +469 -0
  37. package/lib/verify.js +313 -16
  38. package/manifest-snapshot.json +1 -1
  39. package/manifest-snapshot.sha256 +1 -0
  40. package/manifest.json +73 -73
  41. package/orchestrator/dispatcher.js +21 -1
  42. package/orchestrator/event-bus.js +52 -8
  43. package/orchestrator/index.js +279 -20
  44. package/orchestrator/pipeline.js +63 -2
  45. package/orchestrator/scanner.js +32 -10
  46. package/orchestrator/scheduler.js +196 -20
  47. package/package.json +3 -1
  48. package/sbom.cdx.json +9 -9
  49. package/scripts/check-manifest-snapshot.js +32 -0
  50. package/scripts/check-sbom-currency.js +65 -3
  51. package/scripts/check-test-coverage.js +142 -19
  52. package/scripts/predeploy.js +110 -40
  53. package/scripts/refresh-manifest-snapshot.js +55 -4
  54. package/scripts/validate-vendor-online.js +169 -0
  55. package/scripts/verify-shipped-tarball.js +106 -3
  56. package/skills/ai-attack-surface/skill.md +18 -10
  57. package/skills/ai-c2-detection/skill.md +7 -2
  58. package/skills/ai-risk-management/skill.md +5 -4
  59. package/skills/api-security/skill.md +3 -3
  60. package/skills/attack-surface-pentest/skill.md +5 -5
  61. package/skills/cloud-security/skill.md +1 -1
  62. package/skills/compliance-theater/skill.md +8 -8
  63. package/skills/container-runtime-security/skill.md +1 -1
  64. package/skills/dlp-gap-analysis/skill.md +5 -1
  65. package/skills/email-security-anti-phishing/skill.md +1 -1
  66. package/skills/exploit-scoring/skill.md +18 -18
  67. package/skills/framework-gap-analysis/skill.md +6 -6
  68. package/skills/global-grc/skill.md +3 -2
  69. package/skills/identity-assurance/skill.md +2 -2
  70. package/skills/incident-response-playbook/skill.md +4 -4
  71. package/skills/kernel-lpe-triage/skill.md +21 -2
  72. package/skills/mcp-agent-trust/skill.md +17 -10
  73. package/skills/mlops-security/skill.md +2 -1
  74. package/skills/ot-ics-security/skill.md +1 -1
  75. package/skills/policy-exception-gen/skill.md +3 -3
  76. package/skills/pqc-first/skill.md +1 -1
  77. package/skills/rag-pipeline-security/skill.md +7 -3
  78. package/skills/researcher/skill.md +20 -3
  79. package/skills/sector-energy/skill.md +1 -1
  80. package/skills/sector-federal-government/skill.md +1 -1
  81. package/skills/sector-financial/skill.md +3 -3
  82. package/skills/sector-healthcare/skill.md +2 -2
  83. package/skills/security-maturity-tiers/skill.md +7 -7
  84. package/skills/skill-update-loop/skill.md +19 -3
  85. package/skills/supply-chain-integrity/skill.md +1 -1
  86. package/skills/threat-model-currency/skill.md +11 -11
  87. package/skills/threat-modeling-methodology/skill.md +3 -3
  88. package/skills/webapp-security/skill.md +1 -1
  89. package/skills/zeroday-gap-learn/skill.md +51 -7
  90. package/vendor/blamejs/_PROVENANCE.json +4 -1
  91. package/vendor/blamejs/worker-pool.js +38 -0
@@ -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")));
@@ -125,6 +139,18 @@ function parseTar(buf) {
125
139
  const entries = [];
126
140
  let offset = 0;
127
141
  let pendingLongName = null;
142
+ // v0.12.12: tarballs from a compromised registry CDN could ship entries
143
+ // with `..`-bearing names targeting paths outside the install root. The
144
+ // immediate callers (verify-shipped-tarball.js + the network update path)
145
+ // do hash + signature checks before honoring entries, so this is
146
+ // defense-in-depth — drop the entry rather than handing a path-traversal
147
+ // string downstream.
148
+ const isSafeName = (n) => {
149
+ if (typeof n !== "string" || n.length === 0) return false;
150
+ // Reject absolute paths AND any segment that is exactly ".."
151
+ if (/^[\\/]/.test(n) || /^[A-Za-z]:[\\/]/.test(n)) return false;
152
+ return !n.split(/[\\/]/).some((seg) => seg === "..");
153
+ };
128
154
  while (offset + 512 <= buf.length) {
129
155
  const block = buf.subarray(offset, offset + 512);
130
156
  // empty block = end-of-archive marker
@@ -141,7 +167,9 @@ function parseTar(buf) {
141
167
  if (type === "L") {
142
168
  pendingLongName = buf.subarray(dataStart, dataEnd).toString("utf8").replace(/\0.*$/, "");
143
169
  } else if (type === "0" || type === "" || type === "\0") {
144
- entries.push({ name, body: buf.subarray(dataStart, dataEnd) });
170
+ if (isSafeName(name)) {
171
+ entries.push({ name, body: buf.subarray(dataStart, dataEnd) });
172
+ }
145
173
  }
146
174
  // round up to 512
147
175
  offset = dataStart + Math.ceil(size / 512) * 512;
@@ -163,6 +191,36 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
163
191
  } catch { return false; }
164
192
  }
165
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
+
166
224
  async function main() {
167
225
  const opts = parseArgs(process.argv);
168
226
  const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
@@ -190,6 +248,8 @@ async function main() {
190
248
  const latestVersion = meta.version;
191
249
  const tarballUrl = meta.dist && meta.dist.tarball;
192
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 : [];
193
253
  if (!tarballUrl) {
194
254
  emit({ ok: false, error: "registry metadata missing dist.tarball" }, opts.json);
195
255
  process.exitCode = 2; return;
@@ -226,6 +286,32 @@ async function main() {
226
286
  process.exitCode = 2; return;
227
287
  }
228
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
+
229
315
  // Verify shasum (registry-provided integrity).
230
316
  if (tarballShasum) {
231
317
  const actual = crypto.createHash("sha1").update(tgzBuf).digest("hex");
@@ -276,22 +362,51 @@ async function main() {
276
362
  try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
277
363
  catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
278
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.
279
379
  const localKeyObj = crypto.createPublicKey(localPubKeyText);
280
380
  const skills = Array.isArray(tarballManifest.skills) ? tarballManifest.skills : [];
281
381
  const failures = [];
282
382
  let verifiedCount = 0;
283
383
  for (const sk of skills) {
284
- if (!sk || !sk.id || !sk.signature) continue;
285
- // Find the skill payload entry. manifest convention: skills/<id>/SKILL.md
286
- const payloadName = `skills/${sk.id}/SKILL.md`;
287
- const payloadEntry = entries.find((e) => stripPkg(e.name) === payloadName);
288
- if (!payloadEntry) { failures.push({ id: sk.id, reason: "payload not in tarball" }); continue; }
289
- 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);
290
395
  if (ok) verifiedCount++;
291
- 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" });
292
397
  }
293
398
 
294
- 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) {
295
410
  emit({
296
411
  ok: false,
297
412
  error: `${failures.length}/${skills.length} skill signature(s) failed verification — refusing to swap`,
@@ -303,6 +418,34 @@ async function main() {
303
418
  process.exitCode = 5; return;
304
419
  }
305
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
+
306
449
  if (opts.dryRun) {
307
450
  emit({
308
451
  ok: true,
@@ -316,9 +459,16 @@ async function main() {
316
459
  return;
317
460
  }
318
461
 
319
- // 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.
320
468
  const stageDir = fs.mkdtempSync(path.join(ROOT, ".refresh-network-"));
321
469
  let written = 0;
470
+ let backupDir = null;
471
+ const completedSteps = []; // [{kind: 'backup' | 'install', target}]
322
472
  try {
323
473
  for (const entry of entries) {
324
474
  const rel = stripPkg(entry.name);
@@ -333,19 +483,32 @@ async function main() {
333
483
  written++;
334
484
  }
335
485
 
336
- // Replace targets.
337
- const replaceList = ["data", "skills", "manifest.json", "manifest-snapshot.json"];
338
- 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}`);
339
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.
340
496
  for (const target of replaceList) {
341
- const src = path.join(stageDir, target);
342
- if (!fs.existsSync(src)) continue;
343
497
  const dst = path.join(ROOT, target);
344
498
  if (fs.existsSync(dst)) {
345
499
  fs.renameSync(dst, path.join(backupDir, target));
500
+ completedSteps.push({ kind: "backup", target });
346
501
  }
347
- fs.renameSync(src, dst);
348
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
+
349
512
  fs.rmSync(stageDir, { recursive: true, force: true });
350
513
  // Best-effort cleanup of backup dir — keep on disk for one cycle so
351
514
  // operators can manually roll back if something feels off.
@@ -357,11 +520,38 @@ async function main() {
357
520
  total_skills: skills.length,
358
521
  files_written: written,
359
522
  backup_dir: path.relative(ROOT, backupDir),
523
+ registry_signatures_present: registrySignatures.length,
360
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.`,
361
525
  }, opts.json);
362
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
+ }
363
544
  fs.rmSync(stageDir, { recursive: true, force: true });
364
- 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);
365
555
  process.exitCode = 4;
366
556
  }
367
557
  }
@@ -82,6 +82,22 @@
82
82
  "type": "array",
83
83
  "items": { "type": "string", "minLength": 1 }
84
84
  },
85
+ "rfc_refs": {
86
+ "type": "array",
87
+ "items": { "type": "string", "minLength": 1 }
88
+ },
89
+ "cwe_refs": {
90
+ "type": "array",
91
+ "items": { "type": "string", "minLength": 1 }
92
+ },
93
+ "d3fend_refs": {
94
+ "type": "array",
95
+ "items": { "type": "string", "minLength": 1 }
96
+ },
97
+ "dlp_refs": {
98
+ "type": "array",
99
+ "items": { "type": "string", "minLength": 1 }
100
+ },
85
101
  "last_threat_review": {
86
102
  "type": "string",
87
103
  "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
package/lib/scoring.js CHANGED
@@ -34,7 +34,62 @@ function score(cveId, catalog) {
34
34
  return entry.rwep_score;
35
35
  }
36
36
 
37
- function scoreCustom(factors) {
37
+ /**
38
+ * E10: Validate an RWEP factor bag. Returns an array of warning strings
39
+ * for missing-but-defaultable fields and out-of-range values. Does NOT
40
+ * throw — operators wanting hard enforcement should treat a non-empty
41
+ * return as a failure themselves.
42
+ *
43
+ * Range expectations:
44
+ * - cisa_kev, poc_available, ai_assisted_weapon, ai_discovered,
45
+ * patch_available, live_patch_available, reboot_required: boolean
46
+ * (or null, treated as false with a missing-field warning).
47
+ * - active_exploitation: 'none' | 'unknown' | 'suspected' | 'confirmed'.
48
+ * - blast_radius: integer in [0, 30] (clamped at the weight ceiling but
49
+ * flagged when out-of-range — out-of-range usually means a unit error).
50
+ */
51
+ function validateFactors(factors) {
52
+ const warnings = [];
53
+ if (!factors || typeof factors !== 'object') {
54
+ return ['factors: expected object, got ' + (factors === null ? 'null' : typeof factors)];
55
+ }
56
+ const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
57
+ 'patch_available', 'live_patch_available', 'reboot_required'];
58
+ for (const f of boolFields) {
59
+ if (factors[f] === undefined || factors[f] === null) {
60
+ warnings.push(`${f}: missing (treated as false; explicit value recommended)`);
61
+ } else if (typeof factors[f] !== 'boolean') {
62
+ warnings.push(`${f}: expected boolean, got ${typeof factors[f]} (${JSON.stringify(factors[f])})`);
63
+ }
64
+ }
65
+ const aeAllowed = ['none', 'unknown', 'suspected', 'confirmed'];
66
+ if (factors.active_exploitation === undefined || factors.active_exploitation === null) {
67
+ warnings.push("active_exploitation: missing (treated as 'none')");
68
+ } else if (!aeAllowed.includes(factors.active_exploitation)) {
69
+ warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
70
+ }
71
+ if (factors.blast_radius === undefined || factors.blast_radius === null) {
72
+ warnings.push('blast_radius: missing (treated as 0)');
73
+ } else if (typeof factors.blast_radius !== 'number' || Number.isNaN(factors.blast_radius)) {
74
+ warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
75
+ } else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
76
+ warnings.push(`blast_radius: ${factors.blast_radius} out of expected range [0, 30] (clamped to weight ceiling, but the value usually indicates a unit-of-measure mistake)`);
77
+ }
78
+ return warnings;
79
+ }
80
+
81
+ /**
82
+ * scoreCustom — compute the RWEP for a factor bag. Returns a number
83
+ * (clamped to [0, 100]).
84
+ *
85
+ * Backward-compat note: this function has always returned a number;
86
+ * callers in lib/auto-discovery.js etc. rely on that. E10 surfaces
87
+ * warnings via the optional `opts.collectWarnings` flag — when true,
88
+ * scoreCustom returns `{ score, _scoring_warnings }` instead of a bare
89
+ * number. Operators wanting validation without the score can call
90
+ * `validateFactors(factors)` directly.
91
+ */
92
+ function scoreCustom(factors, opts) {
38
93
  const {
39
94
  cisa_kev = false,
40
95
  poc_available = false,
@@ -44,8 +99,15 @@ function scoreCustom(factors) {
44
99
  blast_radius = 0,
45
100
  patch_available = false,
46
101
  live_patch_available = false,
47
- reboot_required = false
48
- } = factors;
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,
109
+ } = factors || {};
110
+ const rebootFactor = (reboot_required === true) || (patch_required_reboot === true);
49
111
 
50
112
  let score = 0;
51
113
  score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
@@ -53,12 +115,26 @@ function scoreCustom(factors) {
53
115
  score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
54
116
  score += active_exploitation === 'confirmed' ? RWEP_WEIGHTS.active_exploitation : 0;
55
117
  score += active_exploitation === 'suspected' ? Math.floor(RWEP_WEIGHTS.active_exploitation / 2) : 0;
56
- score += Math.min(RWEP_WEIGHTS.blast_radius, blast_radius);
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));
126
+ score += brClamped;
57
127
  score += patch_available ? RWEP_WEIGHTS.patch_available : 0;
58
128
  score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
59
- score += reboot_required ? RWEP_WEIGHTS.reboot_required : 0;
129
+ score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
60
130
 
61
- return 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;
134
+ if (opts && opts.collectWarnings) {
135
+ return { score: clamped, _scoring_warnings: validateFactors(factors) };
136
+ }
137
+ return clamped;
62
138
  }
63
139
 
64
140
  function timeline(rwepScore) {
@@ -146,4 +222,4 @@ function validate(catalog) {
146
222
  return errors;
147
223
  }
148
224
 
149
- module.exports = { score, scoreCustom, timeline, compare, validate, RWEP_WEIGHTS };
225
+ module.exports = { score, scoreCustom, timeline, compare, validate, validateFactors, RWEP_WEIGHTS };
package/lib/sign.js CHANGED
@@ -8,6 +8,22 @@
8
8
  * which is gitignored. The public key at keys/public.pem is tracked and used
9
9
  * by lib/verify.js for signature verification.
10
10
  *
11
+ * Byte-stability contract (must mirror lib/verify.js):
12
+ * Skill content is normalized BEFORE the bytes are signed:
13
+ * 1. Strip a UTF-8 BOM (U+FEFF) if present.
14
+ * 2. Convert CRLF line endings to LF.
15
+ * The same normalization runs in lib/verify.js. A skill file checked
16
+ * out with core.autocrlf=true on Windows therefore signs to the SAME
17
+ * signature as the LF copy on Linux CI — closing the regression class
18
+ * that broke v0.11.x signatures across the Windows/CI line-ending
19
+ * boundary. ANY change to normalize() requires the matching change in
20
+ * lib/verify.js; round-trip stability is a hard contract.
21
+ *
22
+ * Manifest entries are also validated before iteration: skill.path must
23
+ * begin with "skills/" and must not contain ".." or backslashes (see
24
+ * validateSkillPath() below). Without this a tampered manifest could
25
+ * sign or verify arbitrary files outside the skills/ tree.
26
+ *
11
27
  * Signing ceremony:
12
28
  * 1. node lib/sign.js generate-keypair — generate keypair (one time, per deployment)
13
29
  * 2. node lib/sign.js sign-all — sign all skills (after any content change)
@@ -80,10 +96,20 @@ function generateKeypair({ rotate = false } = {}) {
80
96
  /**
81
97
  * Sign all skills in manifest.json using the private key.
82
98
  * Updates manifest.json with Ed25519 signatures.
99
+ *
100
+ * Each manifest entry's `path` is validated through validateSkillPath()
101
+ * BEFORE the file is read — a tampered manifest with an out-of-tree
102
+ * path will reject the whole run.
83
103
  */
84
104
  function signAll() {
85
105
  const privateKey = loadPrivateKey();
86
106
  const manifest = loadManifest();
107
+ // Validate every entry's path before doing any I/O. Reject the whole
108
+ // manifest on the first traversal attempt — we never want to sign
109
+ // half a manifest then exit non-zero with a partial mutation.
110
+ for (const skill of manifest.skills) {
111
+ validateSkillPath(skill.path);
112
+ }
87
113
  let signed = 0;
88
114
  let errors = 0;
89
115
 
@@ -103,7 +129,16 @@ function signAll() {
103
129
  }
104
130
 
105
131
  fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
106
- console.log(`\n[sign] ${signed} skills signed. ${errors} errors.`);
132
+
133
+ // S5: verdict line FIRST, fingerprint banner after. An operator
134
+ // scrolling output should not be able to see "fingerprint: SHA256..."
135
+ // and assume success when errors > 0.
136
+ if (errors > 0) {
137
+ console.error(`\n[sign] FAILED — ${signed} signed, ${errors} errors.`);
138
+ } else {
139
+ console.log(`\n[sign] ${signed} skills signed.`);
140
+ }
141
+ printFingerprintBanner();
107
142
 
108
143
  if (errors > 0) process.exit(1);
109
144
  }
@@ -118,6 +153,7 @@ function signOne(skillName) {
118
153
  const skill = manifest.skills.find(s => s.name === skillName);
119
154
  if (!skill) { console.error(`Skill not found: ${skillName}`); process.exit(1); }
120
155
 
156
+ validateSkillPath(skill.path);
121
157
  const skillPath = path.join(ROOT, skill.path);
122
158
  const content = fs.readFileSync(skillPath, 'utf8');
123
159
  skill.signature = signContent(content, privateKey);
@@ -126,12 +162,69 @@ function signOne(skillName) {
126
162
 
127
163
  fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
128
164
  console.log(`[sign] Signed: ${skillName}`);
165
+ printFingerprintBanner();
129
166
  }
130
167
 
131
168
  // --- helpers ---
132
169
 
170
+ /**
171
+ * Normalize skill content for byte-stable signing.
172
+ *
173
+ * Strips a leading UTF-8 BOM (U+FEFF) if present, then converts CRLF
174
+ * line endings to LF. lib/verify.js applies the exact same transform.
175
+ *
176
+ * Without this, a Windows checkout with core.autocrlf=true reads a
177
+ * skill with \r\n while CI reads the same skill with \n — same bytes
178
+ * on disk in git, different bytes in the working tree, different
179
+ * signature. v0.11.x shipped 0/38 verifies for exactly this reason.
180
+ *
181
+ * @param {string} content
182
+ * @returns {string}
183
+ */
184
+ function normalize(content) {
185
+ let s = content;
186
+ if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
187
+ return s.replace(/\r\n/g, '\n');
188
+ }
189
+
190
+ /**
191
+ * Validate a manifest skill.path entry to prevent path traversal.
192
+ *
193
+ * skill.path MUST be a string.
194
+ * skill.path MUST start with "skills/".
195
+ * skill.path MUST NOT contain "..".
196
+ * skill.path MUST NOT contain backslashes (POSIX-style forward slashes
197
+ * only — manifest paths are not platform-specific).
198
+ *
199
+ * A tampered manifest with "../../../etc/passwd" or
200
+ * "skills/foo/../../.keys/private.pem" is refused; the whole run
201
+ * aborts before any file I/O.
202
+ *
203
+ * @param {string} skillPath
204
+ * @returns {string}
205
+ */
206
+ function validateSkillPath(skillPath) {
207
+ if (typeof skillPath !== 'string') {
208
+ throw new Error(`[sign] manifest skill.path must be a string, got ${typeof skillPath}`);
209
+ }
210
+ // Backslash check runs BEFORE the prefix check so a Windows-style
211
+ // path ("skills\foo\skill.md") returns the clearer "use forward
212
+ // slashes" diagnostic, not the misleading "must start with skills/".
213
+ if (skillPath.includes('\\')) {
214
+ throw new Error(`[sign] manifest skill.path must use forward slashes, not backslashes: ${JSON.stringify(skillPath)}`);
215
+ }
216
+ if (!skillPath.startsWith('skills/')) {
217
+ throw new Error(`[sign] manifest skill.path must start with 'skills/': ${JSON.stringify(skillPath)}`);
218
+ }
219
+ if (skillPath.includes('..')) {
220
+ throw new Error(`[sign] manifest skill.path must not contain '..': ${JSON.stringify(skillPath)}`);
221
+ }
222
+ return skillPath;
223
+ }
224
+
133
225
  function signContent(content, privateKey) {
134
- const signature = crypto.sign(null, Buffer.from(content, 'utf8'), {
226
+ const normalized = normalize(content);
227
+ const signature = crypto.sign(null, Buffer.from(normalized, 'utf8'), {
135
228
  key: privateKey,
136
229
  dsaEncoding: 'ieee-p1363'
137
230
  });
@@ -151,6 +244,22 @@ function loadManifest() {
151
244
  return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
152
245
  }
153
246
 
247
+ function printFingerprintBanner() {
248
+ if (!fs.existsSync(PUBLIC_KEY_PATH)) return;
249
+ try {
250
+ const pem = fs.readFileSync(PUBLIC_KEY_PATH, 'utf8');
251
+ const keyObj = crypto.createPublicKey(pem);
252
+ const der = keyObj.export({ type: 'spki', format: 'der' });
253
+ const sha256 = 'SHA256:' + crypto.createHash('sha256').update(der).digest('base64');
254
+ const sha3_512 = 'SHA3-512:' + crypto.createHash('sha3-512').update(der).digest('base64');
255
+ console.log(`[sign] Public key: keys/public.pem`);
256
+ console.log(`[sign] ${sha256}`);
257
+ console.log(`[sign] ${sha3_512}`);
258
+ } catch (_) {
259
+ // Best-effort banner — never let a fingerprint failure poison the run.
260
+ }
261
+ }
262
+
154
263
  // --- CLI ---
155
264
 
156
265
  if (require.main === module) {
@@ -194,4 +303,4 @@ Signing ceremony (first time):
194
303
  }
195
304
  }
196
305
 
197
- module.exports = { generateKeypair, signAll, signOne };
306
+ module.exports = { generateKeypair, signAll, signOne, normalize, validateSkillPath };