@blamejs/exceptd-skills 0.12.13 → 0.12.16

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 (101) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/bin/exceptd.js +522 -27
  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 +516 -476
  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/playbooks/ai-api.json +3 -1
  19. package/data/playbooks/containers.json +11 -3
  20. package/data/playbooks/cred-stores.json +3 -1
  21. package/data/playbooks/crypto-codebase.json +11 -11
  22. package/data/playbooks/crypto.json +1 -1
  23. package/data/playbooks/hardening.json +3 -1
  24. package/data/playbooks/kernel.json +3 -1
  25. package/data/playbooks/library-author.json +21 -10
  26. package/data/playbooks/mcp.json +1 -1
  27. package/data/playbooks/runtime.json +3 -1
  28. package/data/playbooks/sbom.json +2 -2
  29. package/data/playbooks/secrets.json +3 -1
  30. package/data/rfc-references.json +276 -276
  31. package/keys/EXPECTED_FINGERPRINT +1 -0
  32. package/lib/auto-discovery.js +57 -35
  33. package/lib/cross-ref-api.js +39 -6
  34. package/lib/cve-curation.js +33 -14
  35. package/lib/lint-skills.js +6 -1
  36. package/lib/playbook-runner.js +742 -78
  37. package/lib/prefetch.js +30 -8
  38. package/lib/refresh-external.js +40 -22
  39. package/lib/refresh-network.js +233 -17
  40. package/lib/scoring.js +191 -18
  41. package/lib/source-ghsa.js +219 -37
  42. package/lib/source-osv.js +381 -122
  43. package/lib/validate-catalog-meta.js +64 -9
  44. package/lib/validate-cve-catalog.js +56 -18
  45. package/lib/validate-indexes.js +88 -37
  46. package/lib/validate-playbooks.js +46 -0
  47. package/lib/verify.js +72 -0
  48. package/manifest-snapshot.json +1 -1
  49. package/manifest-snapshot.sha256 +1 -0
  50. package/manifest.json +73 -73
  51. package/orchestrator/dispatcher.js +21 -1
  52. package/orchestrator/event-bus.js +52 -8
  53. package/orchestrator/index.js +279 -20
  54. package/orchestrator/pipeline.js +63 -2
  55. package/orchestrator/scanner.js +32 -10
  56. package/orchestrator/scheduler.js +150 -17
  57. package/package.json +3 -1
  58. package/sbom.cdx.json +7 -7
  59. package/scripts/check-manifest-snapshot.js +32 -0
  60. package/scripts/check-sbom-currency.js +65 -3
  61. package/scripts/check-test-coverage.js +142 -19
  62. package/scripts/predeploy.js +83 -39
  63. package/scripts/refresh-manifest-snapshot.js +55 -4
  64. package/scripts/validate-vendor-online.js +169 -0
  65. package/scripts/verify-shipped-tarball.js +141 -9
  66. package/skills/ai-attack-surface/skill.md +18 -10
  67. package/skills/ai-c2-detection/skill.md +7 -2
  68. package/skills/ai-risk-management/skill.md +5 -4
  69. package/skills/api-security/skill.md +3 -3
  70. package/skills/attack-surface-pentest/skill.md +5 -5
  71. package/skills/cloud-security/skill.md +1 -1
  72. package/skills/compliance-theater/skill.md +8 -8
  73. package/skills/container-runtime-security/skill.md +1 -1
  74. package/skills/dlp-gap-analysis/skill.md +5 -1
  75. package/skills/email-security-anti-phishing/skill.md +1 -1
  76. package/skills/exploit-scoring/skill.md +18 -18
  77. package/skills/framework-gap-analysis/skill.md +6 -6
  78. package/skills/global-grc/skill.md +3 -2
  79. package/skills/identity-assurance/skill.md +2 -2
  80. package/skills/incident-response-playbook/skill.md +4 -4
  81. package/skills/kernel-lpe-triage/skill.md +21 -2
  82. package/skills/mcp-agent-trust/skill.md +17 -10
  83. package/skills/mlops-security/skill.md +2 -1
  84. package/skills/ot-ics-security/skill.md +1 -1
  85. package/skills/policy-exception-gen/skill.md +3 -3
  86. package/skills/pqc-first/skill.md +1 -1
  87. package/skills/rag-pipeline-security/skill.md +7 -3
  88. package/skills/researcher/skill.md +20 -3
  89. package/skills/sector-energy/skill.md +1 -1
  90. package/skills/sector-federal-government/skill.md +1 -1
  91. package/skills/sector-financial/skill.md +3 -3
  92. package/skills/sector-healthcare/skill.md +2 -2
  93. package/skills/security-maturity-tiers/skill.md +7 -7
  94. package/skills/skill-update-loop/skill.md +19 -3
  95. package/skills/supply-chain-integrity/skill.md +1 -1
  96. package/skills/threat-model-currency/skill.md +11 -11
  97. package/skills/threat-modeling-methodology/skill.md +3 -3
  98. package/skills/webapp-security/skill.md +1 -1
  99. package/skills/zeroday-gap-learn/skill.md +51 -7
  100. package/vendor/blamejs/_PROVENANCE.json +4 -1
  101. package/vendor/blamejs/worker-pool.js +38 -0
package/lib/prefetch.js CHANGED
@@ -15,8 +15,14 @@
15
15
  * kev/known_exploited_vulnerabilities.json — full KEV feed
16
16
  * nvd/<cve-id>.json — NVD 2.0 per-CVE response
17
17
  * epss/<cve-id>.json — EPSS per-CVE response
18
- * ietf/<doc-name>.json — IETF Datatracker doc record
19
- * github/<owner>__<repo>__releases.json — releases listing
18
+ * rfc/<doc-name>.json — IETF Datatracker doc record
19
+ * pins/<owner>__<repo>__releases.json MITRE GitHub releases listing
20
+ *
21
+ * audit M P2-K: the registered source names in SOURCES below are `rfc` and
22
+ * `pins`. Earlier comments + --help text said `ietf` and `github`; an
23
+ * operator running `--source ietf` or `--source github` would hit "unknown
24
+ * source" because no such key exists. The names below are the canonical
25
+ * ones consumed by --source filtering.
20
26
  *
21
27
  * Usage:
22
28
  * node lib/prefetch.js # fetch everything not fresh
@@ -137,8 +143,8 @@ Sources:
137
143
  kev CISA Known Exploited Vulnerabilities
138
144
  nvd NIST NVD 2.0 per-CVE
139
145
  epss FIRST EPSS per-CVE
140
- ietf IETF Datatracker per-RFC
141
- github MITRE GitHub releases (ATLAS / ATT&CK / D3FEND / CWE)
146
+ rfc IETF Datatracker per-RFC
147
+ pins MITRE GitHub releases (ATLAS / ATT&CK)
142
148
 
143
149
  Options:
144
150
  --max-age <dur> skip entries fresher than this (e.g. 12h, 1d). Default: 24h.
@@ -296,7 +302,15 @@ function isFresh(idx, source, id, maxAgeMs) {
296
302
 
297
303
  function authHeadersForSource(source) {
298
304
  if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
299
- if (source === "github" && process.env.GITHUB_TOKEN) return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
305
+ // audit M P2-J: the registered source name for MITRE GitHub releases is
306
+ // `pins` (see SOURCES above). The prior check looked for `github`, so
307
+ // GITHUB_TOKEN never reached the per-request Authorization header and
308
+ // anonymous-rate-limited fetches were always used even when an operator
309
+ // had supplied a token. Accept both spellings so this is forgiving of
310
+ // the historical naming and the registered name.
311
+ if ((source === "pins" || source === "github") && process.env.GITHUB_TOKEN) {
312
+ return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
313
+ }
300
314
  return {};
301
315
  }
302
316
 
@@ -459,13 +473,21 @@ function readCached(cacheDir, source, id, opts = {}) {
459
473
  const idx = loadIndex(cacheDir);
460
474
  const meta = idx.entries[entryKey(source, id)];
461
475
  if (!meta) return null;
462
- const ageMs = Date.now() - new Date(meta.fetched_at).getTime();
463
- if (!opts.allowStale && ageMs > maxAgeMs) return null;
476
+ // audit M P2-L: when `fetched_at` is missing / non-string / unparseable,
477
+ // `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false —
478
+ // so the cached entry would have been returned as if fresh. Treat any
479
+ // non-finite age as "no provenance, refuse" unless the caller explicitly
480
+ // opted into allowStale.
481
+ const ageMs = meta.fetched_at ? Date.now() - new Date(meta.fetched_at).getTime() : NaN;
482
+ if (!opts.allowStale) {
483
+ if (!meta.fetched_at || !Number.isFinite(ageMs)) return null;
484
+ if (ageMs > maxAgeMs) return null;
485
+ }
464
486
  const p = entryPath(cacheDir, source, id);
465
487
  if (!fs.existsSync(p)) return null;
466
488
  try {
467
489
  const data = JSON.parse(fs.readFileSync(p, "utf8"));
468
- return { data, age_ms: ageMs, meta };
490
+ return { data, age_ms: Number.isFinite(ageMs) ? ageMs : null, meta };
469
491
  } catch {
470
492
  return null;
471
493
  }
@@ -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");
@@ -285,27 +357,96 @@ async function main() {
285
357
  process.exitCode = 5; return;
286
358
  }
287
359
 
360
+ // v0.12.16 (audit I P1-5): cross-check the local public key against
361
+ // keys/EXPECTED_FINGERPRINT (the CI-pinned signing key). The prior
362
+ // refresh-network code only compared LOCAL ↔ TARBALL fingerprints, so a
363
+ // coordinated attacker who swapped both `keys/public.pem` on the operator's
364
+ // host AND the registry tarball passed every check — fingerprints match
365
+ // each other but match the attacker's key. The pin in EXPECTED_FINGERPRINT
366
+ // is the external trust anchor that closes this gap.
367
+ //
368
+ // Honors `KEYS_ROTATED=1` env to allow legitimate key rotation without
369
+ // re-bootstrap. Missing EXPECTED_FINGERPRINT file → warn-and-continue
370
+ // (don't break existing installs whose tree predates the pin file).
371
+ const expectedFingerprintPath = path.join(ROOT, "keys", "EXPECTED_FINGERPRINT");
372
+ if (fs.existsSync(expectedFingerprintPath) && !process.env.KEYS_ROTATED) {
373
+ try {
374
+ const expectedFp = fs.readFileSync(expectedFingerprintPath, "utf8")
375
+ .split(/\r?\n/).map(l => l.trim()).find(l => l.length > 0);
376
+ // v0.12.16 (codex P1 PR #11): `expectedFp` is read verbatim from
377
+ // keys/EXPECTED_FINGERPRINT (formatted as `SHA256:<base64>`), but
378
+ // `fingerprintPublicKey()` returns the raw base64 without the
379
+ // `SHA256:` prefix. Comparing the two raw strings would refuse every
380
+ // legitimate run unless KEYS_ROTATED=1 was set. Normalize by stripping
381
+ // the prefix from the pin file before compare. lib/verify.js's
382
+ // checkExpectedFingerprint() does the symmetric thing (adds the
383
+ // prefix to localFp); either side works as long as one is canonical.
384
+ const expectedFpBase64 = expectedFp && expectedFp.startsWith("SHA256:")
385
+ ? expectedFp.slice("SHA256:".length)
386
+ : expectedFp;
387
+ if (expectedFpBase64 && expectedFpBase64 !== localFp) {
388
+ emit({
389
+ ok: false,
390
+ error: `local keys/public.pem fingerprint diverges from keys/EXPECTED_FINGERPRINT pin`,
391
+ local_fingerprint: "SHA256:" + localFp,
392
+ pinned_fingerprint: expectedFp,
393
+ hint: "Either keys/public.pem was rotated since the pin was set (rerun `npm run bootstrap` to re-pin), or the local public.pem was tampered with. Set KEYS_ROTATED=1 to bypass once. Refusing to swap on --network.",
394
+ }, opts.json);
395
+ process.exitCode = 5; return;
396
+ }
397
+ } catch { /* unreadable pin file = warn-and-continue */ }
398
+ }
399
+
288
400
  // Verify every signed entry in the tarball manifest using the local key.
289
401
  let tarballManifest;
290
402
  try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
291
403
  catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
292
404
 
405
+ // v0.12.14 (audit F1): the prior loop iterated `sk.id` + a fixed payload
406
+ // path `skills/<id>/SKILL.md`. Manifest entries actually expose `name` +
407
+ // `path` (a forward-slash relative path like `skills/<name>/skill.md`,
408
+ // lowercase). Result: the loop matched zero entries; `failures.length === 0`
409
+ // and `verifiedCount === 0` and the swap proceeded with `ok: true`. Every
410
+ // operator running `exceptd refresh --network` installed unverified bytes.
411
+ //
412
+ // Fixed shape mirrors lib/verify.js: iterate `manifest.skills[]` by
413
+ // `name` + `path` + `signature`. Apply the same CRLF/BOM normalization
414
+ // before verify (lib/verify.js normalize() — duplicated here to keep
415
+ // this path free of cross-module runtime deps). validateSkillPath()
416
+ // is also mirrored to defend against path traversal in a tampered
417
+ // tarball manifest before we resolve the path against the extracted
418
+ // tree.
293
419
  const localKeyObj = crypto.createPublicKey(localPubKeyText);
294
420
  const skills = Array.isArray(tarballManifest.skills) ? tarballManifest.skills : [];
295
421
  const failures = [];
296
422
  let verifiedCount = 0;
297
423
  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);
424
+ if (!sk || typeof sk.name !== "string" || typeof sk.signature !== "string") {
425
+ failures.push({ name: sk?.name || "(missing name)", reason: "manifest entry missing name or signature" });
426
+ continue;
427
+ }
428
+ let normalizedPath;
429
+ try { normalizedPath = validateManifestSkillPath(sk.path); }
430
+ catch (e) { failures.push({ name: sk.name, reason: `manifest path rejected: ${e.message}` }); continue; }
431
+ const payloadEntry = entries.find((e) => stripPkg(e.name) === normalizedPath);
432
+ if (!payloadEntry) { failures.push({ name: sk.name, reason: `payload missing from tarball: ${normalizedPath}` }); continue; }
433
+ const normalized = normalizeSkillBytes(payloadEntry.body);
434
+ const ok = verifyDetached(localKeyObj, normalized, sk.signature);
304
435
  if (ok) verifiedCount++;
305
- else failures.push({ id: sk.id, reason: "signature did not verify against local public key" });
436
+ else failures.push({ name: sk.name, reason: "Ed25519 signature did not verify against local public key" });
306
437
  }
307
438
 
308
- if (failures.length > 0) {
439
+ if (skills.length === 0) {
440
+ emit({
441
+ ok: false,
442
+ error: "tarball manifest.json declares zero skills — refusing to swap",
443
+ verified: 0,
444
+ total: 0,
445
+ hint: "A legitimate tarball must declare at least one skill. Treat this as a tarball-integrity failure.",
446
+ }, opts.json);
447
+ process.exitCode = 5; return;
448
+ }
449
+ if (verifiedCount !== skills.length || failures.length > 0) {
309
450
  emit({
310
451
  ok: false,
311
452
  error: `${failures.length}/${skills.length} skill signature(s) failed verification — refusing to swap`,
@@ -317,6 +458,34 @@ async function main() {
317
458
  process.exitCode = 5; return;
318
459
  }
319
460
 
461
+ // v0.12.14 (audit F2): the swap loop replaces `data/` + `manifest.json` +
462
+ // `manifest-snapshot.json` in addition to `skills/`. None of those files
463
+ // are covered by the per-skill Ed25519 signature (which signs only the
464
+ // skill body bytes). The only integrity check between the registry and
465
+ // those bytes is SHA-1 dist.shasum — collision-broken since 2017 and
466
+ // weaker than `npm install` itself which honors dist.integrity (SHA-512
467
+ // SRI) + dist.signatures (npm Ed25519 registry key) + dist.attestations
468
+ // (sigstore SLSA provenance).
469
+ //
470
+ // Defense-in-depth: refuse the swap if the manifest skills list doesn't
471
+ // exactly match the skill payload entries present in the tarball. A
472
+ // malicious tarball that drops/adds a skill outside the manifest no
473
+ // longer slips through.
474
+ const manifestSkillPaths = new Set(skills.map(s => validateManifestSkillPath(s.path)));
475
+ const tarballSkillPayloads = entries
476
+ .map(e => stripPkg(e.name))
477
+ .filter(name => /^skills\/[^/]+\/skill\.md$/.test(name));
478
+ for (const tp of tarballSkillPayloads) {
479
+ if (!manifestSkillPaths.has(tp)) {
480
+ emit({
481
+ ok: false,
482
+ error: `tarball ships skill payload not declared in manifest: ${tp} — refusing to swap`,
483
+ hint: "Tarball+manifest divergence. Report at https://github.com/blamejs/exceptd-skills/issues.",
484
+ }, opts.json);
485
+ process.exitCode = 5; return;
486
+ }
487
+ }
488
+
320
489
  if (opts.dryRun) {
321
490
  emit({
322
491
  ok: true,
@@ -330,9 +499,16 @@ async function main() {
330
499
  return;
331
500
  }
332
501
 
333
- // Atomic swap: stage to a tmp dir under the install, then rename.
502
+ // v0.12.14 (audit F4): the prior swap loop renamed targets one-by-one,
503
+ // and a mid-loop failure left the install half-applied with no automatic
504
+ // rollback. New shape: rename all old targets into a single backup dir
505
+ // first (so the install is empty-of-old before any new content is moved
506
+ // in); then rename all new targets in; on failure, walk the backup dir
507
+ // in reverse and restore.
334
508
  const stageDir = fs.mkdtempSync(path.join(ROOT, ".refresh-network-"));
335
509
  let written = 0;
510
+ let backupDir = null;
511
+ const completedSteps = []; // [{kind: 'backup' | 'install', target}]
336
512
  try {
337
513
  for (const entry of entries) {
338
514
  const rel = stripPkg(entry.name);
@@ -347,19 +523,32 @@ async function main() {
347
523
  written++;
348
524
  }
349
525
 
350
- // Replace targets.
351
- const replaceList = ["data", "skills", "manifest.json", "manifest-snapshot.json"];
352
- const backupDir = path.join(ROOT, `.refresh-network-backup-${Date.now()}`);
526
+ // v0.12.14 (audit F10): use PID + random suffix in the backup dir name
527
+ // so concurrent refresh-network invocations don't collide on the
528
+ // millisecond clock.
529
+ const backupSuffix = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
530
+ backupDir = path.join(ROOT, `.refresh-network-backup-${Date.now()}-${backupSuffix}`);
353
531
  fs.mkdirSync(backupDir);
532
+ const replaceList = ["data", "skills", "manifest.json", "manifest-snapshot.json"];
533
+
534
+ // Phase A: move all existing targets to backupDir. After this loop
535
+ // completes, the install root has none of the replaced targets.
354
536
  for (const target of replaceList) {
355
- const src = path.join(stageDir, target);
356
- if (!fs.existsSync(src)) continue;
357
537
  const dst = path.join(ROOT, target);
358
538
  if (fs.existsSync(dst)) {
359
539
  fs.renameSync(dst, path.join(backupDir, target));
540
+ completedSteps.push({ kind: "backup", target });
360
541
  }
361
- fs.renameSync(src, dst);
362
542
  }
543
+
544
+ // Phase B: move all new targets in from stage.
545
+ for (const target of replaceList) {
546
+ const src = path.join(stageDir, target);
547
+ if (!fs.existsSync(src)) continue;
548
+ fs.renameSync(src, path.join(ROOT, target));
549
+ completedSteps.push({ kind: "install", target });
550
+ }
551
+
363
552
  fs.rmSync(stageDir, { recursive: true, force: true });
364
553
  // Best-effort cleanup of backup dir — keep on disk for one cycle so
365
554
  // operators can manually roll back if something feels off.
@@ -371,11 +560,38 @@ async function main() {
371
560
  total_skills: skills.length,
372
561
  files_written: written,
373
562
  backup_dir: path.relative(ROOT, backupDir),
563
+ registry_signatures_present: registrySignatures.length,
374
564
  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
565
  }, opts.json);
376
566
  } catch (e) {
567
+ // v0.12.14 (audit F4): walk completedSteps in reverse to undo partial work.
568
+ const rollbackErrors = [];
569
+ for (const step of [...completedSteps].reverse()) {
570
+ try {
571
+ if (step.kind === "install") {
572
+ // Remove the newly-installed copy.
573
+ fs.rmSync(path.join(ROOT, step.target), { recursive: true, force: true });
574
+ } else if (step.kind === "backup" && backupDir) {
575
+ // Restore from backup.
576
+ const src = path.join(backupDir, step.target);
577
+ const dst = path.join(ROOT, step.target);
578
+ if (fs.existsSync(src)) fs.renameSync(src, dst);
579
+ }
580
+ } catch (re) {
581
+ rollbackErrors.push({ target: step.target, kind: step.kind, error: re.message });
582
+ }
583
+ }
377
584
  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);
585
+ emit({
586
+ ok: false,
587
+ error: `swap failed mid-rename: ${e.message}`,
588
+ rolled_back: rollbackErrors.length === 0,
589
+ rollback_errors: rollbackErrors,
590
+ backup_dir: backupDir ? path.relative(ROOT, backupDir) : null,
591
+ hint: rollbackErrors.length === 0
592
+ ? "Auto-rollback completed. Install state matches pre-refresh. Re-run `exceptd refresh --network` or `npm install -g @blamejs/exceptd-skills` to retry."
593
+ : "Auto-rollback partially failed. Restore manually from the backup dir at the install root, or reinstall with `npm install -g @blamejs/exceptd-skills`.",
594
+ }, opts.json);
379
595
  process.exitCode = 4;
380
596
  }
381
597
  }