@blamejs/exceptd-skills 0.18.7 → 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 +22 -0
- package/bin/exceptd.js +103 -63
- package/data/_indexes/_meta.json +2 -2
- package/lib/collectors/library-author.js +5 -2
- package/lib/playbook-runner.js +68 -10
- package/lib/prefetch.js +16 -1
- package/lib/refresh-network.js +34 -2
- package/lib/scoring.js +37 -10
- package/lib/validate-cve-catalog.js +11 -1
- package/lib/validate-indexes.js +76 -31
- package/lib/validate-playbooks.js +15 -6
- package/lib/verify.js +23 -6
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +14 -3
- package/orchestrator/index.js +31 -7
- package/orchestrator/scanner.js +8 -0
- package/package.json +1 -1
- package/sbom.cdx.json +42 -42
- package/scripts/build-indexes.js +27 -8
- package/scripts/validate-vendor-online.js +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.8 — 2026-06-20
|
|
4
|
+
|
|
5
|
+
Signature verification now fails closed on the two states an attacker with write access to `keys/` can engineer: a missing `keys/public.pem` makes `loadManifestValidated()` throw rather than return an unverified manifest, and a missing `keys/EXPECTED_FINGERPRINT` makes the manifest-signature check refuse rather than verify against a possibly-swapped key. The pin ships in the tarball and is committed, so its absence is treated as tampering. A missing pin fails closed unconditionally and the error directs you to restore the committed pin; `KEYS_ROTATED=1` governs a key-fingerprint mismatch (a deliberate rotation), which is the only state it applies to — a rotation updates the pin in place, it never removes it.
|
|
6
|
+
|
|
7
|
+
The compliance close phase no longer crashes when a jurisdiction obligation entry is malformed: a null/non-object entry is skipped during notification enrichment and synthesis instead of throwing; an obligation whose `window_hours` is not a finite number falls back to the pending-clock sentinel instead of computing an invalid date and aborting the phase; a `notification_action` whose `obligation_ref` resolves to nothing is dropped (the unmatched ref is surfaced as a diagnostic) rather than emitted with null jurisdiction/regulation; and a notify obligation with a non-number `window_hours` is surfaced as a diagnostic and skipped rather than synthesized into a malformed record. The threat-currency gate hard-blocks a non-numeric `threat_currency_score` (absent / null / NaN) instead of letting it slip past as "not less than 50"; `forceStale` still overrides. Air-gap mode flags an artifact that has no `air_gap_alternative` so its fall-back to the original (possibly network-bound) source is visible instead of a silent bypass. A CVE marked both `not_affected` and `fixed` in a VEX statement no longer appears as fixed while absent from the matched/baseline sets.
|
|
8
|
+
|
|
9
|
+
The `theater_score` exposed to `feeds_into` / escalation conditions now follows the same direction as the rest of the engine — a `theater` verdict (a compliance gap exists) scores high and a clear verdict scores low — so a condition such as `theater_score >= 50` chains exactly when a gap is found rather than the reverse.
|
|
10
|
+
|
|
11
|
+
RWEP scoring is consistent across its three surfaces: the derived score counts the `reboot_required` / `patch_required_reboot` alias once (no double-count), `validateFactors` normalizes `active_exploitation` and accepts the `patch_required_reboot` alias (matching the scorer), and the `compare()` explanation lists the AI factor for `ai_assisted_weaponization`, a stray-cased confirmed exploitation, and the reboot alias.
|
|
12
|
+
|
|
13
|
+
The orchestrator CSAF report emits a real `cvss_v3` block (base score + vector) for a catalogued CVE instead of a hardcoded `base_score: 0`, and never emits a null description or threat detail. The dispatch plan omits the per-entry `evidence` object when it has no content rather than emitting a bare `{}`, and it now keeps a plan entry for every distinct finding that routes to a skill — two different CVEs that route to the same skill each appear with their own evidence instead of all but the first being silently dropped; a parse-error finding routes directly to its skill rather than relying solely on the domain table. `attest diff`'s human output classifies the sidecar by tamper class first, so an unsigned-substitution attestation is not mislabeled as benign explicitly-unsigned, and prints `(no detail)` instead of `(null)` for an absent timestamp.
|
|
14
|
+
|
|
15
|
+
Empty-string flag values are rejected instead of silently degrading the command: `--mode ""`, `brief --phase ""`, and `--playbook ""` error rather than running with no scope. The catalog and playbook validators fail loud when a reference catalog or the playbook schema is absent, instead of silently skipping the framework-control, attack-ref, clock-start, and frameworks-in-scope checks — a partial tree can no longer pass an unresolvable reference or an invalid closed-vocabulary value.
|
|
16
|
+
|
|
17
|
+
`refresh --network`'s upstream prefetch retries a timed-out request (re-classified as a retryable network error) instead of dropping it, so a slow or rate-limiting upstream no longer pushes the scheduled refresh past its error budget. The tarball fetch builds its destination from the canonical npm URL plus a strict-semver-validated version rather than the registry-supplied `dist.tarball`, so a tampered metadata response cannot steer the download's host or path (content trust is unchanged: SHA-512/SRI + shasum + Ed25519); the vendor-source online check likewise validates every URL component (owner/repo shape, hex commit, traversal-free path) before fetching.
|
|
18
|
+
|
|
19
|
+
The publish-workflow audit now flags a floating (non-SHA) action reference even when the `uses:` line carries a trailing YAML comment — previously such refs were silently missed, reading as compliant.
|
|
20
|
+
|
|
21
|
+
The index gates fail closed on a degraded tree: a source that vanished between discovery and hashing, or a corrupted hash entry, is reported rather than crashing the validation; and `build-indexes --only` preserves the prior source hashes so a partial rebuild can no longer record stale outputs as fresh. Index files are written atomically.
|
|
22
|
+
|
|
23
|
+
Internal: the host OS-release lookup reads the file directly instead of spawning `cat`; the orchestrator watch-lock and the playbook mutex lock files are created with an explicit owner-only mode; the evidence-dir entry is opened through a single no-follow descriptor and validated on that descriptor (no check-then-reopen window); the index-output validator and a lock-reentrancy test read through a single descriptor; and a help-text assertion uses a literal substring instead of a partially-escaped regex.
|
|
24
|
+
|
|
3
25
|
## 0.18.7 — 2026-06-20
|
|
4
26
|
|
|
5
27
|
Escalation and feeds_into conditions that use an `any`/`all` quantifier prefix, a quoted `matches '…'` pattern, or a quoted `contains` member now evaluate instead of silently failing closed. Several catalog chains that depended on these forms — most visibly the compliance-theater → SBOM correlation — were dead and now fire; the feeds_into evaluation context also exposes the bare `blast_radius_score`, `theater_verdict`, and `compliance_theater_check` tokens the catalog references, so an engine-computed value is no longer lost to a `null` lookup. The SBOM → kernel / MCP / AI-API follow-ups now fire too: a matched CVE carries an `attack_class`, the analyze and close contexts expose the matched-CVE array the `any matched_cve.attack_class == …` rules quantify over, and the CVEs that map unambiguously to one of those deep-dive classes are tagged in the catalog — so an SBOM scan that surfaces an MCP-server RCE or a prompt-injection-to-RCE routes the operator into the matching deep dive, while an unclassified match routes nowhere rather than guessing. `IN […]` member lists, AND/OR joins, and outer parentheses are parsed quote-aware, so a comma, bracket, or operator inside a quoted value no longer tears the clause, and a `contains`/`IN` against a path that doesn't resolve records a diagnostic instead of evaluating to an invisible false.
|
package/bin/exceptd.js
CHANGED
|
@@ -1469,9 +1469,11 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1469
1469
|
}
|
|
1470
1470
|
runOpts.session_key = args["session-key"];
|
|
1471
1471
|
}
|
|
1472
|
-
if (args.mode) {
|
|
1472
|
+
if (args.mode !== undefined) {
|
|
1473
1473
|
// Bug #32: validate --mode against the accepted set. Previously
|
|
1474
|
-
// `--mode garbage` was silently accepted.
|
|
1474
|
+
// `--mode garbage` was silently accepted. Gate on `!== undefined` (not
|
|
1475
|
+
// truthiness) so `--mode ""` is also rejected by the set check below rather
|
|
1476
|
+
// than silently slipping past as a falsy value.
|
|
1475
1477
|
const VALID_MODES = ["self_service", "authorized_pentest", "ir_response", "ctf", "research", "compliance_audit"];
|
|
1476
1478
|
if (!VALID_MODES.includes(args.mode)) {
|
|
1477
1479
|
// v0.13.2: did-you-mean on flag-value typos (Levenshtein ≤ 2).
|
|
@@ -2842,7 +2844,10 @@ function cmdLint(runner, args, runOpts, pretty) {
|
|
|
2842
2844
|
|
|
2843
2845
|
function cmdBrief(runner, args, runOpts, pretty) {
|
|
2844
2846
|
const playbookId = args._[0];
|
|
2845
|
-
|
|
2847
|
+
// Preserve an explicit empty string (don't coerce "" -> null) so the
|
|
2848
|
+
// accepted-set check below rejects `--phase ""` instead of silently treating
|
|
2849
|
+
// it as "no filter" and emitting the full brief. Only an OMITTED flag is null.
|
|
2850
|
+
const onlyPhase = args.phase === undefined ? null : args.phase;
|
|
2846
2851
|
|
|
2847
2852
|
// v0.12.9 (P2 #7 from production smoke): refuse garbage values to --phase.
|
|
2848
2853
|
// Pre-v0.12.9 `brief secrets --phase foo` silently accepted any string and
|
|
@@ -2965,6 +2970,11 @@ function cmdVerifyAttestation(runner, args, runOpts, pretty) {
|
|
|
2965
2970
|
}
|
|
2966
2971
|
|
|
2967
2972
|
function cmdPlan(runner, args, runOpts, pretty) {
|
|
2973
|
+
// Reject an empty --playbook value rather than letting the truthy gate below
|
|
2974
|
+
// coerce it to null and silently plan across ALL playbooks (wrong scope).
|
|
2975
|
+
if (args.playbook === "" || (Array.isArray(args.playbook) && args.playbook.some(p => p === ""))) {
|
|
2976
|
+
return emitError("plan: --playbook was given an empty value; pass a playbook id, or omit --playbook to plan across all.", { verb: "plan", flag: "playbook" }, pretty);
|
|
2977
|
+
}
|
|
2968
2978
|
let playbookIds = args.playbook
|
|
2969
2979
|
? (Array.isArray(args.playbook) ? args.playbook : [args.playbook])
|
|
2970
2980
|
: null;
|
|
@@ -4243,55 +4253,83 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
4243
4253
|
return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
|
|
4244
4254
|
}
|
|
4245
4255
|
// The path.resolve check above only catches `..` traversal in the
|
|
4246
|
-
// joined path;
|
|
4247
|
-
//
|
|
4248
|
-
//
|
|
4249
|
-
//
|
|
4250
|
-
//
|
|
4251
|
-
//
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
// ordinary directories), bypassing the symlink refusal above. Use
|
|
4266
|
-
// realpathSync to resolve the entry and confirm it still lives under
|
|
4267
|
-
// the resolved evidence-dir — the realpath approach is portable
|
|
4268
|
-
// (catches POSIX symlinks too, defense in depth) and works regardless
|
|
4269
|
-
// of whether the OS exposes reparse-point bits.
|
|
4270
|
-
let realEntry;
|
|
4271
|
-
try { realEntry = fs.realpathSync(entryPath); }
|
|
4272
|
-
catch (e) {
|
|
4273
|
-
return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
|
|
4274
|
-
}
|
|
4275
|
-
if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
|
|
4276
|
-
return emitError(
|
|
4277
|
-
`run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
|
|
4278
|
-
{ entry: f, resolved_to: realEntry },
|
|
4279
|
-
pretty
|
|
4280
|
-
);
|
|
4281
|
-
}
|
|
4282
|
-
// Hardlink defense in depth: no clean cross-platform refusal exists —
|
|
4283
|
-
// hardlinks are indistinguishable from regular files at the inode
|
|
4284
|
-
// level. Surface a stderr warning when nlink > 1 so the operator is
|
|
4285
|
-
// aware a second name may point at the same file. Not a refusal —
|
|
4286
|
-
// legitimate use cases (atomic rename, package-manager dedup) produce
|
|
4287
|
-
// nlink > 1 without malicious intent.
|
|
4288
|
-
if (lst.nlink > 1) {
|
|
4289
|
-
process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${lst.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
|
|
4256
|
+
// joined path; reading the path would still follow symlinks, so a
|
|
4257
|
+
// `<pb-id>.json -> /etc/shadow` symlink inside the dir would slurp the
|
|
4258
|
+
// target. Rather than lstat/realpath the PATH and then re-open it
|
|
4259
|
+
// (a check-then-use TOCTOU window), open a single O_NOFOLLOW descriptor
|
|
4260
|
+
// FIRST and make every subsequent decision about that exact descriptor.
|
|
4261
|
+
// O_NOFOLLOW refuses a symlinked leaf at open (ELOOP) on POSIX; on
|
|
4262
|
+
// Windows it is a no-op, so the fstat type check + realpath gate below
|
|
4263
|
+
// carry the junction/symlink defense. Opening before any path stat means
|
|
4264
|
+
// the bytes read come from the inode we validated, not a path that could
|
|
4265
|
+
// be re-pointed between check and read.
|
|
4266
|
+
let efd;
|
|
4267
|
+
try {
|
|
4268
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
4269
|
+
efd = fs.openSync(entryPath, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
4270
|
+
} catch (e) {
|
|
4271
|
+
const why = e.code === "ELOOP"
|
|
4272
|
+
? "symbolic link refused (symlinks bypass the directory-confinement check)"
|
|
4273
|
+
: e.message;
|
|
4274
|
+
return emitError(`run: --evidence-dir entry ${f}: open failed: ${why}`, { entry: f }, pretty);
|
|
4290
4275
|
}
|
|
4291
4276
|
try {
|
|
4292
|
-
|
|
4277
|
+
const st = fs.fstatSync(efd);
|
|
4278
|
+
if (!st.isFile()) {
|
|
4279
|
+
return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing (symlink / junction / dir / fifo bypass the directory-confinement check).`, { entry: f }, pretty);
|
|
4280
|
+
}
|
|
4281
|
+
// Hardlink defense in depth: no clean cross-platform refusal exists —
|
|
4282
|
+
// hardlinks are indistinguishable from regular files at the inode
|
|
4283
|
+
// level. Surface a stderr warning when nlink > 1 so the operator is
|
|
4284
|
+
// aware a second name may point at the same file. Not a refusal —
|
|
4285
|
+
// legitimate use cases (atomic rename, package-manager dedup) produce
|
|
4286
|
+
// nlink > 1 without malicious intent.
|
|
4287
|
+
if (st.nlink > 1) {
|
|
4288
|
+
process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${st.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
|
|
4289
|
+
}
|
|
4290
|
+
// Read the bytes from `efd` FIRST — the descriptor was opened
|
|
4291
|
+
// O_NOFOLLOW and fstat-confirmed a regular file, so this reads the exact
|
|
4292
|
+
// inode we validated. Reading before the realpath gate (rather than
|
|
4293
|
+
// checking the path then reading) means there is no check-then-use
|
|
4294
|
+
// window at all; the containment gate below decides whether to USE the
|
|
4295
|
+
// bytes, and discards them otherwise.
|
|
4296
|
+
const raw = fs.readFileSync(efd, "utf8");
|
|
4297
|
+
// Symlink refusal. O_NOFOLLOW already rejects a symlinked leaf at open on
|
|
4298
|
+
// POSIX (ELOOP), but it is a no-op on Windows, where the open follows the
|
|
4299
|
+
// link. Detect and refuse a symlink explicitly via lstat — regardless of
|
|
4300
|
+
// where it points — so a symlinked entry is never accepted. This runs
|
|
4301
|
+
// AFTER the descriptor read (the bytes are dropped on refusal), so there
|
|
4302
|
+
// is no path-check-before-read TOCTOU window.
|
|
4303
|
+
let lst;
|
|
4304
|
+
try { lst = fs.lstatSync(entryPath); }
|
|
4305
|
+
catch (e) {
|
|
4306
|
+
return emitError(`run: --evidence-dir entry ${f}: lstat failed: ${e.message}`, null, pretty);
|
|
4307
|
+
}
|
|
4308
|
+
if (lst.isSymbolicLink()) {
|
|
4309
|
+
return emitError(`run: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, { entry: f }, pretty);
|
|
4310
|
+
}
|
|
4311
|
+
// Windows directory junctions are reparse-point dirs that
|
|
4312
|
+
// lstat().isSymbolicLink() returns FALSE for, and O_NOFOLLOW is a
|
|
4313
|
+
// no-op there; realpath resolves the entry and confirms it still lives
|
|
4314
|
+
// under the resolved evidence-dir. A target that escapes the dir is
|
|
4315
|
+
// refused and the already-read bytes are dropped unused.
|
|
4316
|
+
let realEntry;
|
|
4317
|
+
try { realEntry = fs.realpathSync(entryPath); }
|
|
4318
|
+
catch (e) {
|
|
4319
|
+
return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
|
|
4320
|
+
}
|
|
4321
|
+
if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
|
|
4322
|
+
return emitError(
|
|
4323
|
+
`run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
|
|
4324
|
+
{ entry: f, resolved_to: realEntry },
|
|
4325
|
+
pretty
|
|
4326
|
+
);
|
|
4327
|
+
}
|
|
4328
|
+
bundle[pbId] = JSON.parse(raw);
|
|
4293
4329
|
} catch (e) {
|
|
4294
|
-
return emitError(`run: failed to
|
|
4330
|
+
return emitError(`run: failed to read --evidence-dir entry ${f}: ${e.message}`, null, pretty);
|
|
4331
|
+
} finally {
|
|
4332
|
+
try { fs.closeSync(efd); } catch { /* already closed / invalid fd */ }
|
|
4295
4333
|
}
|
|
4296
4334
|
}
|
|
4297
4335
|
}
|
|
@@ -5641,8 +5679,8 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
5641
5679
|
lines.push(`attest diff: ${obj.session_id} (${obj.playbook_id})`);
|
|
5642
5680
|
const icon = obj.status === "unchanged" ? "[ok]" : "[i DRIFTED]";
|
|
5643
5681
|
lines.push(`\n${icon} status=${obj.status}`);
|
|
5644
|
-
lines.push(` prior: ${obj.prior_evidence_hash} (${obj.prior_captured_at})`);
|
|
5645
|
-
lines.push(` replay: ${obj.replay_evidence_hash} (${obj.replayed_at})`);
|
|
5682
|
+
lines.push(` prior: ${obj.prior_evidence_hash} (${obj.prior_captured_at || '(no detail)'})`);
|
|
5683
|
+
lines.push(` replay: ${obj.replay_evidence_hash} (${obj.replayed_at || '(no detail)'})`);
|
|
5646
5684
|
if (obj.replay_classification) {
|
|
5647
5685
|
lines.push(` replay classification: ${obj.replay_classification} RWEP=${obj.replay_rwep_adjusted ?? 0}`);
|
|
5648
5686
|
}
|
|
@@ -5748,13 +5786,13 @@ function renderAttestDiff(obj) {
|
|
|
5748
5786
|
lines.push(` artifact diff: ${ad.added?.length ?? 0} added, ${ad.removed?.length ?? 0} removed, ${ad.changed?.length ?? 0} changed, ${ad.unchanged_count ?? 0} unchanged (of ${ad.total_compared ?? 0})`);
|
|
5749
5787
|
lines.push(` signal diff: ${sd.changed?.length ?? 0} changed, ${sd.unchanged_count ?? 0} unchanged (of ${sd.total_compared ?? 0})`);
|
|
5750
5788
|
if (obj.sidecar_verify) {
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
lines.push(` sidecar verify: ${
|
|
5789
|
+
// Use the canonical classifier, which checks tamper_class BEFORE the reason
|
|
5790
|
+
// strings. The previous inline logic matched reason.includes("explicitly
|
|
5791
|
+
// unsigned") first, so an unsigned-SUBSTITUTION attack (whose reason also
|
|
5792
|
+
// contains "explicitly unsigned" but carries tamper_class:
|
|
5793
|
+
// 'unsigned-substitution') was mislabeled as the benign 'explicitly-unsigned'
|
|
5794
|
+
// — hiding the substitution signal in the human diff output.
|
|
5795
|
+
lines.push(` sidecar verify: ${classifySidecarVerify(obj.sidecar_verify)}`);
|
|
5758
5796
|
}
|
|
5759
5797
|
return lines.join("\n");
|
|
5760
5798
|
}
|
|
@@ -6685,11 +6723,13 @@ function cmdDiscover(runner, args, runOpts, pretty) {
|
|
|
6685
6723
|
let hostDistro = null;
|
|
6686
6724
|
if (hostPlatform === "linux") {
|
|
6687
6725
|
try {
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
const
|
|
6726
|
+
// Read the file directly instead of spawning `cat` (a process to do what
|
|
6727
|
+
// fs does, and a static-analysis "unnecessary use of cat" flag).
|
|
6728
|
+
const osRelease = fs.readFileSync("/etc/os-release", "utf8");
|
|
6729
|
+
if (osRelease) {
|
|
6730
|
+
const idMatch = osRelease.match(/^ID=(.+)$/m);
|
|
6731
|
+
const verMatch = osRelease.match(/^VERSION_ID=(.+)$/m);
|
|
6732
|
+
const prettyMatch = osRelease.match(/^PRETTY_NAME=(.+)$/m);
|
|
6693
6733
|
hostDistro = {
|
|
6694
6734
|
id: idMatch ? idMatch[1].replace(/^"|"$/g, "") : null,
|
|
6695
6735
|
version_id: verMatch ? verMatch[1].replace(/^"|"$/g, "") : null,
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-06-
|
|
3
|
+
"generated_at": "2026-06-20T22:42:01.751Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 64,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "9e87cb1282ac35f2d0564d0eb4571583874e01fb74890989ced47bf50c099ab3",
|
|
8
8
|
"README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
|
|
9
9
|
"data/atlas-ttps.json": "5bc59e23d6c2defa54168de161a0825299b9cc4a49c6b26df2dae70b4f42eedf",
|
|
10
10
|
"data/attack-techniques.json": "53c6f248760eecb11a0354f74ab467a5814e95075a686b9b3bf18c34e2f7435e",
|
|
@@ -194,8 +194,11 @@ function scanPublishWorkflow(content, rel) {
|
|
|
194
194
|
if (line.length > 4096) continue;
|
|
195
195
|
// Allow optional leading `-` from a YAML list item: `- uses: ...`.
|
|
196
196
|
// `^[ \t]*(?:-[ \t]*)?` anchors the indentation once, then an optional
|
|
197
|
-
// `- ` list marker — no overlapping `\s*` runs that backtrack.
|
|
198
|
-
|
|
197
|
+
// `- ` list marker — no overlapping `\s*` runs that backtrack. Exclude `#`
|
|
198
|
+
// from the capture and drop the end anchor (mirroring the cicd collector) so
|
|
199
|
+
// a `uses: x@v1 # comment` line is still matched — the `$`-anchored pattern
|
|
200
|
+
// silently missed every floating ref that carried a trailing YAML comment.
|
|
201
|
+
const m = line.match(/^[ \t]*(?:-[ \t]*)?uses:\s*['"]?([^'"\s#]+)['"]?/);
|
|
199
202
|
if (!m) continue;
|
|
200
203
|
const ref = m[1];
|
|
201
204
|
if (ref.startsWith("./") || ref.startsWith("./.github/")) continue; // local
|
package/lib/playbook-runner.js
CHANGED
|
@@ -249,15 +249,22 @@ function preflight(playbook, runOpts = {}) {
|
|
|
249
249
|
|
|
250
250
|
// 1. Currency gate
|
|
251
251
|
const score = meta.threat_currency_score;
|
|
252
|
-
|
|
252
|
+
// A non-numeric score (absent / null / NaN from a malformed _meta) must HARD
|
|
253
|
+
// BLOCK, not slip through: `undefined < 50` is false, which would silently
|
|
254
|
+
// bypass the staleness gate on exactly the playbooks whose currency metadata
|
|
255
|
+
// is broken. Treat "no usable score" as the most-stale state.
|
|
256
|
+
const scoreUsable = typeof score === 'number' && !Number.isNaN(score);
|
|
257
|
+
if ((!scoreUsable || score < 50) && !runOpts.forceStale) {
|
|
253
258
|
return {
|
|
254
259
|
ok: false,
|
|
255
260
|
blocked_by: 'currency',
|
|
256
|
-
reason:
|
|
261
|
+
reason: scoreUsable
|
|
262
|
+
? `threat_currency_score = ${score} (< 50). Hard-blocked. Pass forceStale=true to override.`
|
|
263
|
+
: `threat_currency_score is absent or non-numeric (${JSON.stringify(score)}). Hard-blocked — a playbook without a usable currency score is treated as stale. Fix _meta.threat_currency_score, or pass forceStale=true to override.`,
|
|
257
264
|
issues
|
|
258
265
|
};
|
|
259
266
|
}
|
|
260
|
-
if (score < 70) {
|
|
267
|
+
if (scoreUsable && score < 70) {
|
|
261
268
|
issues.push({ kind: 'currency_warn', message: `threat_currency_score = ${score} (< 70). Threat model is stale — recommend running the skill-update-loop before relying on findings.` });
|
|
262
269
|
}
|
|
263
270
|
|
|
@@ -649,7 +656,12 @@ function look(playbookId, directiveId, runOpts = {}) {
|
|
|
649
656
|
// Surface the air-gap alternative as the primary source when air_gap_mode
|
|
650
657
|
// is active, so the agent doesn't accidentally hit the network.
|
|
651
658
|
source: airGap && a.air_gap_alternative ? a.air_gap_alternative : a.source,
|
|
652
|
-
_original_source: a.source
|
|
659
|
+
_original_source: a.source,
|
|
660
|
+
// In air-gap mode an artifact with NO air_gap_alternative silently keeps
|
|
661
|
+
// its original (possibly network-bound) source — defeating air-gap with no
|
|
662
|
+
// signal. Flag it so the agent treats the source as not-offline-verified
|
|
663
|
+
// and the gap is observable instead of a silent network fallback.
|
|
664
|
+
...(airGap && !a.air_gap_alternative ? { air_gap_alternative_missing: true } : {}),
|
|
653
665
|
})),
|
|
654
666
|
collection_scope: l.collection_scope,
|
|
655
667
|
environment_assumptions: l.environment_assumptions || [],
|
|
@@ -1019,8 +1031,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
|
|
|
1019
1031
|
: [];
|
|
1020
1032
|
// VEX-fixed CVEs remain in matched/catalog arrays but get annotated
|
|
1021
1033
|
// with vex_status:'fixed' downstream so consumers see them as resolved.
|
|
1034
|
+
// Source from catalogBaselineCves (post-vexFilter survivors), NOT allCves: a
|
|
1035
|
+
// CVE the operator marked BOTH not_affected (dropped by vexFilter) AND fixed
|
|
1036
|
+
// is contradictory, and listing it as fixed while it's absent from
|
|
1037
|
+
// matched/baseline would have it appear in fixed_cves but nowhere else.
|
|
1022
1038
|
const vexFixedIds = vexFixed
|
|
1023
|
-
?
|
|
1039
|
+
? catalogBaselineCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
|
|
1024
1040
|
: [];
|
|
1025
1041
|
|
|
1026
1042
|
// Build correlation map: cve_id -> array of "indicator_hit:<id>" / "signal:<id>" reasons.
|
|
@@ -1744,8 +1760,16 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1744
1760
|
};
|
|
1745
1761
|
const enrichNotification = (na) => {
|
|
1746
1762
|
const obligation = (g.jurisdiction_obligations || []).find(o =>
|
|
1763
|
+
o && typeof o === 'object' &&
|
|
1747
1764
|
`${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
|
|
1748
1765
|
);
|
|
1766
|
+
// A non-empty obligation_ref that resolves to nothing leaves the
|
|
1767
|
+
// jurisdiction / regulation / deadline fields null below — surface it as a
|
|
1768
|
+
// runtime_error so the unmatched ref is observable, not a silent null record.
|
|
1769
|
+
if (!obligation && na && typeof na.obligation_ref === 'string' && na.obligation_ref &&
|
|
1770
|
+
Array.isArray(runOpts && runOpts._runErrors)) {
|
|
1771
|
+
pushRunError(runOpts._runErrors, { kind: 'unresolved_obligation_ref', obligation_ref: na.obligation_ref }, { dedupeKey: x => x.obligation_ref || '' });
|
|
1772
|
+
}
|
|
1749
1773
|
// Thread runOpts + the engine-computed classification through so
|
|
1750
1774
|
// computeClockStart can check operator_consent.explicit before
|
|
1751
1775
|
// auto-stamping detect_confirmed, and so an engine-confirmed detection
|
|
@@ -1776,9 +1800,23 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1776
1800
|
&& autoStartEvent
|
|
1777
1801
|
&& eventReady
|
|
1778
1802
|
&& !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
|
|
1779
|
-
|
|
1803
|
+
// window_hours must be a finite number before it enters the deadline
|
|
1804
|
+
// arithmetic — a malformed obligation with an undefined/null/non-number
|
|
1805
|
+
// window_hours would otherwise compute `getTime() + NaN` and crash the
|
|
1806
|
+
// close phase at `new Date(NaN).toISOString()`. Runtime validation of the
|
|
1807
|
+
// playbook is not enforced, so guard here independently of the schema.
|
|
1808
|
+
const windowValid = obligation && typeof obligation.window_hours === 'number' && Number.isFinite(obligation.window_hours);
|
|
1809
|
+
const deadline = obligation && clockValid && windowValid
|
|
1780
1810
|
? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
|
|
1781
1811
|
: 'pending_clock_start_event';
|
|
1812
|
+
// A notification_action whose obligation_ref was specified but resolves to
|
|
1813
|
+
// no obligation is an internally-inconsistent playbook (the schema requires
|
|
1814
|
+
// obligation_ref to name a real govern obligation). The unmatched ref is
|
|
1815
|
+
// already surfaced as a runtime_error above; drop the record rather than
|
|
1816
|
+
// emit a null-jurisdiction/regulation entry that pollutes the deadline list.
|
|
1817
|
+
if (!obligation && na && typeof na.obligation_ref === 'string' && na.obligation_ref) {
|
|
1818
|
+
return null;
|
|
1819
|
+
}
|
|
1782
1820
|
return {
|
|
1783
1821
|
...na,
|
|
1784
1822
|
// Carry obligation metadata forward so each notification entry is
|
|
@@ -1827,7 +1865,10 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1827
1865
|
})(),
|
|
1828
1866
|
};
|
|
1829
1867
|
};
|
|
1830
|
-
|
|
1868
|
+
// enrichNotification returns null for an unresolved specified obligation_ref
|
|
1869
|
+
// (already surfaced as a runtime_error); filter those out so the notification
|
|
1870
|
+
// list never carries a null-jurisdiction record.
|
|
1871
|
+
const notificationActions = (c.notification_actions || []).map(enrichNotification).filter(Boolean);
|
|
1831
1872
|
|
|
1832
1873
|
// A govern obligation that declares a notification duty but has no
|
|
1833
1874
|
// matching close.notification_actions entry would otherwise never surface
|
|
@@ -1839,15 +1880,27 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1839
1880
|
// can tell it apart from a playbook-authored action.
|
|
1840
1881
|
const coveredObligationRefs = new Set((c.notification_actions || []).map(na => na.obligation_ref));
|
|
1841
1882
|
for (const o of (g.jurisdiction_obligations || [])) {
|
|
1883
|
+
if (!o || typeof o !== 'object') continue; // skip a null/malformed obligation rather than crash close() during synthesis
|
|
1842
1884
|
if (!String(o.obligation || '').startsWith('notify')) continue;
|
|
1885
|
+
// A notify obligation with a non-number window_hours is malformed: it would
|
|
1886
|
+
// synthesize a "…/… undefinedh" ref and could not produce a real deadline.
|
|
1887
|
+
// Surface it as a runtime_error and skip synthesis rather than emit a bogus
|
|
1888
|
+
// record (the deadline guard in enrichNotification also prevents the crash).
|
|
1889
|
+
if (typeof o.window_hours !== 'number' || !Number.isFinite(o.window_hours)) {
|
|
1890
|
+
if (Array.isArray(runOpts && runOpts._runErrors)) {
|
|
1891
|
+
pushRunError(runOpts._runErrors, { kind: 'malformed_obligation_window_hours', obligation: `${o.jurisdiction}/${o.regulation}` }, { dedupeKey: x => x.obligation || '' });
|
|
1892
|
+
}
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1843
1895
|
const ref = `${o.jurisdiction}/${o.regulation} ${o.window_hours}h`;
|
|
1844
1896
|
if (coveredObligationRefs.has(ref)) continue;
|
|
1845
|
-
|
|
1897
|
+
const synthesized = enrichNotification({
|
|
1846
1898
|
obligation_ref: ref,
|
|
1847
1899
|
recipient: null,
|
|
1848
1900
|
draft_notification: null,
|
|
1849
1901
|
synthesized_from_obligation: true,
|
|
1850
|
-
})
|
|
1902
|
+
});
|
|
1903
|
+
if (synthesized) notificationActions.push(synthesized);
|
|
1851
1904
|
}
|
|
1852
1905
|
|
|
1853
1906
|
// exception_generation — evaluate trigger.
|
|
@@ -1999,7 +2052,12 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
|
|
|
1999
2052
|
// Govern-phase jurisdiction obligations so feeds_into conditions like
|
|
2000
2053
|
// `… AND jurisdiction_obligations contains 'EU'` (framework → sbom) resolve.
|
|
2001
2054
|
jurisdiction_obligations: (g && g.jurisdiction_obligations) || (playbook.phases && playbook.phases.govern && playbook.phases.govern.jurisdiction_obligations) || [],
|
|
2002
|
-
theater_score
|
|
2055
|
+
// theater_score follows lib/framework-gap.js's convention: HIGH = more
|
|
2056
|
+
// theater detected (worse). A 'theater' verdict (a gap exists) is the
|
|
2057
|
+
// concerning case, so it scores 100; a clear verdict scores 0. (Earlier
|
|
2058
|
+
// this was inverted, so a feeds_into condition like `theater_score >= 50`
|
|
2059
|
+
// would have failed to fire exactly when a gap was found.)
|
|
2060
|
+
theater_score: analyzeResult.compliance_theater_check?.verdict === 'theater' ? 100 : 0,
|
|
2003
2061
|
// Top-level matched_cve array so the shipped sbom feeds_into quantifiers
|
|
2004
2062
|
// (`any matched_cve.attack_class == 'kernel-lpe'`, … IN ['ai-c2', …]) re-root
|
|
2005
2063
|
// at each matched CVE. Without it the quantifier head resolves null and the
|
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,
|
|
@@ -309,6 +310,20 @@ async function timedFetch(url, headers = {}) {
|
|
|
309
310
|
const lastModified = res.headers.get("last-modified") || null;
|
|
310
311
|
const json = await res.json();
|
|
311
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;
|
|
312
327
|
} finally {
|
|
313
328
|
clearTimeout(t);
|
|
314
329
|
}
|
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) {
|
|
@@ -446,11 +464,25 @@ async function main() {
|
|
|
446
464
|
process.exitCode = 3; return;
|
|
447
465
|
}
|
|
448
466
|
|
|
449
|
-
|
|
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);
|
|
450
482
|
|
|
451
483
|
let tgzBuf;
|
|
452
484
|
try {
|
|
453
|
-
tgzBuf = await getBuffer(
|
|
485
|
+
tgzBuf = await getBuffer(canonicalUrl, opts.timeoutMs);
|
|
454
486
|
} catch (e) {
|
|
455
487
|
emit({ ok: false, error: `tarball fetch failed: ${e.message}` }, opts.json);
|
|
456
488
|
process.exitCode = 2; return;
|
package/lib/scoring.js
CHANGED
|
@@ -179,10 +179,15 @@ function validateFactors(factors) {
|
|
|
179
179
|
const boolFields = ['cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
|
|
180
180
|
'patch_available', 'live_patch_available', 'reboot_required'];
|
|
181
181
|
for (const f of boolFields) {
|
|
182
|
-
// The catalog field `ai_assisted_weaponization` satisfies `ai_assisted_weapon
|
|
182
|
+
// The catalog field `ai_assisted_weaponization` satisfies `ai_assisted_weapon`,
|
|
183
|
+
// and `patch_required_reboot` satisfies `reboot_required` — the same aliasing
|
|
184
|
+
// scoreCustom/deriveRwepFromFactors honor, so validateFactors must accept a
|
|
185
|
+
// block that supplies only the alias instead of flagging it "missing".
|
|
183
186
|
const present = (f === 'ai_assisted_weapon')
|
|
184
187
|
? (factors.ai_assisted_weapon ?? factors.ai_assisted_weaponization)
|
|
185
|
-
:
|
|
188
|
+
: (f === 'reboot_required')
|
|
189
|
+
? (factors.reboot_required ?? factors.patch_required_reboot)
|
|
190
|
+
: factors[f];
|
|
186
191
|
if (present === undefined || present === null) {
|
|
187
192
|
warnings.push(`${f}: missing (treated as false; explicit value recommended)`);
|
|
188
193
|
} else if (typeof present !== 'boolean') {
|
|
@@ -190,10 +195,18 @@ function validateFactors(factors) {
|
|
|
190
195
|
}
|
|
191
196
|
}
|
|
192
197
|
const aeAllowed = ['none', 'unknown', 'suspected', 'theoretical', 'confirmed'];
|
|
193
|
-
|
|
198
|
+
const aeRaw = factors.active_exploitation;
|
|
199
|
+
if (aeRaw === undefined || aeRaw === null) {
|
|
194
200
|
warnings.push("active_exploitation: missing (treated as 'none')");
|
|
195
|
-
} else
|
|
196
|
-
|
|
201
|
+
} else {
|
|
202
|
+
// Normalize (trim + lowercase) before the vocab check so validateFactors
|
|
203
|
+
// accepts exactly what scoreCustom/resolveActiveExploitation accept — a
|
|
204
|
+
// stray-cased 'Confirmed' / ' confirmed ' must not be flagged here while the
|
|
205
|
+
// scorer consumes it, or the two surfaces disagree.
|
|
206
|
+
const aeNorm = typeof aeRaw === 'string' ? aeRaw.trim().toLowerCase() : aeRaw;
|
|
207
|
+
if (!aeAllowed.includes(aeNorm)) {
|
|
208
|
+
warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(aeRaw)}`);
|
|
209
|
+
}
|
|
197
210
|
}
|
|
198
211
|
// NaN diagnostics. The prior message read "expected number,
|
|
199
212
|
// got number (null)" because `JSON.stringify(NaN) === 'null'` and `typeof
|
|
@@ -346,7 +359,7 @@ function deriveRwepFromFactors(factors) {
|
|
|
346
359
|
if (values.length === 0) return 0;
|
|
347
360
|
const aeAllowed = new Set(['none', 'unknown', 'suspected', 'theoretical', 'confirmed']);
|
|
348
361
|
const hasBooleanOrLadder = values.some(
|
|
349
|
-
(v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v)),
|
|
362
|
+
(v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v.trim().toLowerCase())),
|
|
350
363
|
);
|
|
351
364
|
if (hasBooleanOrLadder) {
|
|
352
365
|
return scoreCustom(factors);
|
|
@@ -367,6 +380,12 @@ function deriveRwepFromFactors(factors) {
|
|
|
367
380
|
let sum = 0;
|
|
368
381
|
for (const [k, v] of Object.entries(factors)) {
|
|
369
382
|
if (typeof v !== 'number' || !Number.isFinite(v)) continue;
|
|
383
|
+
// reboot_required and patch_required_reboot are aliases for the SAME
|
|
384
|
+
// post-weight contribution (scoreCustom collapses them). A block carrying
|
|
385
|
+
// both must count it once; summing both double-counts the reboot weight,
|
|
386
|
+
// inflating the derived RWEP past the formula AND past the stored score the
|
|
387
|
+
// validate() divergence gate compares against.
|
|
388
|
+
if (k === 'patch_required_reboot' && Object.prototype.hasOwnProperty.call(factors, 'reboot_required')) continue;
|
|
370
389
|
if (k === 'blast_radius') {
|
|
371
390
|
sum += Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, v));
|
|
372
391
|
} else {
|
|
@@ -447,12 +466,17 @@ function compare(cveId, catalog, opts) {
|
|
|
447
466
|
explanation = 'CVSS absent — RWEP is the only usable score for this CVE; no CVSS comparison is possible. Backfill cvss_score in the catalog to enable the comparison.';
|
|
448
467
|
} else if (delta > 10) {
|
|
449
468
|
explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
|
|
469
|
+
// The explanation must list every factor scoreCustom actually counts, via
|
|
470
|
+
// the same aliases/normalization — otherwise a CVE whose RWEP is driven by
|
|
471
|
+
// ai_assisted_weaponization (not ai_discovered), a stray-cased 'Confirmed',
|
|
472
|
+
// or the patch_required_reboot alias shows a higher RWEP with no stated
|
|
473
|
+
// reason for the delta.
|
|
450
474
|
const driving = [];
|
|
451
475
|
if (entry.cisa_kev) driving.push('CISA KEV (+25)');
|
|
452
476
|
if (entry.poc_available) driving.push('public PoC (+20)');
|
|
453
|
-
if (entry.ai_discovered) driving.push('AI-discovered (+15 weaponization)');
|
|
454
|
-
if (entry.active_exploitation === 'confirmed') driving.push('confirmed exploitation (+20)');
|
|
455
|
-
if (entry.patch_required_reboot && !entry.live_patch_available) driving.push('reboot required (+5)');
|
|
477
|
+
if (entry.ai_discovered || entry.ai_assisted_weaponization) driving.push('AI-discovered (+15 weaponization)');
|
|
478
|
+
if (String(entry.active_exploitation || '').trim().toLowerCase() === 'confirmed') driving.push('confirmed exploitation (+20)');
|
|
479
|
+
if ((entry.reboot_required || entry.patch_required_reboot) && !entry.live_patch_available) driving.push('reboot required (+5)');
|
|
456
480
|
explanation += driving.join(', ');
|
|
457
481
|
explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
|
|
458
482
|
} else if (delta < -10) {
|
|
@@ -612,7 +636,10 @@ function validate(catalog) {
|
|
|
612
636
|
blast_radius: entry.rwep_factors ? entry.rwep_factors.blast_radius : 0,
|
|
613
637
|
patch_available: entry.patch_available,
|
|
614
638
|
live_patch_available: entry.live_patch_available,
|
|
615
|
-
reboot_required
|
|
639
|
+
// Mirror the reboot alias scoreCustom itself honors (reboot_required OR
|
|
640
|
+
// patch_required_reboot): passing only patch_required_reboot would drop a
|
|
641
|
+
// top-level reboot_required field and compute a divergent expected RWEP.
|
|
642
|
+
reboot_required: entry.reboot_required || entry.patch_required_reboot
|
|
616
643
|
});
|
|
617
644
|
if (Math.abs(calculatedRwep - entry.rwep_score) > 5) {
|
|
618
645
|
errors.push(`${cveId}: rwep_score ${entry.rwep_score} diverges from calculated ${calculatedRwep} by more than 5 — verify factors`);
|