@blamejs/exceptd-skills 0.12.18 → 0.12.21

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 (53) hide show
  1. package/CHANGELOG.md +224 -52
  2. package/README.md +1 -1
  3. package/bin/exceptd.js +841 -68
  4. package/data/_indexes/_meta.json +14 -14
  5. package/data/_indexes/activity-feed.json +3 -3
  6. package/data/_indexes/catalog-summaries.json +3 -3
  7. package/data/_indexes/chains.json +15 -0
  8. package/data/_indexes/jurisdiction-map.json +3 -2
  9. package/data/_indexes/section-offsets.json +175 -175
  10. package/data/_indexes/summary-cards.json +1 -1
  11. package/data/_indexes/token-budget.json +83 -83
  12. package/data/cve-catalog.json +169 -2
  13. package/data/exploit-availability.json +16 -0
  14. package/data/playbooks/ai-api.json +20 -1
  15. package/data/playbooks/containers.json +30 -0
  16. package/data/playbooks/cred-stores.json +18 -0
  17. package/data/playbooks/crypto.json +18 -0
  18. package/data/playbooks/hardening.json +26 -1
  19. package/data/playbooks/kernel.json +22 -2
  20. package/data/playbooks/mcp.json +18 -0
  21. package/data/playbooks/runtime.json +20 -1
  22. package/data/playbooks/sbom.json +18 -0
  23. package/data/playbooks/secrets.json +6 -0
  24. package/data/zeroday-lessons.json +102 -0
  25. package/lib/auto-discovery.js +68 -15
  26. package/lib/cross-ref-api.js +43 -10
  27. package/lib/cve-curation.js +4 -4
  28. package/lib/playbook-runner.js +545 -63
  29. package/lib/prefetch.js +65 -18
  30. package/lib/refresh-external.js +40 -2
  31. package/lib/refresh-network.js +100 -12
  32. package/lib/scoring.js +22 -13
  33. package/lib/sign.js +14 -6
  34. package/lib/validate-catalog-meta.js +1 -1
  35. package/lib/validate-indexes.js +2 -2
  36. package/lib/verify.js +51 -10
  37. package/manifest.json +47 -48
  38. package/orchestrator/scheduler.js +10 -0
  39. package/package.json +1 -1
  40. package/sbom.cdx.json +6 -6
  41. package/scripts/check-manifest-snapshot.js +1 -1
  42. package/scripts/check-sbom-currency.js +1 -1
  43. package/scripts/predeploy.js +10 -5
  44. package/scripts/refresh-manifest-snapshot.js +2 -2
  45. package/scripts/validate-vendor-online.js +1 -1
  46. package/scripts/verify-shipped-tarball.js +94 -6
  47. package/skills/compliance-theater/skill.md +4 -1
  48. package/skills/exploit-scoring/skill.md +20 -1
  49. package/skills/framework-gap-analysis/skill.md +6 -2
  50. package/skills/kernel-lpe-triage/skill.md +50 -3
  51. package/skills/threat-model-currency/skill.md +6 -4
  52. package/skills/webapp-security/skill.md +1 -1
  53. package/skills/zeroday-gap-learn/skill.md +44 -1
package/lib/prefetch.js CHANGED
@@ -18,7 +18,7 @@
18
18
  * rfc/<doc-name>.json — IETF Datatracker doc record
19
19
  * pins/<owner>__<repo>__releases.json — MITRE GitHub releases listing
20
20
  *
21
- * audit M P2-K: the registered source names in SOURCES below are `rfc` and
21
+ * K: the registered source names in SOURCES below are `rfc` and
22
22
  * `pins`. Earlier comments + --help text said `ietf` and `github`; an
23
23
  * operator running `--source ietf` or `--source github` would hit "unknown
24
24
  * source" because no such key exists. The names below are the canonical
@@ -237,6 +237,26 @@ async function withIndexLock(cacheDir, mutator) {
237
237
  // raised when the other process is mid-unlink). Treat both as
238
238
  // "lock held, back off" rather than a fatal error.
239
239
  if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
240
+ // T P1-1: PID-liveness check. Same pattern as withCatalogLock in
241
+ // lib/refresh-external.js — read the lockfile's PID, probe with
242
+ // process.kill(pid, 0); ESRCH → holder dead, reclaim immediately;
243
+ // EPERM → holder alive (different user), keep waiting. The mtime
244
+ // fallback below covers malformed / unreadable lockfiles.
245
+ let reclaimedByPid = false;
246
+ try {
247
+ const raw = fs.readFileSync(lockPath, "utf8").trim();
248
+ const pid = Number.parseInt(raw, 10);
249
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
250
+ try {
251
+ process.kill(pid, 0);
252
+ } catch (probeErr) {
253
+ if (probeErr && probeErr.code === "ESRCH") {
254
+ try { fs.unlinkSync(lockPath); reclaimedByPid = true; } catch {}
255
+ }
256
+ }
257
+ }
258
+ } catch {}
259
+ if (reclaimedByPid) continue;
240
260
  try {
241
261
  const stat = fs.statSync(lockPath);
242
262
  if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
@@ -302,7 +322,7 @@ function isFresh(idx, source, id, maxAgeMs) {
302
322
 
303
323
  function authHeadersForSource(source) {
304
324
  if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
305
- // audit M P2-J: the registered source name for MITRE GitHub releases is
325
+ // J: the registered source name for MITRE GitHub releases is
306
326
  // `pins` (see SOURCES above). The prior check looked for `github`, so
307
327
  // GITHUB_TOKEN never reached the per-request Authorization header and
308
328
  // anonymous-rate-limited fetches were always used even when an operator
@@ -394,11 +414,20 @@ async function prefetch(options = {}) {
394
414
  const targetPath = entryPath(opts.cacheDir, item.source, item.id);
395
415
  const dir = path.dirname(targetPath);
396
416
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
397
- // v0.12.12 C4: atomic write of the payload. A concurrent reader
398
- // (refresh --from-cache running in parallel) sees the prior
399
- // payload in full or the new payload in full, never a partial
400
- // buffer.
401
- writeFileAtomic(targetPath, JSON.stringify(res.json, null, 2) + "\n");
417
+ const body = JSON.stringify(res.json, null, 2) + "\n";
418
+ // T P1-3: stage the payload to a same-volume tmp file BEFORE
419
+ // attempting to acquire the index lock. If withIndexLock fails
420
+ // (timeout after MAX_RETRIES), we want the partially-completed
421
+ // download discarded not left on disk as an orphan payload
422
+ // with no index entry. Air-gap operators feed off `readCached`,
423
+ // which consults the index; an unindexed payload silently becomes
424
+ // junk taking cache space. Pattern: stage → lock → rename+index
425
+ // → release. The rename is atomic same-volume; if it fails inside
426
+ // the lock we clean up the tmp file. If we never reach the rename
427
+ // (lock acquisition throws), the tmp file is unlinked in the
428
+ // catch block below.
429
+ const tmpPath = `${targetPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
430
+ fs.writeFileSync(tmpPath, body);
402
431
  const meta = {
403
432
  fetched_at: new Date().toISOString(),
404
433
  etag: res.etag,
@@ -406,16 +435,34 @@ async function prefetch(options = {}) {
406
435
  url: item.url,
407
436
  sha256: crypto.createHash("sha256").update(JSON.stringify(res.json)).digest("hex"),
408
437
  };
409
- idx.entries[entryKey(item.source, item.id)] = meta;
410
- // v0.12.12 C2: persist this entry's metadata to _index.json under
411
- // lock immediately, merging with whatever the on-disk index has
412
- // (another concurrent prefetch may have written sibling entries).
413
- // Without this, only the in-memory idx is updated; the final
414
- // saveIndex() would overwrite a sibling run's writes.
415
- await withIndexLock(opts.cacheDir, (current) => {
416
- current.entries[entryKey(item.source, item.id)] = meta;
417
- return current;
418
- });
438
+ try {
439
+ // v0.12.12 C2: persist this entry's metadata to _index.json under
440
+ // lock immediately, merging with whatever the on-disk index has
441
+ // (another concurrent prefetch may have written sibling entries).
442
+ // Inside the lock we also rename the staged tmp final path so
443
+ // a concurrent reader sees the new payload + new index entry as
444
+ // an atomic pair.
445
+ await withIndexLock(opts.cacheDir, (current) => {
446
+ try {
447
+ fs.renameSync(tmpPath, targetPath);
448
+ } catch (renameErr) {
449
+ // Surface as a failure to mutator: throwing here aborts the
450
+ // lock's write step. We re-throw to the outer catch which
451
+ // will increment errors.
452
+ throw renameErr;
453
+ }
454
+ current.entries[entryKey(item.source, item.id)] = meta;
455
+ return current;
456
+ });
457
+ // Mirror the entry into the in-memory idx for callers that read
458
+ // it later in this run (e.g. the final saveIndex merge).
459
+ idx.entries[entryKey(item.source, item.id)] = meta;
460
+ } catch (lockErr) {
461
+ // Lock failure OR rename-inside-lock failure — unlink the staged
462
+ // tmp so the cache directory does not accumulate orphans.
463
+ try { fs.unlinkSync(tmpPath); } catch {}
464
+ throw lockErr;
465
+ }
419
466
  result.fetched++;
420
467
  result.by_source[item.source].fetched++;
421
468
  log(` [${item.source}] ${item.id} — ok`);
@@ -473,7 +520,7 @@ function readCached(cacheDir, source, id, opts = {}) {
473
520
  const idx = loadIndex(cacheDir);
474
521
  const meta = idx.entries[entryKey(source, id)];
475
522
  if (!meta) return null;
476
- // audit M P2-L: when `fetched_at` is missing / non-string / unparseable,
523
+ // L: when `fetched_at` is missing / non-string / unparseable,
477
524
  // `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false —
478
525
  // so the cached entry would have been returned as if fresh. Treat any
479
526
  // non-finite age as "no provenance, refuse" unless the caller explicitly
@@ -94,6 +94,12 @@ function parseArgs(argv) {
94
94
  else if (a.startsWith("--from-fixture=")) out.fromFixture = a.slice("--from-fixture=".length);
95
95
  else if (a === "--report-out") out.reportOut = argv[++i];
96
96
  else if (a.startsWith("--report-out=")) out.reportOut = a.slice("--report-out=".length);
97
+ // FF P1-3: previously only EXCEPTD_AIR_GAP=1 reached the GHSA/OSV source
98
+ // modules — the CLI flag was undocumented in parseArgs, so a downstream
99
+ // operator following the documented `--air-gap` path silently allowed
100
+ // network calls. Now the flag is honoured; env var still works as a
101
+ // fallback so existing automation isn't broken.
102
+ else if (a === "--air-gap") out.airGap = true;
97
103
  }
98
104
  return out;
99
105
  }
@@ -549,7 +555,7 @@ const GHSA_SOURCE = {
549
555
  return ghsa.buildDiff(ctx);
550
556
  },
551
557
  async applyDiff(ctx, diffs) {
552
- // v0.12.14 (audit B-F1): the prior shape mutated ctx.cveCatalog in
558
+ // v0.12.14: the prior shape mutated ctx.cveCatalog in
553
559
  // memory but NEVER persisted to disk. Bulk `--source ghsa --apply`
554
560
  // reported "applied: N updates" while the catalog file gained zero
555
561
  // entries. Worse under `--swarm`: KEV's withCatalogLock would re-read
@@ -602,7 +608,7 @@ const OSV_SOURCE = {
602
608
  return osv.buildDiff(ctx);
603
609
  },
604
610
  async applyDiff(ctx, diffs) {
605
- // v0.12.14 (audit B-F1): same fix as GHSA — route the read-modify-write
611
+ // v0.12.14: same fix as GHSA — route the read-modify-write
606
612
  // through withCatalogLock so writes actually land on disk and so
607
613
  // concurrent --source osv --apply doesn't lose updates.
608
614
  const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
@@ -846,6 +852,11 @@ function loadCtx(opts) {
846
852
  d3fendCatalog: JSON.parse(fs.readFileSync(ABS("data/d3fend-catalog.json"), "utf8")),
847
853
  fixtures: null,
848
854
  cacheDir: null,
855
+ // FF P1-3: thread --air-gap (or EXCEPTD_AIR_GAP=1) through to ctx.airGap
856
+ // so the GHSA + OSV source modules (lib/source-ghsa.js, lib/source-osv.js)
857
+ // can branch on ctx.airGap and refuse network egress. Pre-fix the GHSA/OSV
858
+ // sources only saw `ctx?.airGap` as undefined when the CLI flag was used.
859
+ airGap: !!(opts && opts.airGap) || process.env.EXCEPTD_AIR_GAP === "1",
849
860
  };
850
861
  if (opts.fromFixture) {
851
862
  ctx.fixtures = { dir: path.resolve(opts.fromFixture), kev: true, epss: true, nvd: true, rfc: true, pins: true, ghsa: true, osv: true };
@@ -938,6 +949,33 @@ async function withCatalogLock(catalogPath, mutator) {
938
949
  // Windows the same race surfaces as EPERM (sharing-violation raised
939
950
  // when the holder is mid-unlink). Treat both as "lock held, back off."
940
951
  if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
952
+ // T P1-1: PID-liveness check before falling back to mtime. The
953
+ // lockfile already contains String(process.pid) of the holder; parse
954
+ // it and probe with `process.kill(pid, 0)`. ESRCH means the holder is
955
+ // dead — reclaim immediately rather than waiting STALE_LOCK_MS for
956
+ // the mtime gate to expire. EPERM (holder alive, different user) is
957
+ // treated as "alive, keep waiting." The mtime gate remains as a
958
+ // belt-and-suspenders for the case where the lockfile content is
959
+ // missing / malformed / belongs to a recycled PID. Matches the PID
960
+ // pattern in orchestrator/index.js _acquireWatchLock and
961
+ // lib/playbook-runner.js pidAlive().
962
+ let reclaimedByPid = false;
963
+ try {
964
+ const raw = fs.readFileSync(lockPath, "utf8").trim();
965
+ const pid = Number.parseInt(raw, 10);
966
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
967
+ try {
968
+ process.kill(pid, 0);
969
+ // holder alive
970
+ } catch (probeErr) {
971
+ if (probeErr && probeErr.code === "ESRCH") {
972
+ try { fs.unlinkSync(lockPath); reclaimedByPid = true; } catch {}
973
+ }
974
+ // EPERM and anything else: treat as alive, fall through to mtime/sleep.
975
+ }
976
+ }
977
+ } catch {} // unreadable lockfile — proceed to mtime fallback
978
+ if (reclaimedByPid) continue;
941
979
  // Stale-lock check before sleeping — a long-dead holder shouldn't keep
942
980
  // us waiting MAX_RETRIES * backoff before we recover.
943
981
  try {
@@ -112,7 +112,7 @@ function getBuffer(url, timeoutMs) {
112
112
  }
113
113
  const chunks = [];
114
114
  let total = 0;
115
- // v0.12.14 (audit F5): enforce streaming size cap so a hostile
115
+ // v0.12.14: enforce streaming size cap so a hostile
116
116
  // registry CDN can't stream gigabytes into RAM.
117
117
  res.on("data", (c) => {
118
118
  total += c.length;
@@ -194,14 +194,63 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
194
194
  // v0.12.14 (audit F1, F7): CRLF/BOM normalization mirrors lib/verify.js's
195
195
  // normalize(). Duplicated here to keep refresh-network free of cross-module
196
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.
197
+ // lib/sign.js + scripts/verify-shipped-tarball.js — the four normalize()
198
+ // implementations form a byte-stability contract enforced by
199
+ // tests/normalize-contract.test.js.
199
200
  function normalizeSkillBytes(buf) {
200
201
  let s = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
201
202
  if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
202
203
  return Buffer.from(s.replace(/\r\n/g, "\n"), "utf8");
203
204
  }
204
205
 
206
+ // B + Q P1: in-line manifest-signature verifier. Kept here
207
+ // rather than imported from lib/verify.js so refresh-network.js retains
208
+ // its no-cross-module-dep posture (mirrors the per-skill verify path).
209
+ // ANY change to canonical-bytes computation here MUST stay in lockstep
210
+ // with lib/sign.js canonicalManifestBytes() / lib/verify.js
211
+ // canonicalManifestBytes() — tests/normalize-contract.test.js enforces.
212
+ function canonicalizeForRefresh(value) {
213
+ if (Array.isArray(value)) return value.map(canonicalizeForRefresh);
214
+ if (value && typeof value === "object") {
215
+ const out = {};
216
+ for (const key of Object.keys(value).sort()) {
217
+ out[key] = canonicalizeForRefresh(value[key]);
218
+ }
219
+ return out;
220
+ }
221
+ return value;
222
+ }
223
+ function canonicalManifestBytesForRefresh(manifest) {
224
+ const clone = Object.assign({}, manifest);
225
+ delete clone.manifest_signature;
226
+ const json = JSON.stringify(canonicalizeForRefresh(clone), null, 2);
227
+ return normalizeSkillBytes(Buffer.from(json, "utf8"));
228
+ }
229
+ function verifyTarballManifestSignature(manifest, publicKeyPem) {
230
+ const sig = manifest && manifest.manifest_signature;
231
+ if (!sig || typeof sig !== "object") return { status: "missing" };
232
+ if (typeof sig.signature_base64 !== "string") {
233
+ return { status: "invalid", reason: "manifest_signature.signature_base64 missing or not a string" };
234
+ }
235
+ if (sig.algorithm !== "Ed25519") {
236
+ return { status: "invalid", reason: `manifest_signature.algorithm must be 'Ed25519' (got ${JSON.stringify(sig.algorithm)})` };
237
+ }
238
+ let signatureBytes;
239
+ try { signatureBytes = Buffer.from(sig.signature_base64, "base64"); }
240
+ catch (e) { return { status: "invalid", reason: `malformed base64: ${e.message}` }; }
241
+ const bytes = canonicalManifestBytesForRefresh(manifest);
242
+ let ok = false;
243
+ try {
244
+ ok = crypto.verify(null, bytes, {
245
+ key: publicKeyPem,
246
+ dsaEncoding: "ieee-p1363",
247
+ }, signatureBytes);
248
+ } catch (e) {
249
+ return { status: "invalid", reason: `crypto.verify threw: ${e.message}` };
250
+ }
251
+ return ok ? { status: "valid" } : { status: "invalid", reason: "Ed25519 manifest signature did not verify against local public.pem" };
252
+ }
253
+
205
254
  // Manifest path validation. Mirrors lib/verify.js validateSkillPath().
206
255
  function validateManifestSkillPath(skillPath) {
207
256
  if (typeof skillPath !== "string") throw new Error(`manifest skill.path must be a string, got ${typeof skillPath}`);
@@ -211,7 +260,7 @@ function validateManifestSkillPath(skillPath) {
211
260
  return skillPath;
212
261
  }
213
262
 
214
- // v0.12.14 (audit F5): tarball download size cap. A hostile registry CDN
263
+ // v0.12.14: tarball download size cap. A hostile registry CDN
215
264
  // could stream gigabytes; Node buffers chunks in RAM until OOM. Current
216
265
  // tarball is ~2 MB; 200 MB is generous defense-in-depth. Tunable via
217
266
  // EXCEPTD_TARBALL_SIZE_CAP_BYTES for future growth.
@@ -286,7 +335,7 @@ async function main() {
286
335
  process.exitCode = 2; return;
287
336
  }
288
337
 
289
- // v0.12.14 (audit F5): defense-in-depth tarball size cap.
338
+ // v0.12.14: defense-in-depth tarball size cap.
290
339
  const sizeCap = tarballSizeCap();
291
340
  if (tgzBuf.length > sizeCap) {
292
341
  emit({ ok: false, error: `tarball exceeds size cap: ${tgzBuf.length} bytes > ${sizeCap} (EXCEPTD_TARBALL_SIZE_CAP_BYTES)` }, opts.json);
@@ -357,7 +406,7 @@ async function main() {
357
406
  process.exitCode = 5; return;
358
407
  }
359
408
 
360
- // v0.12.16 (audit I P1-5): cross-check the local public key against
409
+ // v0.12.16: cross-check the local public key against
361
410
  // keys/EXPECTED_FINGERPRINT (the CI-pinned signing key). The prior
362
411
  // refresh-network code only compared LOCAL ↔ TARBALL fingerprints, so a
363
412
  // coordinated attacker who swapped both `keys/public.pem` on the operator's
@@ -402,7 +451,34 @@ async function main() {
402
451
  try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
403
452
  catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
404
453
 
405
- // v0.12.14 (audit F1): the prior loop iterated `sk.id` + a fixed payload
454
+ // B + Q P1: verify the top-level manifest_signature against
455
+ // the LOCAL public key before honoring any entry in the tarball manifest.
456
+ // The previous flow iterated `manifest.skills[].signature` per-skill but
457
+ // never authenticated the manifest envelope itself — a coordinated
458
+ // attacker who flipped paths/names/atlas_refs on entries already covered
459
+ // by per-skill signatures (which sign only the skill body bytes, not the
460
+ // metadata around them) could re-shape catalog routing without breaking
461
+ // any per-skill signature. The manifest signature closes that gap.
462
+ //
463
+ // Unlike post-install verify (which warns-and-continues on missing
464
+ // signature for legacy-tarball compat), refresh-network REQUIRES the
465
+ // signature: this code path is publishing fresh content into the local
466
+ // tree, and the tarball must already be ≥ v0.12.17 to have reached the
467
+ // registry through the sign-all gate.
468
+ const manifestSigResult = verifyTarballManifestSignature(tarballManifest, localPubKeyText);
469
+ if (manifestSigResult.status !== "valid") {
470
+ emit({
471
+ ok: false,
472
+ error: `tarball manifest_signature ${manifestSigResult.status} — refusing to swap`,
473
+ reason: manifestSigResult.reason || null,
474
+ hint: manifestSigResult.status === "missing"
475
+ ? "Tarball predates v0.12.17 manifest signing. Run `npm update -g @blamejs/exceptd-skills` instead so the full provenance-verified install path runs."
476
+ : "Tarball manifest envelope failed Ed25519 verification against the LOCAL public key. Run `npm update -g @blamejs/exceptd-skills` for the full provenance-verified path, or report this tarball at https://github.com/blamejs/exceptd-skills/issues.",
477
+ }, opts.json);
478
+ process.exitCode = 5; return;
479
+ }
480
+
481
+ // v0.12.14: the prior loop iterated `sk.id` + a fixed payload
406
482
  // path `skills/<id>/SKILL.md`. Manifest entries actually expose `name` +
407
483
  // `path` (a forward-slash relative path like `skills/<name>/skill.md`,
408
484
  // lowercase). Result: the loop matched zero entries; `failures.length === 0`
@@ -458,7 +534,7 @@ async function main() {
458
534
  process.exitCode = 5; return;
459
535
  }
460
536
 
461
- // v0.12.14 (audit F2): the swap loop replaces `data/` + `manifest.json` +
537
+ // v0.12.14: the swap loop replaces `data/` + `manifest.json` +
462
538
  // `manifest-snapshot.json` in addition to `skills/`. None of those files
463
539
  // are covered by the per-skill Ed25519 signature (which signs only the
464
540
  // skill body bytes). The only integrity check between the registry and
@@ -499,7 +575,7 @@ async function main() {
499
575
  return;
500
576
  }
501
577
 
502
- // v0.12.14 (audit F4): the prior swap loop renamed targets one-by-one,
578
+ // v0.12.14: the prior swap loop renamed targets one-by-one,
503
579
  // and a mid-loop failure left the install half-applied with no automatic
504
580
  // rollback. New shape: rename all old targets into a single backup dir
505
581
  // first (so the install is empty-of-old before any new content is moved
@@ -523,7 +599,7 @@ async function main() {
523
599
  written++;
524
600
  }
525
601
 
526
- // v0.12.14 (audit F10): use PID + random suffix in the backup dir name
602
+ // v0.12.14: use PID + random suffix in the backup dir name
527
603
  // so concurrent refresh-network invocations don't collide on the
528
604
  // millisecond clock.
529
605
  const backupSuffix = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
@@ -564,7 +640,7 @@ async function main() {
564
640
  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.`,
565
641
  }, opts.json);
566
642
  } catch (e) {
567
- // v0.12.14 (audit F4): walk completedSteps in reverse to undo partial work.
643
+ // v0.12.14: walk completedSteps in reverse to undo partial work.
568
644
  const rollbackErrors = [];
569
645
  for (const step of [...completedSteps].reverse()) {
570
646
  try {
@@ -603,4 +679,16 @@ if (require.main === module) {
603
679
  });
604
680
  }
605
681
 
606
- module.exports = { parseTar, fingerprintPublicKey };
682
+ module.exports = {
683
+ parseTar,
684
+ fingerprintPublicKey,
685
+ // A: exported for tests/normalize-contract.test.js so the
686
+ // byte-stability contract can be asserted across all four normalize()
687
+ // implementations (lib/sign.js, lib/verify.js, lib/refresh-network.js,
688
+ // scripts/verify-shipped-tarball.js).
689
+ normalizeSkillBytes,
690
+ // B + Q P1: exported for in-process tests of the refresh
691
+ // path's manifest envelope check.
692
+ verifyTarballManifestSignature,
693
+ canonicalManifestBytesForRefresh,
694
+ };
package/lib/scoring.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * Supplements CVSS with exploit availability, active exploitation, and operational constraints.
6
6
  *
7
7
  * ----------------------------------------------------------------------------
8
- * `rwep_factors` dual-semantics (audit J F2)
8
+ * `rwep_factors` dual-semantics
9
9
  * ----------------------------------------------------------------------------
10
10
  * Catalog entries (data/cve-catalog.json) store `rwep_factors` as an object
11
11
  * whose values are POST-WEIGHT CONTRIBUTIONS for boolean / ladder factors
@@ -62,7 +62,7 @@ const RWEP_WEIGHTS = {
62
62
  reboot_required: 5
63
63
  };
64
64
 
65
- // audit J F4: active_exploitation ladder. Aligned with playbook-runner's
65
+ // active_exploitation ladder. Aligned with playbook-runner's
66
66
  // _activeExploitationLadder so the catalog scorer and the runtime evaluator
67
67
  // produce identical results for the same string value. 'unknown' contributes
68
68
  // a quarter of the confirmed weight (5 points) — operationally "we have not
@@ -76,7 +76,7 @@ const ACTIVE_EXPLOITATION_LADDER = {
76
76
  };
77
77
 
78
78
  // The canonical set of factor keys scoreCustom recognises. Used by
79
- // validateFactors to flag unknown keys (audit J F8).
79
+ // validateFactors to flag unknown keys.
80
80
  const RECOGNISED_FACTOR_KEYS = new Set([
81
81
  'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
82
82
  'active_exploitation', 'blast_radius', 'patch_available',
@@ -125,7 +125,7 @@ function validateFactors(factors) {
125
125
  } else if (!aeAllowed.includes(factors.active_exploitation)) {
126
126
  warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
127
127
  }
128
- // audit J F6: NaN diagnostics. The prior message read "expected number,
128
+ // NaN diagnostics. The prior message read "expected number,
129
129
  // got number (null)" because `JSON.stringify(NaN) === 'null'` and `typeof
130
130
  // NaN === 'number'`. Number.isFinite catches NaN + Infinity + -Infinity
131
131
  // and emits a useful message.
@@ -140,7 +140,7 @@ function validateFactors(factors) {
140
140
  } else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
141
141
  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)`);
142
142
  }
143
- // audit J F8: surface unknown factor keys so a typo'd answer file
143
+ // surface unknown factor keys so a typo'd answer file
144
144
  // (`patch_avilable`, `cisa-kev`, etc.) doesn't silently default to false
145
145
  // with no diagnostic.
146
146
  for (const k of Object.keys(factors)) {
@@ -173,7 +173,7 @@ function scoreCustom(factors, opts) {
173
173
  patch_available = false,
174
174
  live_patch_available = false,
175
175
  reboot_required = false,
176
- // v0.12.15 (audit J F9): the CVE catalog field is `patch_required_reboot`
176
+ // v0.12.15: the CVE catalog field is `patch_required_reboot`
177
177
  // but scoreCustom historically expected `reboot_required`. validate()
178
178
  // already aliases at the call site; accept either spelling here so a
179
179
  // direct caller passing the catalog entry doesn't silently lose the
@@ -186,7 +186,7 @@ function scoreCustom(factors, opts) {
186
186
  score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
187
187
  score += poc_available ? RWEP_WEIGHTS.poc_available : 0;
188
188
  score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
189
- // audit J F4 + F16: active_exploitation goes through the ladder rather
189
+ // active_exploitation goes through the ladder rather
190
190
  // than two hand-written branches with `Math.floor(weight/2)`. The floor
191
191
  // was a no-op for even weights (20/2 = 10) but would have silently
192
192
  // truncated to asymmetric results if a future operator bumped the
@@ -195,7 +195,7 @@ function scoreCustom(factors, opts) {
195
195
  // aligns the catalog scorer with playbook-runner._activeExploitationLadder.
196
196
  const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[active_exploitation] ?? 0;
197
197
  score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
198
- // v0.12.15 (audit J F1, F5): blast_radius numeric coercion must reject
198
+ // v0.12.15: blast_radius numeric coercion must reject
199
199
  // NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
200
200
  // check passed NaN (which is `typeof === 'number'`) into `Math.min/max`
201
201
  // which propagates NaN through the final clamp, defeating the [0,100]
@@ -208,12 +208,12 @@ function scoreCustom(factors, opts) {
208
208
  score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
209
209
  score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
210
210
 
211
- // audit J F10: keep the pre-clamp value so collectWarnings consumers can
211
+ // keep the pre-clamp value so collectWarnings consumers can
212
212
  // see deduction magnitude (e.g. a -25 raw score collapsed to 0 hides the
213
213
  // fact that the entry had three mitigating factors).
214
214
  const rawUnclamped = score;
215
215
 
216
- // v0.12.15 (audit J F1): defense-in-depth clamp against any unforeseen
216
+ // v0.12.15: defense-in-depth clamp against any unforeseen
217
217
  // NaN production above (negative weight + Infinity + math edge case).
218
218
  const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
219
219
  if (opts && opts.collectWarnings) {
@@ -227,7 +227,7 @@ function scoreCustom(factors, opts) {
227
227
  }
228
228
 
229
229
  /**
230
- * audit J F3 + audit M P1-C bridge: derive an RWEP score from a
230
+ * Derive an RWEP score from a
231
231
  * `rwep_factors` object regardless of which shape it uses.
232
232
  *
233
233
  * - SHAPE A (boolean / string-ladder): values are booleans + an
@@ -275,7 +275,7 @@ function compare(cveId, catalog, opts) {
275
275
  const entry = catalog[cveId];
276
276
  if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
277
277
 
278
- // audit J F11: `--recompute` ignores the stored rwep_score and forces a
278
+ // `--recompute` ignores the stored rwep_score and forces a
279
279
  // fresh computation from rwep_factors. Useful for catching catalog drift
280
280
  // (stored score grew stale relative to current weights) and for auditing
281
281
  // the divergence between stored vs. formula-derived scores.
@@ -294,7 +294,7 @@ function compare(cveId, catalog, opts) {
294
294
  const cvssEquivalent = cvss * 10;
295
295
  const delta = rwep - cvssEquivalent;
296
296
 
297
- // audit J F15: narrow the "broadly aligned" band from ±20 to ±10. The old
297
+ // narrow the "broadly aligned" band from ±20 to ±10. The old
298
298
  // ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
299
299
  // where the operator-facing point is precisely that the CVSS-calibrated
300
300
  // SLA is insufficient. ±10 is the tightest classifier that still treats
@@ -342,6 +342,15 @@ function validate(catalog) {
342
342
  const errors = [];
343
343
  for (const [cveId, entry] of Object.entries(catalog)) {
344
344
  if (cveId.startsWith('_')) continue;
345
+ // FF P1-1: skip auto-imported drafts. KEV/GHSA/OSV-discovered drafts
346
+ // store a conservative-default rwep_score (poc=true, reboot=true, etc.)
347
+ // alongside `poc_available: null` and other null-until-curated factor
348
+ // fields, so the recomputed-vs-stored divergence check ALWAYS fires
349
+ // against them — flooding the predeploy gate. Drafts are reviewed
350
+ // separately via the `_auto_imported_meta.curation_needed` list and the
351
+ // strict catalog validator's draft-warning tier. Once curation promotes
352
+ // an entry, `_auto_imported` is cleared and full validation resumes.
353
+ if (entry && entry._auto_imported === true) continue;
345
354
  for (const field of CVE_SCHEMA_REQUIRED) {
346
355
  if (!(field in entry)) {
347
356
  errors.push(`${cveId}: missing required field '${field}'`);
package/lib/sign.js CHANGED
@@ -112,7 +112,7 @@ function generateKeypair({ rotate = false } = {}) {
112
112
  fs.writeFileSync(PRIVATE_KEY_PATH, privateKey, { encoding: 'utf8', mode: 0o600 });
113
113
  fs.writeFileSync(PUBLIC_KEY_PATH, publicKey, { encoding: 'utf8', mode: 0o644 });
114
114
 
115
- // Audit I P1-3: on win32, fs.writeFileSync `mode` does not produce
115
+ // on win32, fs.writeFileSync `mode` does not produce
116
116
  // a POSIX-style restrictive ACL. Tighten via icacls so other desktop
117
117
  // users on the same workstation / CI runner can't read the key.
118
118
  restrictWindowsAcl(PRIVATE_KEY_PATH);
@@ -166,7 +166,7 @@ function signAll() {
166
166
  signed++;
167
167
  }
168
168
 
169
- // Audit I P1-4: sign the manifest itself. Removes any existing
169
+ // sign the manifest itself. Removes any existing
170
170
  // manifest_signature field so the canonical bytes are deterministic
171
171
  // across re-runs, signs with the private key, then writes the result.
172
172
  // A coordinated attacker who rewrites the manifest (and snapshot, and
@@ -298,7 +298,7 @@ function loadManifest() {
298
298
  }
299
299
 
300
300
  /**
301
- * Audit I P1-4 — canonical byte form of the manifest, used for both
301
+ * canonical byte form of the manifest, used for both
302
302
  * signing (lib/sign.js) and verification (lib/verify.js).
303
303
  *
304
304
  * Contract: the same logical manifest content must produce the same bytes
@@ -343,9 +343,18 @@ function canonicalManifestBytes(manifest) {
343
343
  * Returns the manifest_signature object literal to splice into the
344
344
  * manifest top level.
345
345
  *
346
+ * A: the previous shape included a `signed_at` ISO timestamp.
347
+ * That field was stripped from the canonical bytes before signing (via
348
+ * `delete clone.manifest_signature`), so it was NOT covered by the
349
+ * signature — an attacker who replayed a known-valid signature could
350
+ * rewrite `signed_at` to any value, lending false freshness authority to
351
+ * a stale signature. The field is now omitted entirely. Freshness signal
352
+ * lives outside the signed bytes (git-log mtime of manifest.json, npm
353
+ * publish timestamp).
354
+ *
346
355
  * @param {object} manifest
347
356
  * @param {string} privateKey PEM-encoded Ed25519 private key
348
- * @returns {{algorithm:'Ed25519', signature_base64:string, signed_at:string}}
357
+ * @returns {{algorithm:'Ed25519', signature_base64:string}}
349
358
  */
350
359
  function signCanonicalManifest(manifest, privateKey) {
351
360
  const bytes = canonicalManifestBytes(manifest);
@@ -356,12 +365,11 @@ function signCanonicalManifest(manifest, privateKey) {
356
365
  return {
357
366
  algorithm: 'Ed25519',
358
367
  signature_base64: sig.toString('base64'),
359
- signed_at: new Date().toISOString(),
360
368
  };
361
369
  }
362
370
 
363
371
  /**
364
- * Audit I P1-3 — tighten Windows ACL on the private key.
372
+ * tighten Windows ACL on the private key.
365
373
  *
366
374
  * fs.writeFileSync({mode: 0o600}) on win32 only affects read-only
367
375
  * attributes; the file inherits its ACL from the parent. icacls strips
@@ -163,7 +163,7 @@ function validateMeta(catalogPath, opts) {
163
163
  }
164
164
  }
165
165
 
166
- /* Audit G F3 — freshness enforcement. When both meta.last_updated and
166
+ /* freshness enforcement. When both meta.last_updated and
167
167
  * freshness_policy.stale_after_days are present, surface a warning if
168
168
  * (now - last_updated) > stale_after_days. Patch-class release emits at
169
169
  * WARN level (does not fail validation); v0.13.0 will flip to an error.
@@ -47,7 +47,7 @@ function main() {
47
47
  const meta = JSON.parse(fs.readFileSync(META, "utf8"));
48
48
  const recorded = meta.source_hashes || {};
49
49
 
50
- // Audit G F1 — reject an empty source_hashes table outright. The previous
50
+ // reject an empty source_hashes table outright. The previous
51
51
  // gate would silently pass when source_hashes was {} (or missing entirely)
52
52
  // because the for-loop body never executed; the resulting "0 sources" pass
53
53
  // banner falsely advertised the indexes as current. An empty source-hash
@@ -67,7 +67,7 @@ function main() {
67
67
  const manifest = JSON.parse(fs.readFileSync(ABS("manifest.json"), "utf8"));
68
68
  const liveSources = new Set();
69
69
  liveSources.add("manifest.json");
70
- // Audit G F16 — use lstat to detect symlinks. A symlinked .json under data/
70
+ // use lstat to detect symlinks. A symlinked .json under data/
71
71
  // would be hashed via the followed target, allowing a malicious checkout
72
72
  // (or a misconfigured filesystem) to swap data origin without tripping the
73
73
  // gate. Reject symlinks outright.