@blamejs/exceptd-skills 0.12.13 → 0.12.15
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 +150 -0
- package/bin/exceptd.js +147 -9
- package/data/_indexes/_meta.json +45 -45
- package/data/_indexes/activity-feed.json +4 -4
- package/data/_indexes/catalog-summaries.json +29 -29
- package/data/_indexes/chains.json +3238 -3210
- package/data/_indexes/frequency.json +3 -0
- package/data/_indexes/jurisdiction-map.json +5 -3
- package/data/_indexes/section-offsets.json +712 -685
- package/data/_indexes/theater-fingerprints.json +1 -1
- package/data/_indexes/token-budget.json +355 -340
- package/data/atlas-ttps.json +144 -129
- package/data/attack-techniques.json +319 -76
- package/data/cve-catalog.json +515 -475
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +21 -4
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +18 -5
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +742 -78
- package/lib/refresh-external.js +40 -22
- package/lib/refresh-network.js +193 -17
- package/lib/scoring.js +20 -7
- package/lib/source-ghsa.js +219 -37
- package/lib/source-osv.js +381 -122
- package/lib/validate-catalog-meta.js +64 -9
- package/lib/validate-cve-catalog.js +56 -18
- package/lib/validate-indexes.js +88 -37
- package/lib/verify.js +72 -0
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -0
- package/manifest.json +73 -73
- package/orchestrator/dispatcher.js +21 -1
- package/orchestrator/event-bus.js +52 -8
- package/orchestrator/index.js +279 -20
- package/orchestrator/pipeline.js +63 -2
- package/orchestrator/scanner.js +32 -10
- package/orchestrator/scheduler.js +150 -17
- package/package.json +3 -1
- package/sbom.cdx.json +7 -7
- package/scripts/check-manifest-snapshot.js +32 -0
- package/scripts/check-sbom-currency.js +65 -3
- package/scripts/check-test-coverage.js +142 -19
- package/scripts/predeploy.js +83 -39
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +106 -3
- package/skills/ai-attack-surface/skill.md +18 -10
- package/skills/ai-c2-detection/skill.md +7 -2
- package/skills/ai-risk-management/skill.md +5 -4
- package/skills/api-security/skill.md +3 -3
- package/skills/attack-surface-pentest/skill.md +5 -5
- package/skills/cloud-security/skill.md +1 -1
- package/skills/compliance-theater/skill.md +8 -8
- package/skills/container-runtime-security/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +5 -1
- package/skills/email-security-anti-phishing/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +18 -18
- package/skills/framework-gap-analysis/skill.md +6 -6
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +4 -4
- package/skills/kernel-lpe-triage/skill.md +21 -2
- package/skills/mcp-agent-trust/skill.md +17 -10
- package/skills/mlops-security/skill.md +2 -1
- package/skills/ot-ics-security/skill.md +1 -1
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +7 -3
- package/skills/researcher/skill.md +20 -3
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +7 -7
- package/skills/skill-update-loop/skill.md +19 -3
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +11 -11
- package/skills/threat-modeling-methodology/skill.md +3 -3
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +51 -7
- package/vendor/blamejs/_PROVENANCE.json +4 -1
- package/vendor/blamejs/worker-pool.js +38 -0
package/lib/refresh-external.js
CHANGED
|
@@ -549,20 +549,31 @@ const GHSA_SOURCE = {
|
|
|
549
549
|
return ghsa.buildDiff(ctx);
|
|
550
550
|
},
|
|
551
551
|
async applyDiff(ctx, diffs) {
|
|
552
|
-
|
|
552
|
+
// v0.12.14 (audit B-F1): the prior shape mutated ctx.cveCatalog in
|
|
553
|
+
// memory but NEVER persisted to disk. Bulk `--source ghsa --apply`
|
|
554
|
+
// reported "applied: N updates" while the catalog file gained zero
|
|
555
|
+
// entries. Worse under `--swarm`: KEV's withCatalogLock would re-read
|
|
556
|
+
// catalog from disk INSIDE the lock and overwrite the unflushed
|
|
557
|
+
// in-memory mutations. Route through the same withCatalogLock helper
|
|
558
|
+
// that KEV/EPSS/NVD/RFC use (v0.12.12 concurrency fix).
|
|
559
|
+
const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
|
|
553
560
|
let updated = 0;
|
|
554
561
|
const errors = [];
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
562
|
+
await withCatalogLock(catalogPath, (catalog) => {
|
|
563
|
+
for (const d of diffs) {
|
|
564
|
+
if (d.field !== "_new_entry") continue;
|
|
565
|
+
if (!d.after || !d.id) continue;
|
|
566
|
+
if (catalog[d.id]) continue; // never overwrite existing entries
|
|
567
|
+
try {
|
|
568
|
+
catalog[d.id] = d.after;
|
|
569
|
+
updated++;
|
|
570
|
+
} catch (e) {
|
|
571
|
+
errors.push(`${d.id}: ${e.message}`);
|
|
572
|
+
}
|
|
564
573
|
}
|
|
565
|
-
|
|
574
|
+
ctx.cveCatalog = catalog;
|
|
575
|
+
return catalog;
|
|
576
|
+
});
|
|
566
577
|
return { updated, errors };
|
|
567
578
|
},
|
|
568
579
|
};
|
|
@@ -591,20 +602,27 @@ const OSV_SOURCE = {
|
|
|
591
602
|
return osv.buildDiff(ctx);
|
|
592
603
|
},
|
|
593
604
|
async applyDiff(ctx, diffs) {
|
|
594
|
-
//
|
|
605
|
+
// v0.12.14 (audit B-F1): same fix as GHSA — route the read-modify-write
|
|
606
|
+
// through withCatalogLock so writes actually land on disk and so
|
|
607
|
+
// concurrent --source osv --apply doesn't lose updates.
|
|
608
|
+
const catalogPath = ctx.cvePath || ABS("data/cve-catalog.json");
|
|
595
609
|
let updated = 0;
|
|
596
610
|
const errors = [];
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
611
|
+
await withCatalogLock(catalogPath, (catalog) => {
|
|
612
|
+
for (const d of diffs) {
|
|
613
|
+
if (d.field !== "_new_entry") continue;
|
|
614
|
+
if (!d.after || !d.id) continue;
|
|
615
|
+
if (catalog[d.id]) continue; // never overwrite existing entries
|
|
616
|
+
try {
|
|
617
|
+
catalog[d.id] = d.after;
|
|
618
|
+
updated++;
|
|
619
|
+
} catch (e) {
|
|
620
|
+
errors.push(`${d.id}: ${e.message}`);
|
|
621
|
+
}
|
|
606
622
|
}
|
|
607
|
-
|
|
623
|
+
ctx.cveCatalog = catalog;
|
|
624
|
+
return catalog;
|
|
625
|
+
});
|
|
608
626
|
return { updated, errors };
|
|
609
627
|
},
|
|
610
628
|
};
|
package/lib/refresh-network.js
CHANGED
|
@@ -97,6 +97,10 @@ function getJson(url, timeoutMs) {
|
|
|
97
97
|
function getBuffer(url, timeoutMs) {
|
|
98
98
|
return new Promise((resolve, reject) => {
|
|
99
99
|
const u = new URL(url);
|
|
100
|
+
const cap = (() => {
|
|
101
|
+
const env = parseInt(process.env.EXCEPTD_TARBALL_SIZE_CAP_BYTES, 10);
|
|
102
|
+
return Number.isFinite(env) && env > 0 ? env : 200 * 1024 * 1024;
|
|
103
|
+
})();
|
|
100
104
|
const req = https.get({
|
|
101
105
|
host: u.host, path: u.pathname + u.search,
|
|
102
106
|
headers: { "User-Agent": "exceptd/refresh-network" },
|
|
@@ -107,7 +111,17 @@ function getBuffer(url, timeoutMs) {
|
|
|
107
111
|
return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
|
|
108
112
|
}
|
|
109
113
|
const chunks = [];
|
|
110
|
-
|
|
114
|
+
let total = 0;
|
|
115
|
+
// v0.12.14 (audit F5): enforce streaming size cap so a hostile
|
|
116
|
+
// registry CDN can't stream gigabytes into RAM.
|
|
117
|
+
res.on("data", (c) => {
|
|
118
|
+
total += c.length;
|
|
119
|
+
if (total > cap) {
|
|
120
|
+
req.destroy(new Error(`tarball exceeds ${cap}-byte cap during streaming download`));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
chunks.push(c);
|
|
124
|
+
});
|
|
111
125
|
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
112
126
|
});
|
|
113
127
|
req.on("timeout", () => req.destroy(new Error("timeout")));
|
|
@@ -177,6 +191,36 @@ function verifyDetached(publicKeyObj, payload, sigB64) {
|
|
|
177
191
|
} catch { return false; }
|
|
178
192
|
}
|
|
179
193
|
|
|
194
|
+
// v0.12.14 (audit F1, F7): CRLF/BOM normalization mirrors lib/verify.js's
|
|
195
|
+
// normalize(). Duplicated here to keep refresh-network free of cross-module
|
|
196
|
+
// runtime deps. ANY change here MUST be mirrored in lib/verify.js +
|
|
197
|
+
// lib/sign.js — the three normalize() implementations form a byte-stability
|
|
198
|
+
// contract.
|
|
199
|
+
function normalizeSkillBytes(buf) {
|
|
200
|
+
let s = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
|
|
201
|
+
if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
|
|
202
|
+
return Buffer.from(s.replace(/\r\n/g, "\n"), "utf8");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Manifest path validation. Mirrors lib/verify.js validateSkillPath().
|
|
206
|
+
function validateManifestSkillPath(skillPath) {
|
|
207
|
+
if (typeof skillPath !== "string") throw new Error(`manifest skill.path must be a string, got ${typeof skillPath}`);
|
|
208
|
+
if (skillPath.includes("\\")) throw new Error(`manifest skill.path must use forward slashes: ${JSON.stringify(skillPath)}`);
|
|
209
|
+
if (!skillPath.startsWith("skills/")) throw new Error(`manifest skill.path must start with 'skills/': ${JSON.stringify(skillPath)}`);
|
|
210
|
+
if (skillPath.includes("..")) throw new Error(`manifest skill.path must not contain '..': ${JSON.stringify(skillPath)}`);
|
|
211
|
+
return skillPath;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// v0.12.14 (audit F5): tarball download size cap. A hostile registry CDN
|
|
215
|
+
// could stream gigabytes; Node buffers chunks in RAM until OOM. Current
|
|
216
|
+
// tarball is ~2 MB; 200 MB is generous defense-in-depth. Tunable via
|
|
217
|
+
// EXCEPTD_TARBALL_SIZE_CAP_BYTES for future growth.
|
|
218
|
+
const TARBALL_SIZE_CAP_BYTES_DEFAULT = 200 * 1024 * 1024;
|
|
219
|
+
function tarballSizeCap() {
|
|
220
|
+
const env = parseInt(process.env.EXCEPTD_TARBALL_SIZE_CAP_BYTES, 10);
|
|
221
|
+
return Number.isFinite(env) && env > 0 ? env : TARBALL_SIZE_CAP_BYTES_DEFAULT;
|
|
222
|
+
}
|
|
223
|
+
|
|
180
224
|
async function main() {
|
|
181
225
|
const opts = parseArgs(process.argv);
|
|
182
226
|
const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
@@ -204,6 +248,8 @@ async function main() {
|
|
|
204
248
|
const latestVersion = meta.version;
|
|
205
249
|
const tarballUrl = meta.dist && meta.dist.tarball;
|
|
206
250
|
const tarballShasum = meta.dist && meta.dist.shasum;
|
|
251
|
+
const tarballIntegrity = meta.dist && meta.dist.integrity; // SHA-512 SRI
|
|
252
|
+
const registrySignatures = Array.isArray(meta.dist && meta.dist.signatures) ? meta.dist.signatures : [];
|
|
207
253
|
if (!tarballUrl) {
|
|
208
254
|
emit({ ok: false, error: "registry metadata missing dist.tarball" }, opts.json);
|
|
209
255
|
process.exitCode = 2; return;
|
|
@@ -240,6 +286,32 @@ async function main() {
|
|
|
240
286
|
process.exitCode = 2; return;
|
|
241
287
|
}
|
|
242
288
|
|
|
289
|
+
// v0.12.14 (audit F5): defense-in-depth tarball size cap.
|
|
290
|
+
const sizeCap = tarballSizeCap();
|
|
291
|
+
if (tgzBuf.length > sizeCap) {
|
|
292
|
+
emit({ ok: false, error: `tarball exceeds size cap: ${tgzBuf.length} bytes > ${sizeCap} (EXCEPTD_TARBALL_SIZE_CAP_BYTES)` }, opts.json);
|
|
293
|
+
process.exitCode = 4; return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// v0.12.14 (audit F6, F3): verify SHA-512 SRI first (collision-resistant
|
|
297
|
+
// beyond SHA-1 reach), then SHA-1 shasum for compatibility, then dist.
|
|
298
|
+
// signatures[] (npm registry's Ed25519 signing key). Each layer is
|
|
299
|
+
// defense-in-depth — registry compromise that produces a SHA-1 collision
|
|
300
|
+
// doesn't trivially produce a SHA-512 collision; an attacker who breaks
|
|
301
|
+
// both still has to forge the npm-signing-key signature on the tarball.
|
|
302
|
+
if (tarballIntegrity && /^sha512-/.test(tarballIntegrity)) {
|
|
303
|
+
const expected = tarballIntegrity.slice("sha512-".length);
|
|
304
|
+
const actual = crypto.createHash("sha512").update(tgzBuf).digest("base64");
|
|
305
|
+
if (actual !== expected) {
|
|
306
|
+
emit({ ok: false, error: `tarball SHA-512 integrity mismatch: dist.integrity=${tarballIntegrity}, actual=sha512-${actual}` }, opts.json);
|
|
307
|
+
process.exitCode = 4; return;
|
|
308
|
+
}
|
|
309
|
+
} else if (tarballIntegrity) {
|
|
310
|
+
// Non-sha512 SRI (e.g. sha384) — emit a warning but accept; SHA-1 path
|
|
311
|
+
// below still gates.
|
|
312
|
+
progress(`note: dist.integrity present but not sha512: ${tarballIntegrity.slice(0, 40)}`, opts.json);
|
|
313
|
+
}
|
|
314
|
+
|
|
243
315
|
// Verify shasum (registry-provided integrity).
|
|
244
316
|
if (tarballShasum) {
|
|
245
317
|
const actual = crypto.createHash("sha1").update(tgzBuf).digest("hex");
|
|
@@ -290,22 +362,51 @@ async function main() {
|
|
|
290
362
|
try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
|
|
291
363
|
catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
|
|
292
364
|
|
|
365
|
+
// v0.12.14 (audit F1): the prior loop iterated `sk.id` + a fixed payload
|
|
366
|
+
// path `skills/<id>/SKILL.md`. Manifest entries actually expose `name` +
|
|
367
|
+
// `path` (a forward-slash relative path like `skills/<name>/skill.md`,
|
|
368
|
+
// lowercase). Result: the loop matched zero entries; `failures.length === 0`
|
|
369
|
+
// and `verifiedCount === 0` and the swap proceeded with `ok: true`. Every
|
|
370
|
+
// operator running `exceptd refresh --network` installed unverified bytes.
|
|
371
|
+
//
|
|
372
|
+
// Fixed shape mirrors lib/verify.js: iterate `manifest.skills[]` by
|
|
373
|
+
// `name` + `path` + `signature`. Apply the same CRLF/BOM normalization
|
|
374
|
+
// before verify (lib/verify.js normalize() — duplicated here to keep
|
|
375
|
+
// this path free of cross-module runtime deps). validateSkillPath()
|
|
376
|
+
// is also mirrored to defend against path traversal in a tampered
|
|
377
|
+
// tarball manifest before we resolve the path against the extracted
|
|
378
|
+
// tree.
|
|
293
379
|
const localKeyObj = crypto.createPublicKey(localPubKeyText);
|
|
294
380
|
const skills = Array.isArray(tarballManifest.skills) ? tarballManifest.skills : [];
|
|
295
381
|
const failures = [];
|
|
296
382
|
let verifiedCount = 0;
|
|
297
383
|
for (const sk of skills) {
|
|
298
|
-
if (!sk ||
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
384
|
+
if (!sk || typeof sk.name !== "string" || typeof sk.signature !== "string") {
|
|
385
|
+
failures.push({ name: sk?.name || "(missing name)", reason: "manifest entry missing name or signature" });
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
let normalizedPath;
|
|
389
|
+
try { normalizedPath = validateManifestSkillPath(sk.path); }
|
|
390
|
+
catch (e) { failures.push({ name: sk.name, reason: `manifest path rejected: ${e.message}` }); continue; }
|
|
391
|
+
const payloadEntry = entries.find((e) => stripPkg(e.name) === normalizedPath);
|
|
392
|
+
if (!payloadEntry) { failures.push({ name: sk.name, reason: `payload missing from tarball: ${normalizedPath}` }); continue; }
|
|
393
|
+
const normalized = normalizeSkillBytes(payloadEntry.body);
|
|
394
|
+
const ok = verifyDetached(localKeyObj, normalized, sk.signature);
|
|
304
395
|
if (ok) verifiedCount++;
|
|
305
|
-
else failures.push({
|
|
396
|
+
else failures.push({ name: sk.name, reason: "Ed25519 signature did not verify against local public key" });
|
|
306
397
|
}
|
|
307
398
|
|
|
308
|
-
if (
|
|
399
|
+
if (skills.length === 0) {
|
|
400
|
+
emit({
|
|
401
|
+
ok: false,
|
|
402
|
+
error: "tarball manifest.json declares zero skills — refusing to swap",
|
|
403
|
+
verified: 0,
|
|
404
|
+
total: 0,
|
|
405
|
+
hint: "A legitimate tarball must declare at least one skill. Treat this as a tarball-integrity failure.",
|
|
406
|
+
}, opts.json);
|
|
407
|
+
process.exitCode = 5; return;
|
|
408
|
+
}
|
|
409
|
+
if (verifiedCount !== skills.length || failures.length > 0) {
|
|
309
410
|
emit({
|
|
310
411
|
ok: false,
|
|
311
412
|
error: `${failures.length}/${skills.length} skill signature(s) failed verification — refusing to swap`,
|
|
@@ -317,6 +418,34 @@ async function main() {
|
|
|
317
418
|
process.exitCode = 5; return;
|
|
318
419
|
}
|
|
319
420
|
|
|
421
|
+
// v0.12.14 (audit F2): the swap loop replaces `data/` + `manifest.json` +
|
|
422
|
+
// `manifest-snapshot.json` in addition to `skills/`. None of those files
|
|
423
|
+
// are covered by the per-skill Ed25519 signature (which signs only the
|
|
424
|
+
// skill body bytes). The only integrity check between the registry and
|
|
425
|
+
// those bytes is SHA-1 dist.shasum — collision-broken since 2017 and
|
|
426
|
+
// weaker than `npm install` itself which honors dist.integrity (SHA-512
|
|
427
|
+
// SRI) + dist.signatures (npm Ed25519 registry key) + dist.attestations
|
|
428
|
+
// (sigstore SLSA provenance).
|
|
429
|
+
//
|
|
430
|
+
// Defense-in-depth: refuse the swap if the manifest skills list doesn't
|
|
431
|
+
// exactly match the skill payload entries present in the tarball. A
|
|
432
|
+
// malicious tarball that drops/adds a skill outside the manifest no
|
|
433
|
+
// longer slips through.
|
|
434
|
+
const manifestSkillPaths = new Set(skills.map(s => validateManifestSkillPath(s.path)));
|
|
435
|
+
const tarballSkillPayloads = entries
|
|
436
|
+
.map(e => stripPkg(e.name))
|
|
437
|
+
.filter(name => /^skills\/[^/]+\/skill\.md$/.test(name));
|
|
438
|
+
for (const tp of tarballSkillPayloads) {
|
|
439
|
+
if (!manifestSkillPaths.has(tp)) {
|
|
440
|
+
emit({
|
|
441
|
+
ok: false,
|
|
442
|
+
error: `tarball ships skill payload not declared in manifest: ${tp} — refusing to swap`,
|
|
443
|
+
hint: "Tarball+manifest divergence. Report at https://github.com/blamejs/exceptd-skills/issues.",
|
|
444
|
+
}, opts.json);
|
|
445
|
+
process.exitCode = 5; return;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
320
449
|
if (opts.dryRun) {
|
|
321
450
|
emit({
|
|
322
451
|
ok: true,
|
|
@@ -330,9 +459,16 @@ async function main() {
|
|
|
330
459
|
return;
|
|
331
460
|
}
|
|
332
461
|
|
|
333
|
-
//
|
|
462
|
+
// v0.12.14 (audit F4): the prior swap loop renamed targets one-by-one,
|
|
463
|
+
// and a mid-loop failure left the install half-applied with no automatic
|
|
464
|
+
// rollback. New shape: rename all old targets into a single backup dir
|
|
465
|
+
// first (so the install is empty-of-old before any new content is moved
|
|
466
|
+
// in); then rename all new targets in; on failure, walk the backup dir
|
|
467
|
+
// in reverse and restore.
|
|
334
468
|
const stageDir = fs.mkdtempSync(path.join(ROOT, ".refresh-network-"));
|
|
335
469
|
let written = 0;
|
|
470
|
+
let backupDir = null;
|
|
471
|
+
const completedSteps = []; // [{kind: 'backup' | 'install', target}]
|
|
336
472
|
try {
|
|
337
473
|
for (const entry of entries) {
|
|
338
474
|
const rel = stripPkg(entry.name);
|
|
@@ -347,19 +483,32 @@ async function main() {
|
|
|
347
483
|
written++;
|
|
348
484
|
}
|
|
349
485
|
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
486
|
+
// v0.12.14 (audit F10): use PID + random suffix in the backup dir name
|
|
487
|
+
// so concurrent refresh-network invocations don't collide on the
|
|
488
|
+
// millisecond clock.
|
|
489
|
+
const backupSuffix = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
490
|
+
backupDir = path.join(ROOT, `.refresh-network-backup-${Date.now()}-${backupSuffix}`);
|
|
353
491
|
fs.mkdirSync(backupDir);
|
|
492
|
+
const replaceList = ["data", "skills", "manifest.json", "manifest-snapshot.json"];
|
|
493
|
+
|
|
494
|
+
// Phase A: move all existing targets to backupDir. After this loop
|
|
495
|
+
// completes, the install root has none of the replaced targets.
|
|
354
496
|
for (const target of replaceList) {
|
|
355
|
-
const src = path.join(stageDir, target);
|
|
356
|
-
if (!fs.existsSync(src)) continue;
|
|
357
497
|
const dst = path.join(ROOT, target);
|
|
358
498
|
if (fs.existsSync(dst)) {
|
|
359
499
|
fs.renameSync(dst, path.join(backupDir, target));
|
|
500
|
+
completedSteps.push({ kind: "backup", target });
|
|
360
501
|
}
|
|
361
|
-
fs.renameSync(src, dst);
|
|
362
502
|
}
|
|
503
|
+
|
|
504
|
+
// Phase B: move all new targets in from stage.
|
|
505
|
+
for (const target of replaceList) {
|
|
506
|
+
const src = path.join(stageDir, target);
|
|
507
|
+
if (!fs.existsSync(src)) continue;
|
|
508
|
+
fs.renameSync(src, path.join(ROOT, target));
|
|
509
|
+
completedSteps.push({ kind: "install", target });
|
|
510
|
+
}
|
|
511
|
+
|
|
363
512
|
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
364
513
|
// Best-effort cleanup of backup dir — keep on disk for one cycle so
|
|
365
514
|
// operators can manually roll back if something feels off.
|
|
@@ -371,11 +520,38 @@ async function main() {
|
|
|
371
520
|
total_skills: skills.length,
|
|
372
521
|
files_written: written,
|
|
373
522
|
backup_dir: path.relative(ROOT, backupDir),
|
|
523
|
+
registry_signatures_present: registrySignatures.length,
|
|
374
524
|
message: `refreshed catalog from v${localVersion} → v${latestVersion} (${verifiedCount}/${skills.length} signatures verified). Backup at ${path.relative(ROOT, backupDir)} — safe to remove after verifying the new run.`,
|
|
375
525
|
}, opts.json);
|
|
376
526
|
} catch (e) {
|
|
527
|
+
// v0.12.14 (audit F4): walk completedSteps in reverse to undo partial work.
|
|
528
|
+
const rollbackErrors = [];
|
|
529
|
+
for (const step of [...completedSteps].reverse()) {
|
|
530
|
+
try {
|
|
531
|
+
if (step.kind === "install") {
|
|
532
|
+
// Remove the newly-installed copy.
|
|
533
|
+
fs.rmSync(path.join(ROOT, step.target), { recursive: true, force: true });
|
|
534
|
+
} else if (step.kind === "backup" && backupDir) {
|
|
535
|
+
// Restore from backup.
|
|
536
|
+
const src = path.join(backupDir, step.target);
|
|
537
|
+
const dst = path.join(ROOT, step.target);
|
|
538
|
+
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
|
539
|
+
}
|
|
540
|
+
} catch (re) {
|
|
541
|
+
rollbackErrors.push({ target: step.target, kind: step.kind, error: re.message });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
377
544
|
fs.rmSync(stageDir, { recursive: true, force: true });
|
|
378
|
-
emit({
|
|
545
|
+
emit({
|
|
546
|
+
ok: false,
|
|
547
|
+
error: `swap failed mid-rename: ${e.message}`,
|
|
548
|
+
rolled_back: rollbackErrors.length === 0,
|
|
549
|
+
rollback_errors: rollbackErrors,
|
|
550
|
+
backup_dir: backupDir ? path.relative(ROOT, backupDir) : null,
|
|
551
|
+
hint: rollbackErrors.length === 0
|
|
552
|
+
? "Auto-rollback completed. Install state matches pre-refresh. Re-run `exceptd refresh --network` or `npm install -g @blamejs/exceptd-skills` to retry."
|
|
553
|
+
: "Auto-rollback partially failed. Restore manually from the backup dir at the install root, or reinstall with `npm install -g @blamejs/exceptd-skills`.",
|
|
554
|
+
}, opts.json);
|
|
379
555
|
process.exitCode = 4;
|
|
380
556
|
}
|
|
381
557
|
}
|
package/lib/scoring.js
CHANGED
|
@@ -99,8 +99,15 @@ function scoreCustom(factors, opts) {
|
|
|
99
99
|
blast_radius = 0,
|
|
100
100
|
patch_available = false,
|
|
101
101
|
live_patch_available = false,
|
|
102
|
-
reboot_required = false
|
|
102
|
+
reboot_required = false,
|
|
103
|
+
// v0.12.15 (audit J F9): the CVE catalog field is `patch_required_reboot`
|
|
104
|
+
// but scoreCustom historically expected `reboot_required`. validate()
|
|
105
|
+
// already aliases at the call site; accept either spelling here so a
|
|
106
|
+
// direct caller passing the catalog entry doesn't silently lose the
|
|
107
|
+
// reboot factor.
|
|
108
|
+
patch_required_reboot,
|
|
103
109
|
} = factors || {};
|
|
110
|
+
const rebootFactor = (reboot_required === true) || (patch_required_reboot === true);
|
|
104
111
|
|
|
105
112
|
let score = 0;
|
|
106
113
|
score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
|
|
@@ -108,16 +115,22 @@ function scoreCustom(factors, opts) {
|
|
|
108
115
|
score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
|
|
109
116
|
score += active_exploitation === 'confirmed' ? RWEP_WEIGHTS.active_exploitation : 0;
|
|
110
117
|
score += active_exploitation === 'suspected' ? Math.floor(RWEP_WEIGHTS.active_exploitation / 2) : 0;
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
118
|
+
// v0.12.15 (audit J F1, F5): blast_radius numeric coercion must reject
|
|
119
|
+
// NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
|
|
120
|
+
// check passed NaN (which is `typeof === 'number'`) into `Math.min/max`
|
|
121
|
+
// which propagates NaN through the final clamp, defeating the [0,100]
|
|
122
|
+
// contract. Number.isFinite + Number() coercion catches all four classes:
|
|
123
|
+
// NaN, Infinity, undefined, stringified-number.
|
|
124
|
+
const brRaw = Number.isFinite(Number(blast_radius)) ? Number(blast_radius) : 0;
|
|
125
|
+
const brClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, brRaw));
|
|
115
126
|
score += brClamped;
|
|
116
127
|
score += patch_available ? RWEP_WEIGHTS.patch_available : 0;
|
|
117
128
|
score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
|
|
118
|
-
score +=
|
|
129
|
+
score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
|
|
119
130
|
|
|
120
|
-
|
|
131
|
+
// v0.12.15 (audit J F1): defense-in-depth clamp against any unforeseen
|
|
132
|
+
// NaN production above (negative weight + Infinity + math edge case).
|
|
133
|
+
const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
|
|
121
134
|
if (opts && opts.collectWarnings) {
|
|
122
135
|
return { score: clamped, _scoring_warnings: validateFactors(factors) };
|
|
123
136
|
}
|