@blamejs/exceptd-skills 0.18.6 → 0.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bin/exceptd.js +364 -119
  3. package/data/_indexes/_meta.json +22 -3
  4. package/data/cve-catalog.json +25 -0
  5. package/data/playbooks/framework.json +2 -2
  6. package/data/playbooks/post-quantum-migration.json +1 -1
  7. package/lib/auto-discovery.js +30 -10
  8. package/lib/collectors/ai-api.js +9 -2
  9. package/lib/collectors/cicd-pipeline-compromise.js +24 -5
  10. package/lib/collectors/cred-stores.js +17 -4
  11. package/lib/collectors/crypto.js +9 -2
  12. package/lib/collectors/hardening.js +9 -2
  13. package/lib/collectors/library-author.js +29 -5
  14. package/lib/collectors/mcp.js +9 -2
  15. package/lib/collectors/runtime.js +9 -2
  16. package/lib/collectors/sbom.js +28 -15
  17. package/lib/collectors/scan-excludes.js +25 -0
  18. package/lib/collectors/secrets.js +40 -4
  19. package/lib/cve-curation.js +84 -8
  20. package/lib/lint-skills.js +75 -3
  21. package/lib/playbook-runner.js +443 -50
  22. package/lib/prefetch.js +22 -2
  23. package/lib/refresh-external.js +32 -1
  24. package/lib/refresh-network.js +235 -26
  25. package/lib/schemas/cve-catalog.schema.json +5 -0
  26. package/lib/scoring.js +141 -21
  27. package/lib/sign.js +107 -29
  28. package/lib/source-advisories.js +23 -5
  29. package/lib/source-ghsa.js +25 -1
  30. package/lib/source-osv.js +26 -1
  31. package/lib/upstream-check.js +1 -1
  32. package/lib/validate-cve-catalog.js +30 -4
  33. package/lib/validate-indexes.js +135 -29
  34. package/lib/validate-playbooks.js +19 -7
  35. package/lib/validate-vendor.js +69 -8
  36. package/lib/verify.js +23 -6
  37. package/manifest.json +53 -53
  38. package/orchestrator/dispatcher.js +14 -3
  39. package/orchestrator/index.js +100 -20
  40. package/orchestrator/scanner.js +8 -0
  41. package/package.json +1 -1
  42. package/sbom.cdx.json +130 -130
  43. package/scripts/audit-cross-skill.js +1 -1
  44. package/scripts/bootstrap.js +1 -0
  45. package/scripts/build-indexes.js +84 -11
  46. package/scripts/check-agents-md-collectors.js +41 -13
  47. package/scripts/check-changelog-extract.js +4 -4
  48. package/scripts/check-codebase-patterns.js +19 -5
  49. package/scripts/check-manifest-snapshot.js +74 -30
  50. package/scripts/check-sbom-currency.js +25 -5
  51. package/scripts/check-test-count.js +26 -7
  52. package/scripts/check-test-coverage.js +44 -4
  53. package/scripts/check-version-tags.js +27 -8
  54. package/scripts/predeploy.js +1 -1
  55. package/scripts/refresh-manifest-snapshot.js +14 -4
  56. package/scripts/refresh-reverse-refs.js +7 -1
  57. package/scripts/refresh-sbom.js +1 -1
  58. package/scripts/release.js +3 -3
  59. package/scripts/run-e2e-scenarios.js +18 -8
  60. package/scripts/validate-vendor-online.js +28 -2
  61. package/scripts/verify-shipped-tarball.js +65 -6
  62. package/sources/validators/cve-validator.js +17 -1
  63. package/vendor/blamejs/_PROVENANCE.json +4 -2
package/lib/prefetch.js CHANGED
@@ -289,7 +289,8 @@ Outputs:
289
289
 
290
290
  async function timedFetch(url, headers = {}) {
291
291
  const ac = new AbortController();
292
- const t = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS);
292
+ let timedOut = false;
293
+ const t = setTimeout(() => { timedOut = true; ac.abort(); }, REQUEST_TIMEOUT_MS);
293
294
  try {
294
295
  const res = await fetch(url, {
295
296
  signal: ac.signal,
@@ -297,6 +298,11 @@ async function timedFetch(url, headers = {}) {
297
298
  });
298
299
  if (!res.ok) {
299
300
  const err = new Error(`HTTP ${res.status}`);
301
+ // The vendored retry classifier (vendor/blamejs/retry.js isRetryable)
302
+ // keys off err.statusCode — set it so a 429/5xx from KEV/NVD/EPSS/OSV
303
+ // routes through the job-queue backoff instead of being dropped on the
304
+ // first hiccup. err.status kept for callers that read it for messaging.
305
+ err.statusCode = res.status;
300
306
  err.status = res.status;
301
307
  throw err;
302
308
  }
@@ -304,6 +310,20 @@ async function timedFetch(url, headers = {}) {
304
310
  const lastModified = res.headers.get("last-modified") || null;
305
311
  const json = await res.json();
306
312
  return { json, etag, lastModified };
313
+ } catch (e) {
314
+ // A timeout surfaces as an AbortError with NO statusCode, which the retry
315
+ // classifier would not retry — so under heavy upstream load (NVD rate
316
+ // limiting + slow responses) timed-out fetches piled up as final errors and
317
+ // pushed the total past --max-errors, failing the whole scheduled refresh.
318
+ // Re-mark a timeout as a retryable network error (ETIMEDOUT) so the job
319
+ // queue backs off and retries instead of dropping it on the first slow
320
+ // response.
321
+ if (timedOut || (e && (e.name === "AbortError" || e.code === "ABORT_ERR"))) {
322
+ const te = new Error(`request timed out after ${REQUEST_TIMEOUT_MS}ms`);
323
+ te.code = "ETIMEDOUT";
324
+ throw te;
325
+ }
326
+ throw e;
307
327
  } finally {
308
328
  clearTimeout(t);
309
329
  }
@@ -913,5 +933,5 @@ module.exports = {
913
933
  // Not part of the operator-facing API — internal contract for tests
914
934
  // that need to exercise the lockfile path without spawning the full
915
935
  // prefetch network pipeline.
916
- _internal: { withIndexLock, writeFileAtomic, loadIndex, saveIndex },
936
+ _internal: { withIndexLock, writeFileAtomic, loadIndex, saveIndex, timedFetch },
917
937
  };
@@ -267,11 +267,42 @@ const KEV_SOURCE = {
267
267
  const report = await validateAllCves(ctx.cveCatalog, { concurrency: 4 });
268
268
  const diffs = [];
269
269
  let errors = 0;
270
+ // Mirror the cache path's implausibly-small-feed guard. The live KEV map is
271
+ // fetched once per process and its size is surfaced on every reachable
272
+ // result as fetched.sources.kev.total_entries. A feed that JSON-parses but
273
+ // is far below a real CISA snapshot (a partial CDN response, a momentarily
274
+ // near-empty feed) must not be trusted to de-list curated entries. When the
275
+ // size is known and below the floor we hold ALL de-listings for review,
276
+ // exactly as kevDiffFromCache does; when the size is unknown (no reachable
277
+ // KEV result carried it) we fall back to the per-entry curated-signal guard.
278
+ let liveFeedSize = null;
279
+ for (const r of report.results) {
280
+ const n = r && r.fetched && r.fetched.sources && r.fetched.sources.kev
281
+ && r.fetched.sources.kev.total_entries;
282
+ if (typeof n === "number") { liveFeedSize = n; break; }
283
+ }
284
+ const feedComplete = liveFeedSize === null || liveFeedSize >= KEV_FEED_MIN_PLAUSIBLE;
270
285
  for (const r of report.results) {
271
286
  if (r.status === "unreachable") errors++;
272
287
  for (const d of r.discrepancies || []) {
273
288
  if (d.field === "cisa_kev" || d.field === "cisa_kev_date") {
274
- diffs.push({ id: r.cve_id, field: d.field, before: d.local, after: d.fetched, severity: d.severity });
289
+ const diff = { id: r.cve_id, field: d.field, before: d.local, after: d.fetched, severity: d.severity };
290
+ // Symmetric with the --from-cache path: a LIVE KEV de-listing
291
+ // (true→false) is held for review (applyDiff skips review_only)
292
+ // instead of auto-downgrading the entry when EITHER the entry carries
293
+ // strong human-curated exploitation signal OR the live feed is
294
+ // implausibly small. Without this the live path silently de-listed
295
+ // confirmed-exploitation CVEs the cache path would have held back, and
296
+ // a truncated-but-valid feed could de-list every non-curated entry.
297
+ if (d.field === "cisa_kev" && d.local === true && d.fetched === false &&
298
+ (!feedComplete || hasCuratedExploitSignal(ctx.cveCatalog && ctx.cveCatalog[r.cve_id]))) {
299
+ diff.review_only = true;
300
+ diff.kev_delist_review = true;
301
+ diff.note = !feedComplete
302
+ ? `KEV de-listing held for review: live feed returned only ${liveFeedSize} entries (< ${KEV_FEED_MIN_PLAUSIBLE}), likely incomplete. Confirm against a complete CISA KEV snapshot before de-listing ${r.cve_id}.`
303
+ : `KEV de-listing held for review: ${r.cve_id} carries curated exploitation signal; confirm a genuine CISA removal before downgrading.`;
304
+ }
305
+ diffs.push(diff);
275
306
  }
276
307
  }
277
308
  }
@@ -42,6 +42,24 @@ const os = require("os");
42
42
 
43
43
  const ROOT = path.resolve(__dirname, "..");
44
44
  const PKG_NAME = "@blamejs/exceptd-skills";
45
+ // Unscoped basename for the npm tarball filename: @scope/name -> name.
46
+ const PKG_UNSCOPED = PKG_NAME.includes("/") ? PKG_NAME.split("/").pop() : PKG_NAME;
47
+ // Strict semver — the ONLY registry-metadata field embedded in the fetch URL.
48
+ // A value that passes this guard cannot carry a path separator, URL scheme, or
49
+ // host, so the canonical tarball URL built from it has no metadata-controlled
50
+ // destination component.
51
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
52
+
53
+ // The canonical npm tarball URL for this package@version, built entirely from
54
+ // string literals plus a semver-guarded version. The fetch destination is no
55
+ // longer derived from the metadata-supplied dist.tarball, so a tampered or
56
+ // fixture-injected /latest response cannot steer the download at an internal or
57
+ // attacker-controlled address — strictly stronger than host-allowlisting the
58
+ // metadata URL. Content trust still rests on the SHA-512/SRI + shasum + Ed25519
59
+ // checks downstream.
60
+ function canonicalTarballUrl(version) {
61
+ return `https://registry.npmjs.org/${PKG_NAME}/-/${PKG_UNSCOPED}-${version}.tgz`;
62
+ }
45
63
  const REQUEST_TIMEOUT_MS = 15000;
46
64
 
47
65
  function parseArgs(argv) {
@@ -72,7 +90,12 @@ function progress(line, json) {
72
90
  process.stderr.write(`[refresh-network] ${line}\n`);
73
91
  }
74
92
 
75
- function getJson(url, timeoutMs) {
93
+ // Cap the registry metadata read so a hostile/misconfigured endpoint can't
94
+ // stream unbounded JSON into RAM (the /latest response is a few KB; 16 MB is
95
+ // generous headroom). Symmetric with getBuffer's tarball streaming cap.
96
+ const JSON_RESPONSE_CAP_BYTES = 16 * 1024 * 1024;
97
+
98
+ function getJsonOnce(url, timeoutMs) {
76
99
  return new Promise((resolve, reject) => {
77
100
  const u = new URL(url);
78
101
  const req = https.get({
@@ -82,23 +105,64 @@ function getJson(url, timeoutMs) {
82
105
  }, (res) => {
83
106
  if (res.statusCode !== 200) {
84
107
  res.resume();
85
- return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
108
+ const e = new Error(`HTTP ${res.statusCode} from ${url}`);
109
+ e.statusCode = res.statusCode; // let withRetry's classifier retry 429/5xx
110
+ return reject(e);
86
111
  }
87
112
  const chunks = [];
88
- res.on("data", (c) => chunks.push(c));
113
+ let total = 0;
114
+ res.on("data", (c) => {
115
+ total += c.length;
116
+ if (total > JSON_RESPONSE_CAP_BYTES) {
117
+ req.destroy(new Error(`registry response exceeds ${JSON_RESPONSE_CAP_BYTES}-byte cap`));
118
+ return;
119
+ }
120
+ chunks.push(c);
121
+ });
89
122
  res.on("end", () => {
90
123
  try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
91
124
  catch (e) { reject(new Error(`parse: ${e.message}`)); }
92
125
  });
93
126
  });
94
- req.on("timeout", () => req.destroy(new Error("timeout")));
127
+ req.on("timeout", () => { const te = new Error("timeout"); te.code = "ETIMEDOUT"; req.destroy(te); });
95
128
  req.on("error", reject);
96
129
  });
97
130
  }
98
131
 
99
- function getBuffer(url, timeoutMs) {
132
+ // Adopt the vendored blamejs retry primitive: a transient registry failure
133
+ // (HTTP 429/5xx, ECONNRESET/ETIMEDOUT family) is retried with exponential
134
+ // backoff + jitter instead of aborting the whole --network refresh on the
135
+ // first network hiccup. Permanent conditions (parse error, size-cap) carry no
136
+ // statusCode/code, so the classifier does not retry them.
137
+ function getJson(url, timeoutMs) {
138
+ // Lazy require: a parseTar-only consumer (scripts/verify-shipped-tarball.js)
139
+ // loads this module in a context where vendor/ may not be present, and it
140
+ // never calls getJson — so retry must not be a load-time dependency.
141
+ const { withRetry } = require("../vendor/blamejs/retry.js");
142
+ return withRetry(() => getJsonOnce(url, timeoutMs), { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 2000, jitterFactor: 0.5 });
143
+ }
144
+
145
+ // The tarball URL is taken from the registry metadata (or, under
146
+ // EXCEPTD_REGISTRY_FIXTURE, a local fixture file), so it is not a hardcoded
147
+ // constant the way the /latest metadata URL is. Pin its host to the npm
148
+ // registry the metadata was queried from, so a tampered metadata response or
149
+ // fixture cannot steer the fetch at an internal or attacker-controlled address.
150
+ // Content trust still rests on the SHA-512/SRI + shasum + Ed25519 checks below;
151
+ // this is fetch-destination defense-in-depth.
152
+ const ALLOWED_TARBALL_HOST = /(?:^|\.)npmjs\.org$|(?:^|\.)npmjs\.com$/;
153
+
154
+ // Exported for in-process tests of the fetch-destination guard.
155
+ function isAllowedTarballHost(url) {
156
+ try { return ALLOWED_TARBALL_HOST.test(new URL(url).hostname.toLowerCase()); }
157
+ catch { return false; }
158
+ }
159
+
160
+ function getBufferOnce(url, timeoutMs) {
100
161
  return new Promise((resolve, reject) => {
101
162
  const u = new URL(url);
163
+ if (!isAllowedTarballHost(url)) {
164
+ return reject(new Error(`refusing to fetch tarball from non-allowlisted host: ${u.hostname}`));
165
+ }
102
166
  const cap = (() => {
103
167
  const env = parseInt(process.env.EXCEPTD_TARBALL_SIZE_CAP_BYTES, 10);
104
168
  return Number.isFinite(env) && env > 0 ? env : 200 * 1024 * 1024;
@@ -110,7 +174,9 @@ function getBuffer(url, timeoutMs) {
110
174
  }, (res) => {
111
175
  if (res.statusCode !== 200) {
112
176
  res.resume();
113
- return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
177
+ const e = new Error(`HTTP ${res.statusCode} from ${url}`);
178
+ e.statusCode = res.statusCode; // retryable 429/5xx via withRetry
179
+ return reject(e);
114
180
  }
115
181
  const chunks = [];
116
182
  let total = 0;
@@ -126,11 +192,19 @@ function getBuffer(url, timeoutMs) {
126
192
  });
127
193
  res.on("end", () => resolve(Buffer.concat(chunks)));
128
194
  });
129
- req.on("timeout", () => req.destroy(new Error("timeout")));
195
+ req.on("timeout", () => { const te = new Error("timeout"); te.code = "ETIMEDOUT"; req.destroy(te); });
130
196
  req.on("error", reject);
131
197
  });
132
198
  }
133
199
 
200
+ // Wrap the tarball fetch in the same vendored retry primitive (BJS-01). The
201
+ // host-allowlist rejection + size-cap carry no statusCode/code, so a permanent
202
+ // refusal is not retried; a transient 5xx / reset / timeout is.
203
+ function getBuffer(url, timeoutMs) {
204
+ const { withRetry } = require("../vendor/blamejs/retry.js"); // lazy — see getJson
205
+ return withRetry(() => getBufferOnce(url, timeoutMs), { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 2000, jitterFactor: 0.5 });
206
+ }
207
+
134
208
  /**
135
209
  * Parse a tar buffer (uncompressed) and return [{ name, body }] entries.
136
210
  * Tiny implementation — supports the GNU ustar variant npm produces.
@@ -193,6 +267,56 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
193
267
  } catch { return false; }
194
268
  }
195
269
 
270
+ // Pinned npm registry signing keys (https://registry.npmjs.org/-/npm/v1/keys).
271
+ // The registry signs `<pkg>@<version>:<integrity>` with an ECDSA P-256
272
+ // (secp256r1 / ecdsa-sha2-nistp256) key; `dist.signatures[]` carries the
273
+ // keyid + base64 signature. These keys are pinned in-tree on purpose: fetching
274
+ // them over the network would mean authenticating the metadata response with a
275
+ // key obtained from the same untrusted channel, which is circular. They also
276
+ // rotate rarely and out-of-band. Values are SPKI DER, base64-encoded.
277
+ const NPM_REGISTRY_KEYS = Object.freeze({
278
+ "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA":
279
+ "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==",
280
+ "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U":
281
+ "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g==",
282
+ });
283
+
284
+ // Verify the npm registry's signature over `<pkg>@<version>:<integrity>`.
285
+ // Returns:
286
+ // { status: "valid", keyid } — a pinned key verified the signature
287
+ // { status: "absent" } — no dist.signatures entries supplied
288
+ // { status: "unverifiable", reason } — integrity missing (nothing to sign over)
289
+ // { status: "unknown-keyid", keyid } — signature keyid is not a pinned npm key
290
+ // { status: "invalid", reason } — a present, pinned-keyid signature did NOT verify
291
+ //
292
+ // The caller treats `valid` as an authenticity anchor over the WHOLE tarball
293
+ // (including the unsigned data/ tree), `invalid`/`unknown-keyid` as a hard
294
+ // refusal, and `absent`/`unverifiable` as warn-and-continue (so private or
295
+ // pre-signing registries still work — the SHA-512/SHA-1 gates still apply).
296
+ function verifyNpmRegistrySignature(pkg, version, integrity, signatures) {
297
+ if (!Array.isArray(signatures) || signatures.length === 0) return { status: "absent" };
298
+ if (typeof integrity !== "string" || integrity.length === 0) {
299
+ return { status: "unverifiable", reason: "dist.integrity missing — no canonical message to verify the registry signature over" };
300
+ }
301
+ const message = Buffer.from(`${pkg}@${version}:${integrity}`, "utf8");
302
+ for (const entry of signatures) {
303
+ if (!entry || typeof entry.keyid !== "string" || typeof entry.sig !== "string") continue;
304
+ const spkiB64 = NPM_REGISTRY_KEYS[entry.keyid];
305
+ if (!spkiB64) return { status: "unknown-keyid", keyid: entry.keyid };
306
+ let keyObj;
307
+ try {
308
+ keyObj = crypto.createPublicKey({ key: Buffer.from(spkiB64, "base64"), format: "der", type: "spki" });
309
+ } catch (e) {
310
+ return { status: "invalid", reason: `pinned npm key ${entry.keyid} failed to load: ${e.message}` };
311
+ }
312
+ let ok = false;
313
+ try { ok = crypto.verify("sha256", message, keyObj, Buffer.from(entry.sig, "base64")); }
314
+ catch (e) { return { status: "invalid", reason: `crypto.verify threw for keyid ${entry.keyid}: ${e.message}` }; }
315
+ if (ok) return { status: "valid", keyid: entry.keyid };
316
+ }
317
+ return { status: "invalid", reason: "no dist.signatures[] entry verified against the pinned npm registry key" };
318
+ }
319
+
196
320
  // v0.12.14: CRLF/BOM normalization mirrors lib/verify.js's
197
321
  // normalize(). Duplicated here to keep refresh-network free of cross-module
198
322
  // runtime deps. ANY change here MUST be mirrored in lib/verify.js +
@@ -302,7 +426,7 @@ async function main() {
302
426
  // + dist.shasum, or just version to exercise the early-return path).
303
427
  meta = JSON.parse(fs.readFileSync(process.env.EXCEPTD_REGISTRY_FIXTURE, "utf8"));
304
428
  } else {
305
- meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(PKG_NAME).replace("%40", "@").replace("%2F", "/")}/latest`, opts.timeoutMs);
429
+ meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(PKG_NAME).replace(/%40/g, "@").replace(/%2F/g, "/")}/latest`, opts.timeoutMs);
306
430
  }
307
431
  } catch (e) {
308
432
  emit({ ok: false, error: `registry unreachable: ${e.message}`, hint: "Network required. Air-gap workflow: run `exceptd refresh --prefetch` on a connected host, then `exceptd refresh --from-cache --apply` offline. Or set EXCEPTD_REGISTRY_FIXTURE for offline testing." }, opts.json);
@@ -340,11 +464,25 @@ async function main() {
340
464
  process.exitCode = 3; return;
341
465
  }
342
466
 
343
- progress(`fetching ${tarballUrl} (${tarballShasum?.slice(0, 12) || "no shasum"})...`, opts.json);
467
+ // Build the fetch destination from constants + a semver-validated version
468
+ // rather than from the metadata-supplied dist.tarball, so the registry
469
+ // response cannot steer the download's host or path. The metadata URL is
470
+ // still cross-checked for visibility; a divergence is noted, not fatal,
471
+ // because the authenticity anchor is the SHA-512/shasum/Ed25519 chain below.
472
+ if (!SEMVER_RE.test(String(latestVersion))) {
473
+ emit({ ok: false, error: `registry metadata version is not valid semver: ${String(latestVersion).slice(0, 64)}` }, opts.json);
474
+ process.exitCode = 2; return;
475
+ }
476
+ const canonicalUrl = canonicalTarballUrl(latestVersion);
477
+ if (tarballUrl !== canonicalUrl) {
478
+ progress(`note: registry dist.tarball (${tarballUrl}) differs from canonical npm URL; fetching ${canonicalUrl}`, opts.json);
479
+ }
480
+
481
+ progress(`fetching ${canonicalUrl} (${tarballShasum?.slice(0, 12) || "no shasum"})...`, opts.json);
344
482
 
345
483
  let tgzBuf;
346
484
  try {
347
- tgzBuf = await getBuffer(tarballUrl, opts.timeoutMs);
485
+ tgzBuf = await getBuffer(canonicalUrl, opts.timeoutMs);
348
486
  } catch (e) {
349
487
  emit({ ok: false, error: `tarball fetch failed: ${e.message}` }, opts.json);
350
488
  process.exitCode = 2; return;
@@ -357,12 +495,26 @@ async function main() {
357
495
  process.exitCode = 4; return;
358
496
  }
359
497
 
360
- // v0.12.14: verify SHA-512 SRI first (collision-resistant
361
- // beyond SHA-1 reach), then SHA-1 shasum for compatibility, then dist.
362
- // signatures[] (npm registry's Ed25519 signing key). Each layer is
363
- // defense-in-depth — registry compromise that produces a SHA-1 collision
364
- // doesn't trivially produce a SHA-512 collision; an attacker who breaks
365
- // both still has to forge the npm-signing-key signature on the tarball.
498
+ // Verify SHA-512 SRI first (collision-resistant beyond SHA-1 reach), then
499
+ // SHA-1 shasum for compatibility. Both are transport hashes drawn from the
500
+ // registry metadata response, so they only prove the downloaded tarball
501
+ // matches the hashes THAT RESPONSE declared — an attacker who can forge the
502
+ // metadata serves a tampered tarball plus matching SHA-512/SHA-1 and both
503
+ // pass. The npm registry's signature over `<pkg>@<version>:<integrity>`
504
+ // (verified further below, after the both-absent refusal) is the only layer
505
+ // an attacker forging the metadata cannot also forge, because it is made
506
+ // with npm's registry private key, which they do not hold.
507
+ //
508
+ // `integrityVerified` records whether at least one transport hash actually
509
+ // matched the downloaded bytes. The per-skill + manifest Ed25519 signatures
510
+ // below authenticate skills/ + manifest.json, but the swap loop ALSO
511
+ // replaces data/ and manifest-snapshot.json, which carry no per-content
512
+ // signature — a transport hash is their only integrity anchor. When the
513
+ // registry metadata supplies NEITHER dist.integrity (sha512 SRI) NOR a
514
+ // dist.shasum, both branches are skipped and those bytes would otherwise be
515
+ // swapped in with zero verification, so the both-absent case is refused
516
+ // below rather than falling through to an unauthenticated swap.
517
+ let integrityVerified = false;
366
518
  if (tarballIntegrity && /^sha512-/.test(tarballIntegrity)) {
367
519
  const expected = tarballIntegrity.slice("sha512-".length);
368
520
  const actual = crypto.createHash("sha512").update(tgzBuf).digest("base64");
@@ -370,9 +522,11 @@ async function main() {
370
522
  emit({ ok: false, error: `tarball SHA-512 integrity mismatch: dist.integrity=${tarballIntegrity}, actual=sha512-${actual}` }, opts.json);
371
523
  process.exitCode = 4; return;
372
524
  }
525
+ integrityVerified = true;
373
526
  } else if (tarballIntegrity) {
374
527
  // Non-sha512 SRI (e.g. sha384) — emit a warning but accept; SHA-1 path
375
- // below still gates.
528
+ // below still gates. A non-sha512 SRI is not actually checked here, so it
529
+ // does not count toward integrityVerified.
376
530
  progress(`note: dist.integrity present but not sha512: ${tarballIntegrity.slice(0, 40)}`, opts.json);
377
531
  }
378
532
 
@@ -383,6 +537,51 @@ async function main() {
383
537
  emit({ ok: false, error: `tarball shasum mismatch: expected ${tarballShasum}, got ${actual}` }, opts.json);
384
538
  process.exitCode = 4; return;
385
539
  }
540
+ integrityVerified = true;
541
+ }
542
+
543
+ // Refuse the swap when no transport hash was verifiable. data/ and
544
+ // manifest-snapshot.json have no per-content signature, so absent both
545
+ // dist.integrity and dist.shasum there is no integrity anchor on those bytes
546
+ // and a tampered registry response (or a hostile EXCEPTD_REGISTRY_FIXTURE)
547
+ // could inject arbitrary catalog/playbook/index content. `npm update -g`
548
+ // runs the full provenance-verified path (sha512 SRI + npm Ed25519 registry
549
+ // signatures + sigstore attestations), so it is the correct fallback.
550
+ if (!integrityVerified) {
551
+ emit({
552
+ ok: false,
553
+ error: "registry metadata carries no verifiable transport integrity (dist.integrity sha512 SRI and dist.shasum both absent) — refusing to swap unsigned data/ + manifest-snapshot.json",
554
+ hint: "data/ and manifest-snapshot.json are not covered by the per-skill or manifest Ed25519 signature; a transport hash is their only integrity anchor. Run `npm update -g @blamejs/exceptd-skills` for the full provenance-verified path.",
555
+ }, opts.json);
556
+ process.exitCode = 4; return;
557
+ }
558
+
559
+ // Verify the npm registry's signature over `<pkg>@<version>:<integrity>`.
560
+ // This is the authenticity anchor the transport hashes above cannot be: the
561
+ // hashes come from the same (possibly forged) metadata response as the
562
+ // tarball URL, but this signature is made with npm's registry private key.
563
+ // It transitively authenticates the WHOLE tarball — including the data/ and
564
+ // manifest-snapshot.json bytes that carry no per-content Ed25519 signature.
565
+ // - valid → strongest anchor present; proceed.
566
+ // - invalid / unknown-keyid → a present signature did not verify against a
567
+ // pinned npm key (or steers us to an unpinned key);
568
+ // this is the forged-metadata signal — refuse.
569
+ // - absent / unverifiable → registry supplied no signature (private or
570
+ // pre-signing registry); the SHA-512/SHA-1 gates above
571
+ // still applied, so warn-and-continue.
572
+ const regSig = verifyNpmRegistrySignature(PKG_NAME, latestVersion, tarballIntegrity, registrySignatures);
573
+ if (regSig.status === "invalid" || regSig.status === "unknown-keyid") {
574
+ emit({
575
+ ok: false,
576
+ error: `npm registry signature ${regSig.status} for ${PKG_NAME}@${latestVersion} — refusing to swap`,
577
+ reason: regSig.reason || null,
578
+ keyid: regSig.keyid || null,
579
+ hint: "dist.signatures is present but did not verify against the pinned npm registry key. A forged metadata response cannot reproduce this signature, so treat it as tampering. Run `npm update -g @blamejs/exceptd-skills` for the full provenance-verified path, or report at https://github.com/blamejs/exceptd-skills/issues.",
580
+ }, opts.json);
581
+ process.exitCode = 5; return;
582
+ }
583
+ if (regSig.status === "absent" || regSig.status === "unverifiable") {
584
+ progress(`note: npm registry signature ${regSig.status} (${regSig.reason || "no dist.signatures"}) — relying on transport-hash integrity only`, opts.json);
386
585
  }
387
586
 
388
587
  // Extract.
@@ -602,14 +801,16 @@ async function main() {
602
801
  process.exitCode = 5; return;
603
802
  }
604
803
 
605
- // v0.12.14: the swap loop replaces `data/` + `manifest.json` +
606
- // `manifest-snapshot.json` in addition to `skills/`. None of those files
607
- // are covered by the per-skill Ed25519 signature (which signs only the
608
- // skill body bytes). The only integrity check between the registry and
609
- // those bytes is SHA-1 dist.shasum — collision-broken since 2017 and
610
- // weaker than `npm install` itself which honors dist.integrity (SHA-512
611
- // SRI) + dist.signatures (npm Ed25519 registry key) + dist.attestations
612
- // (sigstore SLSA provenance).
804
+ // The swap loop replaces `data/` + `manifest.json` + `manifest-snapshot.json`
805
+ // in addition to `skills/`. None of those files are covered by the per-skill
806
+ // Ed25519 signature (which signs only the skill body bytes). Their integrity
807
+ // anchors are: the SHA-512 SRI + SHA-1 shasum transport hashes (checked
808
+ // above; both-absent is refused), AND — when the registry supplies it — the
809
+ // npm registry ECDSA-P256 signature over `<pkg>@<version>:<integrity>`
810
+ // (verifyNpmRegistrySignature above), which authenticates the whole tarball
811
+ // against npm's pinned signing key and cannot be reproduced by a forged
812
+ // metadata response. `npm update -g` additionally honors dist.attestations
813
+ // (sigstore SLSA provenance), which this data-only path does not consume.
613
814
  //
614
815
  // Defense-in-depth: refuse the swap if the manifest skills list doesn't
615
816
  // exactly match the skill payload entries present in the tarball. A
@@ -705,7 +906,9 @@ async function main() {
705
906
  files_written: written,
706
907
  backup_dir: path.relative(ROOT, backupDir),
707
908
  registry_signatures_present: registrySignatures.length,
708
- 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.`,
909
+ registry_signature_status: regSig.status,
910
+ registry_signature_keyid: regSig.keyid || null,
911
+ message: `refreshed catalog from v${localVersion} → v${latestVersion} (${verifiedCount}/${skills.length} skill signatures verified, npm registry signature: ${regSig.status}). Backup at ${path.relative(ROOT, backupDir)} — safe to remove after verifying the new run.`,
709
912
  }, opts.json);
710
913
  } catch (e) {
711
914
  // v0.12.14: walk completedSteps in reverse to undo partial work.
@@ -759,4 +962,10 @@ module.exports = {
759
962
  // check.
760
963
  verifyTarballManifestSignature,
761
964
  canonicalManifestBytesForRefresh,
965
+ // Exported for in-process tests of the tarball fetch-destination allowlist.
966
+ isAllowedTarballHost,
967
+ // Exported for in-process tests of the npm registry signature verification
968
+ // over `<pkg>@<version>:<integrity>`.
969
+ verifyNpmRegistrySignature,
970
+ NPM_REGISTRY_KEYS,
762
971
  };
@@ -41,6 +41,11 @@
41
41
  "minLength": 1,
42
42
  "description": "Vulnerability class (LPE, RCE-via-prompt-injection, RCE-supply-chain, etc.)."
43
43
  },
44
+ "attack_class": {
45
+ "type": "string",
46
+ "enum": ["kernel-lpe", "mcp-supply-chain", "ai-c2", "prompt-injection", "container-escape"],
47
+ "description": "Optional coarse chainable taxonomy the sbom -> deep-dive feeds_into rules quantify over (any matched_cve.attack_class == 'kernel-lpe'). Distinct from the free-form `type`: a small fixed set of deep-dive playbook attack classes a matched CVE routes to. Absent when a CVE does not map cleanly to one, so the chain correctly does not fire rather than misrouting."
48
+ },
44
49
  "cvss_score": {
45
50
  "type": "number",
46
51
  "minimum": 0.0,