@blamejs/exceptd-skills 0.18.5 → 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.
- package/CHANGELOG.md +24 -0
- package/bin/exceptd.js +277 -60
- package/data/_indexes/_meta.json +22 -3
- package/data/cve-catalog.json +25 -0
- package/data/playbooks/framework.json +2 -2
- package/data/playbooks/post-quantum-migration.json +1 -1
- package/lib/auto-discovery.js +30 -10
- package/lib/collectors/ai-api.js +9 -2
- package/lib/collectors/cicd-pipeline-compromise.js +24 -5
- package/lib/collectors/cred-stores.js +17 -4
- package/lib/collectors/crypto.js +9 -2
- package/lib/collectors/hardening.js +9 -2
- package/lib/collectors/library-author.js +24 -3
- package/lib/collectors/mcp.js +9 -2
- package/lib/collectors/runtime.js +9 -2
- package/lib/collectors/sbom.js +28 -15
- package/lib/collectors/scan-excludes.js +25 -0
- package/lib/collectors/secrets.js +40 -4
- package/lib/cve-curation.js +84 -8
- package/lib/lint-skills.js +75 -3
- package/lib/playbook-runner.js +375 -40
- package/lib/prefetch.js +6 -1
- package/lib/refresh-external.js +32 -1
- package/lib/refresh-network.js +201 -24
- package/lib/schemas/cve-catalog.schema.json +5 -0
- package/lib/scoring.js +106 -13
- package/lib/sign.js +107 -29
- package/lib/source-advisories.js +23 -5
- package/lib/source-ghsa.js +25 -1
- package/lib/source-osv.js +26 -1
- package/lib/upstream-check.js +1 -1
- package/lib/validate-cve-catalog.js +19 -3
- package/lib/validate-indexes.js +62 -1
- package/lib/validate-playbooks.js +4 -1
- package/lib/validate-vendor.js +69 -8
- package/manifest.json +53 -53
- package/orchestrator/index.js +69 -13
- package/package.json +1 -1
- package/sbom.cdx.json +124 -124
- package/scripts/audit-cross-skill.js +1 -1
- package/scripts/bootstrap.js +1 -0
- package/scripts/build-indexes.js +58 -4
- package/scripts/check-agents-md-collectors.js +41 -13
- package/scripts/check-changelog-extract.js +4 -4
- package/scripts/check-codebase-patterns.js +19 -5
- package/scripts/check-manifest-snapshot.js +74 -30
- package/scripts/check-sbom-currency.js +25 -5
- package/scripts/check-test-count.js +26 -7
- package/scripts/check-test-coverage.js +44 -4
- package/scripts/check-version-tags.js +27 -8
- package/scripts/predeploy.js +1 -1
- package/scripts/refresh-manifest-snapshot.js +14 -4
- package/scripts/refresh-reverse-refs.js +7 -1
- package/scripts/refresh-sbom.js +1 -1
- package/scripts/release.js +3 -3
- package/scripts/run-e2e-scenarios.js +18 -8
- package/scripts/validate-vendor-online.js +20 -2
- package/scripts/verify-shipped-tarball.js +65 -6
- package/sources/validators/cve-validator.js +17 -1
- package/vendor/blamejs/_PROVENANCE.json +4 -2
package/lib/refresh-network.js
CHANGED
|
@@ -72,7 +72,12 @@ function progress(line, json) {
|
|
|
72
72
|
process.stderr.write(`[refresh-network] ${line}\n`);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", () =>
|
|
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
|
-
|
|
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
|
-
|
|
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", () =>
|
|
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(
|
|
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
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
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
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
125
|
-
warnings.push(`${f}: expected boolean, got ${typeof
|
|
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 =
|
|
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
|
|
289
|
-
if (typeof 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
|
-
|
|
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 (
|
|
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 =
|
|
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,
|