@blamejs/exceptd-skills 0.18.6 → 0.18.7

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 (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/bin/exceptd.js +261 -56
  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 +24 -3
  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 +375 -40
  22. package/lib/prefetch.js +6 -1
  23. package/lib/refresh-external.js +32 -1
  24. package/lib/refresh-network.js +201 -24
  25. package/lib/schemas/cve-catalog.schema.json +5 -0
  26. package/lib/scoring.js +106 -13
  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 +19 -3
  33. package/lib/validate-indexes.js +62 -1
  34. package/lib/validate-playbooks.js +4 -1
  35. package/lib/validate-vendor.js +69 -8
  36. package/manifest.json +53 -53
  37. package/orchestrator/index.js +69 -13
  38. package/package.json +1 -1
  39. package/sbom.cdx.json +124 -124
  40. package/scripts/audit-cross-skill.js +1 -1
  41. package/scripts/bootstrap.js +1 -0
  42. package/scripts/build-indexes.js +58 -4
  43. package/scripts/check-agents-md-collectors.js +41 -13
  44. package/scripts/check-changelog-extract.js +4 -4
  45. package/scripts/check-codebase-patterns.js +19 -5
  46. package/scripts/check-manifest-snapshot.js +74 -30
  47. package/scripts/check-sbom-currency.js +25 -5
  48. package/scripts/check-test-count.js +26 -7
  49. package/scripts/check-test-coverage.js +44 -4
  50. package/scripts/check-version-tags.js +27 -8
  51. package/scripts/predeploy.js +1 -1
  52. package/scripts/refresh-manifest-snapshot.js +14 -4
  53. package/scripts/refresh-reverse-refs.js +7 -1
  54. package/scripts/refresh-sbom.js +1 -1
  55. package/scripts/release.js +3 -3
  56. package/scripts/run-e2e-scenarios.js +18 -8
  57. package/scripts/validate-vendor-online.js +20 -2
  58. package/scripts/verify-shipped-tarball.js +65 -6
  59. package/sources/validators/cve-validator.js +17 -1
  60. package/vendor/blamejs/_PROVENANCE.json +4 -2
@@ -72,7 +72,12 @@ function progress(line, json) {
72
72
  process.stderr.write(`[refresh-network] ${line}\n`);
73
73
  }
74
74
 
75
- function getJson(url, timeoutMs) {
75
+ // Cap the registry metadata read so a hostile/misconfigured endpoint can't
76
+ // stream unbounded JSON into RAM (the /latest response is a few KB; 16 MB is
77
+ // generous headroom). Symmetric with getBuffer's tarball streaming cap.
78
+ const JSON_RESPONSE_CAP_BYTES = 16 * 1024 * 1024;
79
+
80
+ function getJsonOnce(url, timeoutMs) {
76
81
  return new Promise((resolve, reject) => {
77
82
  const u = new URL(url);
78
83
  const req = https.get({
@@ -82,23 +87,64 @@ function getJson(url, timeoutMs) {
82
87
  }, (res) => {
83
88
  if (res.statusCode !== 200) {
84
89
  res.resume();
85
- return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
90
+ const e = new Error(`HTTP ${res.statusCode} from ${url}`);
91
+ e.statusCode = res.statusCode; // let withRetry's classifier retry 429/5xx
92
+ return reject(e);
86
93
  }
87
94
  const chunks = [];
88
- res.on("data", (c) => chunks.push(c));
95
+ let total = 0;
96
+ res.on("data", (c) => {
97
+ total += c.length;
98
+ if (total > JSON_RESPONSE_CAP_BYTES) {
99
+ req.destroy(new Error(`registry response exceeds ${JSON_RESPONSE_CAP_BYTES}-byte cap`));
100
+ return;
101
+ }
102
+ chunks.push(c);
103
+ });
89
104
  res.on("end", () => {
90
105
  try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
91
106
  catch (e) { reject(new Error(`parse: ${e.message}`)); }
92
107
  });
93
108
  });
94
- req.on("timeout", () => req.destroy(new Error("timeout")));
109
+ req.on("timeout", () => { const te = new Error("timeout"); te.code = "ETIMEDOUT"; req.destroy(te); });
95
110
  req.on("error", reject);
96
111
  });
97
112
  }
98
113
 
99
- function getBuffer(url, timeoutMs) {
114
+ // Adopt the vendored blamejs retry primitive: a transient registry failure
115
+ // (HTTP 429/5xx, ECONNRESET/ETIMEDOUT family) is retried with exponential
116
+ // backoff + jitter instead of aborting the whole --network refresh on the
117
+ // first network hiccup. Permanent conditions (parse error, size-cap) carry no
118
+ // statusCode/code, so the classifier does not retry them.
119
+ function getJson(url, timeoutMs) {
120
+ // Lazy require: a parseTar-only consumer (scripts/verify-shipped-tarball.js)
121
+ // loads this module in a context where vendor/ may not be present, and it
122
+ // never calls getJson — so retry must not be a load-time dependency.
123
+ const { withRetry } = require("../vendor/blamejs/retry.js");
124
+ return withRetry(() => getJsonOnce(url, timeoutMs), { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 2000, jitterFactor: 0.5 });
125
+ }
126
+
127
+ // The tarball URL is taken from the registry metadata (or, under
128
+ // EXCEPTD_REGISTRY_FIXTURE, a local fixture file), so it is not a hardcoded
129
+ // constant the way the /latest metadata URL is. Pin its host to the npm
130
+ // registry the metadata was queried from, so a tampered metadata response or
131
+ // fixture cannot steer the fetch at an internal or attacker-controlled address.
132
+ // Content trust still rests on the SHA-512/SRI + shasum + Ed25519 checks below;
133
+ // this is fetch-destination defense-in-depth.
134
+ const ALLOWED_TARBALL_HOST = /(?:^|\.)npmjs\.org$|(?:^|\.)npmjs\.com$/;
135
+
136
+ // Exported for in-process tests of the fetch-destination guard.
137
+ function isAllowedTarballHost(url) {
138
+ try { return ALLOWED_TARBALL_HOST.test(new URL(url).hostname.toLowerCase()); }
139
+ catch { return false; }
140
+ }
141
+
142
+ function getBufferOnce(url, timeoutMs) {
100
143
  return new Promise((resolve, reject) => {
101
144
  const u = new URL(url);
145
+ if (!isAllowedTarballHost(url)) {
146
+ return reject(new Error(`refusing to fetch tarball from non-allowlisted host: ${u.hostname}`));
147
+ }
102
148
  const cap = (() => {
103
149
  const env = parseInt(process.env.EXCEPTD_TARBALL_SIZE_CAP_BYTES, 10);
104
150
  return Number.isFinite(env) && env > 0 ? env : 200 * 1024 * 1024;
@@ -110,7 +156,9 @@ function getBuffer(url, timeoutMs) {
110
156
  }, (res) => {
111
157
  if (res.statusCode !== 200) {
112
158
  res.resume();
113
- return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
159
+ const e = new Error(`HTTP ${res.statusCode} from ${url}`);
160
+ e.statusCode = res.statusCode; // retryable 429/5xx via withRetry
161
+ return reject(e);
114
162
  }
115
163
  const chunks = [];
116
164
  let total = 0;
@@ -126,11 +174,19 @@ function getBuffer(url, timeoutMs) {
126
174
  });
127
175
  res.on("end", () => resolve(Buffer.concat(chunks)));
128
176
  });
129
- req.on("timeout", () => req.destroy(new Error("timeout")));
177
+ req.on("timeout", () => { const te = new Error("timeout"); te.code = "ETIMEDOUT"; req.destroy(te); });
130
178
  req.on("error", reject);
131
179
  });
132
180
  }
133
181
 
182
+ // Wrap the tarball fetch in the same vendored retry primitive (BJS-01). The
183
+ // host-allowlist rejection + size-cap carry no statusCode/code, so a permanent
184
+ // refusal is not retried; a transient 5xx / reset / timeout is.
185
+ function getBuffer(url, timeoutMs) {
186
+ const { withRetry } = require("../vendor/blamejs/retry.js"); // lazy — see getJson
187
+ return withRetry(() => getBufferOnce(url, timeoutMs), { maxAttempts: 3, baseDelayMs: 100, maxDelayMs: 2000, jitterFactor: 0.5 });
188
+ }
189
+
134
190
  /**
135
191
  * Parse a tar buffer (uncompressed) and return [{ name, body }] entries.
136
192
  * Tiny implementation — supports the GNU ustar variant npm produces.
@@ -193,6 +249,56 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
193
249
  } catch { return false; }
194
250
  }
195
251
 
252
+ // Pinned npm registry signing keys (https://registry.npmjs.org/-/npm/v1/keys).
253
+ // The registry signs `<pkg>@<version>:<integrity>` with an ECDSA P-256
254
+ // (secp256r1 / ecdsa-sha2-nistp256) key; `dist.signatures[]` carries the
255
+ // keyid + base64 signature. These keys are pinned in-tree on purpose: fetching
256
+ // them over the network would mean authenticating the metadata response with a
257
+ // key obtained from the same untrusted channel, which is circular. They also
258
+ // rotate rarely and out-of-band. Values are SPKI DER, base64-encoded.
259
+ const NPM_REGISTRY_KEYS = Object.freeze({
260
+ "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA":
261
+ "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==",
262
+ "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U":
263
+ "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g==",
264
+ });
265
+
266
+ // Verify the npm registry's signature over `<pkg>@<version>:<integrity>`.
267
+ // Returns:
268
+ // { status: "valid", keyid } — a pinned key verified the signature
269
+ // { status: "absent" } — no dist.signatures entries supplied
270
+ // { status: "unverifiable", reason } — integrity missing (nothing to sign over)
271
+ // { status: "unknown-keyid", keyid } — signature keyid is not a pinned npm key
272
+ // { status: "invalid", reason } — a present, pinned-keyid signature did NOT verify
273
+ //
274
+ // The caller treats `valid` as an authenticity anchor over the WHOLE tarball
275
+ // (including the unsigned data/ tree), `invalid`/`unknown-keyid` as a hard
276
+ // refusal, and `absent`/`unverifiable` as warn-and-continue (so private or
277
+ // pre-signing registries still work — the SHA-512/SHA-1 gates still apply).
278
+ function verifyNpmRegistrySignature(pkg, version, integrity, signatures) {
279
+ if (!Array.isArray(signatures) || signatures.length === 0) return { status: "absent" };
280
+ if (typeof integrity !== "string" || integrity.length === 0) {
281
+ return { status: "unverifiable", reason: "dist.integrity missing — no canonical message to verify the registry signature over" };
282
+ }
283
+ const message = Buffer.from(`${pkg}@${version}:${integrity}`, "utf8");
284
+ for (const entry of signatures) {
285
+ if (!entry || typeof entry.keyid !== "string" || typeof entry.sig !== "string") continue;
286
+ const spkiB64 = NPM_REGISTRY_KEYS[entry.keyid];
287
+ if (!spkiB64) return { status: "unknown-keyid", keyid: entry.keyid };
288
+ let keyObj;
289
+ try {
290
+ keyObj = crypto.createPublicKey({ key: Buffer.from(spkiB64, "base64"), format: "der", type: "spki" });
291
+ } catch (e) {
292
+ return { status: "invalid", reason: `pinned npm key ${entry.keyid} failed to load: ${e.message}` };
293
+ }
294
+ let ok = false;
295
+ try { ok = crypto.verify("sha256", message, keyObj, Buffer.from(entry.sig, "base64")); }
296
+ catch (e) { return { status: "invalid", reason: `crypto.verify threw for keyid ${entry.keyid}: ${e.message}` }; }
297
+ if (ok) return { status: "valid", keyid: entry.keyid };
298
+ }
299
+ return { status: "invalid", reason: "no dist.signatures[] entry verified against the pinned npm registry key" };
300
+ }
301
+
196
302
  // v0.12.14: CRLF/BOM normalization mirrors lib/verify.js's
197
303
  // normalize(). Duplicated here to keep refresh-network free of cross-module
198
304
  // runtime deps. ANY change here MUST be mirrored in lib/verify.js +
@@ -302,7 +408,7 @@ async function main() {
302
408
  // + dist.shasum, or just version to exercise the early-return path).
303
409
  meta = JSON.parse(fs.readFileSync(process.env.EXCEPTD_REGISTRY_FIXTURE, "utf8"));
304
410
  } else {
305
- meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(PKG_NAME).replace("%40", "@").replace("%2F", "/")}/latest`, opts.timeoutMs);
411
+ meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(PKG_NAME).replace(/%40/g, "@").replace(/%2F/g, "/")}/latest`, opts.timeoutMs);
306
412
  }
307
413
  } catch (e) {
308
414
  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);
@@ -357,12 +463,26 @@ async function main() {
357
463
  process.exitCode = 4; return;
358
464
  }
359
465
 
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.
466
+ // Verify SHA-512 SRI first (collision-resistant beyond SHA-1 reach), then
467
+ // SHA-1 shasum for compatibility. Both are transport hashes drawn from the
468
+ // registry metadata response, so they only prove the downloaded tarball
469
+ // matches the hashes THAT RESPONSE declared — an attacker who can forge the
470
+ // metadata serves a tampered tarball plus matching SHA-512/SHA-1 and both
471
+ // pass. The npm registry's signature over `<pkg>@<version>:<integrity>`
472
+ // (verified further below, after the both-absent refusal) is the only layer
473
+ // an attacker forging the metadata cannot also forge, because it is made
474
+ // with npm's registry private key, which they do not hold.
475
+ //
476
+ // `integrityVerified` records whether at least one transport hash actually
477
+ // matched the downloaded bytes. The per-skill + manifest Ed25519 signatures
478
+ // below authenticate skills/ + manifest.json, but the swap loop ALSO
479
+ // replaces data/ and manifest-snapshot.json, which carry no per-content
480
+ // signature — a transport hash is their only integrity anchor. When the
481
+ // registry metadata supplies NEITHER dist.integrity (sha512 SRI) NOR a
482
+ // dist.shasum, both branches are skipped and those bytes would otherwise be
483
+ // swapped in with zero verification, so the both-absent case is refused
484
+ // below rather than falling through to an unauthenticated swap.
485
+ let integrityVerified = false;
366
486
  if (tarballIntegrity && /^sha512-/.test(tarballIntegrity)) {
367
487
  const expected = tarballIntegrity.slice("sha512-".length);
368
488
  const actual = crypto.createHash("sha512").update(tgzBuf).digest("base64");
@@ -370,9 +490,11 @@ async function main() {
370
490
  emit({ ok: false, error: `tarball SHA-512 integrity mismatch: dist.integrity=${tarballIntegrity}, actual=sha512-${actual}` }, opts.json);
371
491
  process.exitCode = 4; return;
372
492
  }
493
+ integrityVerified = true;
373
494
  } else if (tarballIntegrity) {
374
495
  // Non-sha512 SRI (e.g. sha384) — emit a warning but accept; SHA-1 path
375
- // below still gates.
496
+ // below still gates. A non-sha512 SRI is not actually checked here, so it
497
+ // does not count toward integrityVerified.
376
498
  progress(`note: dist.integrity present but not sha512: ${tarballIntegrity.slice(0, 40)}`, opts.json);
377
499
  }
378
500
 
@@ -383,6 +505,51 @@ async function main() {
383
505
  emit({ ok: false, error: `tarball shasum mismatch: expected ${tarballShasum}, got ${actual}` }, opts.json);
384
506
  process.exitCode = 4; return;
385
507
  }
508
+ integrityVerified = true;
509
+ }
510
+
511
+ // Refuse the swap when no transport hash was verifiable. data/ and
512
+ // manifest-snapshot.json have no per-content signature, so absent both
513
+ // dist.integrity and dist.shasum there is no integrity anchor on those bytes
514
+ // and a tampered registry response (or a hostile EXCEPTD_REGISTRY_FIXTURE)
515
+ // could inject arbitrary catalog/playbook/index content. `npm update -g`
516
+ // runs the full provenance-verified path (sha512 SRI + npm Ed25519 registry
517
+ // signatures + sigstore attestations), so it is the correct fallback.
518
+ if (!integrityVerified) {
519
+ emit({
520
+ ok: false,
521
+ 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",
522
+ 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.",
523
+ }, opts.json);
524
+ process.exitCode = 4; return;
525
+ }
526
+
527
+ // Verify the npm registry's signature over `<pkg>@<version>:<integrity>`.
528
+ // This is the authenticity anchor the transport hashes above cannot be: the
529
+ // hashes come from the same (possibly forged) metadata response as the
530
+ // tarball URL, but this signature is made with npm's registry private key.
531
+ // It transitively authenticates the WHOLE tarball — including the data/ and
532
+ // manifest-snapshot.json bytes that carry no per-content Ed25519 signature.
533
+ // - valid → strongest anchor present; proceed.
534
+ // - invalid / unknown-keyid → a present signature did not verify against a
535
+ // pinned npm key (or steers us to an unpinned key);
536
+ // this is the forged-metadata signal — refuse.
537
+ // - absent / unverifiable → registry supplied no signature (private or
538
+ // pre-signing registry); the SHA-512/SHA-1 gates above
539
+ // still applied, so warn-and-continue.
540
+ const regSig = verifyNpmRegistrySignature(PKG_NAME, latestVersion, tarballIntegrity, registrySignatures);
541
+ if (regSig.status === "invalid" || regSig.status === "unknown-keyid") {
542
+ emit({
543
+ ok: false,
544
+ error: `npm registry signature ${regSig.status} for ${PKG_NAME}@${latestVersion} — refusing to swap`,
545
+ reason: regSig.reason || null,
546
+ keyid: regSig.keyid || null,
547
+ 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.",
548
+ }, opts.json);
549
+ process.exitCode = 5; return;
550
+ }
551
+ if (regSig.status === "absent" || regSig.status === "unverifiable") {
552
+ progress(`note: npm registry signature ${regSig.status} (${regSig.reason || "no dist.signatures"}) — relying on transport-hash integrity only`, opts.json);
386
553
  }
387
554
 
388
555
  // Extract.
@@ -602,14 +769,16 @@ async function main() {
602
769
  process.exitCode = 5; return;
603
770
  }
604
771
 
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).
772
+ // The swap loop replaces `data/` + `manifest.json` + `manifest-snapshot.json`
773
+ // in addition to `skills/`. None of those files are covered by the per-skill
774
+ // Ed25519 signature (which signs only the skill body bytes). Their integrity
775
+ // anchors are: the SHA-512 SRI + SHA-1 shasum transport hashes (checked
776
+ // above; both-absent is refused), AND — when the registry supplies it — the
777
+ // npm registry ECDSA-P256 signature over `<pkg>@<version>:<integrity>`
778
+ // (verifyNpmRegistrySignature above), which authenticates the whole tarball
779
+ // against npm's pinned signing key and cannot be reproduced by a forged
780
+ // metadata response. `npm update -g` additionally honors dist.attestations
781
+ // (sigstore SLSA provenance), which this data-only path does not consume.
613
782
  //
614
783
  // Defense-in-depth: refuse the swap if the manifest skills list doesn't
615
784
  // exactly match the skill payload entries present in the tarball. A
@@ -705,7 +874,9 @@ async function main() {
705
874
  files_written: written,
706
875
  backup_dir: path.relative(ROOT, backupDir),
707
876
  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.`,
877
+ registry_signature_status: regSig.status,
878
+ registry_signature_keyid: regSig.keyid || null,
879
+ 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
880
  }, opts.json);
710
881
  } catch (e) {
711
882
  // v0.12.14: walk completedSteps in reverse to undo partial work.
@@ -759,4 +930,10 @@ module.exports = {
759
930
  // check.
760
931
  verifyTarballManifestSignature,
761
932
  canonicalManifestBytesForRefresh,
933
+ // Exported for in-process tests of the tarball fetch-destination allowlist.
934
+ isAllowedTarballHost,
935
+ // Exported for in-process tests of the npm registry signature verification
936
+ // over `<pkg>@<version>:<integrity>`.
937
+ verifyNpmRegistrySignature,
938
+ NPM_REGISTRY_KEYS,
762
939
  };
@@ -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,
package/lib/scoring.js CHANGED
@@ -81,13 +81,73 @@ const ACTIVE_EXPLOITATION_LADDER = {
81
81
  none: 0,
82
82
  };
83
83
 
84
+ /**
85
+ * Resolve the active_exploitation ladder multiplier for a factor value.
86
+ *
87
+ * The bare `ACTIVE_EXPLOITATION_LADDER[v] ?? 0` lookup silently mapped any
88
+ * out-of-vocabulary string ('exploited', 'in-the-wild', a future vocabulary
89
+ * value) AND any case/whitespace variant ('Confirmed', ' CONFIRMED ') to 0 —
90
+ * dropping up to the full active_exploitation weight (20 pts) from the RWEP
91
+ * with no diagnostic, while validateFactors() flagged the same string. This
92
+ * is the recurring "out-of-vocab token -> silent zero" class: the no-match
93
+ * path must surface an error, not a silent default (same remedy as the
94
+ * playbook-runner condition-evaluator hyphen fix).
95
+ *
96
+ * - Case-normalises the lookup so 'Confirmed' / ' CONFIRMED ' resolve to the
97
+ * canonical ladder entry instead of zeroing.
98
+ * - null / undefined are the documented "treated as 'none'" default (mult 0,
99
+ * recognised) — these are not typos.
100
+ * - A non-empty string NOT in the ladder, or a non-string non-nullish value,
101
+ * is UNRECOGNISED: returns multiplier 0 AND emits a process warning so the
102
+ * zeroed factor is observable in the bare-number call path. The structured
103
+ * diagnostic for the collectWarnings path is produced by validateFactors().
104
+ *
105
+ * Returns { multiplier, recognised, normalised }.
106
+ */
107
+ function resolveActiveExploitation(active_exploitation) {
108
+ if (active_exploitation === undefined || active_exploitation === null) {
109
+ // documented default: absent active_exploitation is scored as 'none'.
110
+ return { multiplier: ACTIVE_EXPLOITATION_LADDER.none, recognised: true, normalised: 'none' };
111
+ }
112
+ if (typeof active_exploitation === 'string') {
113
+ const norm = active_exploitation.trim().toLowerCase();
114
+ if (Object.prototype.hasOwnProperty.call(ACTIVE_EXPLOITATION_LADDER, norm)) {
115
+ return { multiplier: ACTIVE_EXPLOITATION_LADDER[norm], recognised: true, normalised: norm };
116
+ }
117
+ return { multiplier: 0, recognised: false, normalised: norm };
118
+ }
119
+ return { multiplier: 0, recognised: false, normalised: null };
120
+ }
121
+
122
+ function activeExploitationMultiplier(active_exploitation) {
123
+ const r = resolveActiveExploitation(active_exploitation);
124
+ if (!r.recognised) {
125
+ // Observable diagnostic for the bare-number call path (scoreCustom without
126
+ // collectWarnings, which the production write-paths use). Routed through
127
+ // process.emitWarning so it lands on the standard Node diagnostic channel
128
+ // without changing the function's number return contract; deduped per
129
+ // distinct offending value so a batch curation run doesn't flood stderr.
130
+ const detail = active_exploitation === undefined || active_exploitation === null
131
+ ? String(active_exploitation)
132
+ : (typeof active_exploitation === 'string' ? JSON.stringify(active_exploitation) : `${typeof active_exploitation} ${JSON.stringify(active_exploitation)}`);
133
+ process.emitWarning(
134
+ `active_exploitation ${detail} is not in the recognised ladder (${Object.keys(ACTIVE_EXPLOITATION_LADDER).join(', ')}); contributing 0 active-exploitation weight`,
135
+ { type: 'RwepActiveExploitationUnrecognised', code: 'RWEP_AE_UNRECOGNISED' },
136
+ );
137
+ }
138
+ return r.multiplier;
139
+ }
140
+
84
141
  // The canonical set of factor keys scoreCustom recognises. Used by
85
142
  // validateFactors to flag unknown keys.
86
143
  const RECOGNISED_FACTOR_KEYS = new Set([
87
144
  'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
88
145
  'active_exploitation', 'blast_radius', 'patch_available',
89
146
  'live_patch_available', 'reboot_required',
90
- // accepted alias for the catalog field name
147
+ // accepted aliases for the catalog field names: a factor bag built straight
148
+ // from a catalog entry carries `ai_assisted_weaponization` (the field the
149
+ // catalog declares) and `patch_required_reboot`, not the legacy short forms.
150
+ 'ai_assisted_weaponization',
91
151
  'patch_required_reboot',
92
152
  ]);
93
153
 
@@ -119,10 +179,14 @@ function validateFactors(factors) {
119
179
  const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
120
180
  'patch_available', 'live_patch_available', 'reboot_required'];
121
181
  for (const f of boolFields) {
122
- if (factors[f] === undefined || factors[f] === null) {
182
+ // The catalog field `ai_assisted_weaponization` satisfies `ai_assisted_weapon`.
183
+ const present = (f === 'ai_assisted_weapon')
184
+ ? (factors.ai_assisted_weapon ?? factors.ai_assisted_weaponization)
185
+ : factors[f];
186
+ if (present === undefined || present === null) {
123
187
  warnings.push(`${f}: missing (treated as false; explicit value recommended)`);
124
- } else if (typeof factors[f] !== 'boolean') {
125
- warnings.push(`${f}: expected boolean, got ${typeof factors[f]} (${JSON.stringify(factors[f])})`);
188
+ } else if (typeof present !== 'boolean') {
189
+ warnings.push(`${f}: expected boolean, got ${typeof present} (${JSON.stringify(present)})`);
126
190
  }
127
191
  }
128
192
  const aeAllowed = ['none', 'unknown', 'suspected', 'theoretical', 'confirmed'];
@@ -180,6 +244,10 @@ function scoreCustom(factors, opts) {
180
244
  cisa_kev = false,
181
245
  poc_available = false,
182
246
  ai_assisted_weapon = false,
247
+ // The catalog field is `ai_assisted_weaponization`; accept it as an alias
248
+ // so a factor bag built directly from a catalog entry still counts the AI
249
+ // factor instead of silently dropping the +15 weight.
250
+ ai_assisted_weaponization = false,
183
251
  ai_discovered = false,
184
252
  active_exploitation = 'none',
185
253
  blast_radius = 0,
@@ -198,7 +266,7 @@ function scoreCustom(factors, opts) {
198
266
  let score = 0;
199
267
  score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
200
268
  score += poc_available ? RWEP_WEIGHTS.poc_available : 0;
201
- score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
269
+ score += (ai_assisted_weapon || ai_assisted_weaponization || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
202
270
  // active_exploitation goes through the ladder rather
203
271
  // than two hand-written branches with `Math.floor(weight/2)`. The floor
204
272
  // was a no-op for even weights (20/2 = 10) but would have silently
@@ -206,7 +274,7 @@ function scoreCustom(factors, opts) {
206
274
  // weight to 21. The ladder + multiplication preserves the contribution
207
275
  // exactly, including the new `unknown → 0.25 × weight = 5` mapping that
208
276
  // aligns the catalog scorer with playbook-runner._activeExploitationLadder.
209
- const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[active_exploitation] ?? 0;
277
+ const aeMultiplier = activeExploitationMultiplier(active_exploitation);
210
278
  score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
211
279
  // v0.12.15: blast_radius numeric coercion must reject
212
280
  // NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
@@ -284,9 +352,26 @@ function deriveRwepFromFactors(factors) {
284
352
  return scoreCustom(factors);
285
353
  }
286
354
  // Shape B: catalog post-weight. Sum + clamp.
355
+ //
356
+ // blast_radius is the one Shape B field with a per-factor ceiling: it is a
357
+ // RAW 0..30 magnitude, not a post-weight contribution (see the dual-semantics
358
+ // note at the top of this file). Clamp it to [0, RWEP_WEIGHTS.blast_radius]
359
+ // before summing — exactly as scoreCustom does — so an out-of-range stored
360
+ // value (a unit error such as 300, or a negative) cannot silently inflate or
361
+ // zero the result by being absorbed only by the final aggregate clamp. Both
362
+ // paths now produce the same score for the same factors, so the validate()
363
+ // recompute-vs-stored divergence gate stays meaningful instead of flagging a
364
+ // self-inconsistency the two scorers introduced. Every other Shape B value is
365
+ // already a bounded post-weight contribution, so only blast_radius needs the
366
+ // per-factor clamp.
287
367
  let sum = 0;
288
- for (const v of values) {
289
- if (typeof v === 'number' && Number.isFinite(v)) sum += v;
368
+ for (const [k, v] of Object.entries(factors)) {
369
+ if (typeof v !== 'number' || !Number.isFinite(v)) continue;
370
+ if (k === 'blast_radius') {
371
+ sum += Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, v));
372
+ } else {
373
+ sum += v;
374
+ }
290
375
  }
291
376
  return Math.max(0, Math.min(100, sum));
292
377
  }
@@ -333,7 +418,11 @@ function compare(cveId, catalog, opts) {
333
418
  // delta is null (not NaN) when there is no CVSS to compare against, so the
334
419
  // emitted result serializes cleanly and never claims a numeric divergence
335
420
  // that does not exist.
336
- const delta = cvssAbsent ? null : rwep - cvssEquivalent;
421
+ // Guard the RWEP side exactly like CVSS above: an absent or non-finite
422
+ // rwep_score must not flow into `rwep - cvssEquivalent` — NaN poisons the
423
+ // delta, fails every band, and falls through to a false "broadly aligned".
424
+ const rwepValid = (typeof rwep === 'number' && Number.isFinite(rwep));
425
+ const delta = (cvssAbsent || !rwepValid) ? null : rwep - cvssEquivalent;
337
426
 
338
427
  // narrow the "broadly aligned" band from ±20 to ±10. The old
339
428
  // ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
@@ -347,7 +436,9 @@ function compare(cveId, catalog, opts) {
347
436
  // aligned" — coincidence-passing per the field-present-not-populated
348
437
  // pitfall. Now the operator sees a specific signal pointing at the
349
438
  // catalog gap rather than a false sense of alignment.
350
- if ((rwep == null || rwep === 0) && (cvss == null || cvss === 0)) {
439
+ if (!rwepValid) {
440
+ explanation = 'RWEP score absent or non-numeric for this CVE — no usable RWEP signal to compare. Backfill rwep_score / rwep_factors in the catalog.';
441
+ } else if ((rwep == null || rwep === 0) && (cvss == null || cvss === 0)) {
351
442
  explanation = 'No scoring signal — both RWEP and CVSS are zero/null. Investigate the catalog entry; this CVE has no usable risk score.';
352
443
  } else if (cvssAbsent) {
353
444
  // RWEP carries a real signal but there is no CVSS to compare it against.
@@ -379,9 +470,9 @@ function compare(cveId, catalog, opts) {
379
470
  const out = {
380
471
  cve_id: cveId,
381
472
  cvss: cvss,
382
- rwep: rwep,
473
+ rwep: rwepValid ? rwep : null,
383
474
  cvss_framework_sla: timeline(cvssEquivalent),
384
- rwep_actual_sla: timeline(rwep),
475
+ rwep_actual_sla: rwepValid ? timeline(rwep) : { hours: null, label: 'RWEP score unavailable' },
385
476
  delta,
386
477
  explanation,
387
478
  };
@@ -485,7 +576,7 @@ function validate(catalog) {
485
576
  // one judgment-set factor with no deriving source field.
486
577
  if (shape === 'B') {
487
578
  const f = entry.rwep_factors;
488
- const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[entry.active_exploitation] ?? 0;
579
+ const aeMultiplier = resolveActiveExploitation(entry.active_exploitation).multiplier;
489
580
  const implied = {
490
581
  cisa_kev: entry.cisa_kev === true ? RWEP_WEIGHTS.cisa_kev : 0,
491
582
  poc_available: entry.poc_available === true ? RWEP_WEIGHTS.poc_available : 0,
@@ -612,6 +703,8 @@ module.exports = {
612
703
  validateFactors,
613
704
  deriveRwepFromFactors,
614
705
  parseCvss31Vector,
706
+ resolveActiveExploitation,
707
+ activeExploitationMultiplier,
615
708
  RWEP_WEIGHTS,
616
709
  ACTIVE_EXPLOITATION_LADDER,
617
710
  RECOGNISED_FACTOR_KEYS,