@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.
- package/CHANGELOG.md +42 -0
- package/bin/exceptd.js +364 -119
- 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 +29 -5
- 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 +443 -50
- package/lib/prefetch.js +22 -2
- package/lib/refresh-external.js +32 -1
- package/lib/refresh-network.js +235 -26
- package/lib/schemas/cve-catalog.schema.json +5 -0
- package/lib/scoring.js +141 -21
- 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 +30 -4
- package/lib/validate-indexes.js +135 -29
- package/lib/validate-playbooks.js +19 -7
- package/lib/validate-vendor.js +69 -8
- package/lib/verify.js +23 -6
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +14 -3
- package/orchestrator/index.js +100 -20
- package/orchestrator/scanner.js +8 -0
- package/package.json +1 -1
- package/sbom.cdx.json +130 -130
- package/scripts/audit-cross-skill.js +1 -1
- package/scripts/bootstrap.js +1 -0
- package/scripts/build-indexes.js +84 -11
- 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 +28 -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/prefetch.js
CHANGED
|
@@ -289,7 +289,8 @@ Outputs:
|
|
|
289
289
|
|
|
290
290
|
async function timedFetch(url, headers = {}) {
|
|
291
291
|
const ac = new AbortController();
|
|
292
|
-
|
|
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
|
};
|
package/lib/refresh-external.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/refresh-network.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", () =>
|
|
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
|
-
|
|
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
|
-
|
|
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", () =>
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
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
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
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
|
-
|
|
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,
|