@blamejs/exceptd-skills 0.16.29 → 0.16.30
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 +14 -0
- package/bin/exceptd.js +212 -12
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/crypto.json +6 -0
- package/lib/collectors/README.md +3 -2
- package/lib/cross-ref-api.js +96 -31
- package/lib/playbook-runner.js +247 -48
- package/lib/prefetch.js +78 -6
- package/lib/refresh-external.js +103 -3
- package/lib/scoring.js +49 -5
- package/lib/validate-cve-catalog.js +14 -2
- package/lib/validate-playbooks.js +133 -38
- package/manifest.json +53 -53
- package/package.json +1 -1
- package/sbom.cdx.json +34 -34
- package/scripts/run-e2e-scenarios.js +41 -11
package/lib/refresh-external.js
CHANGED
|
@@ -301,6 +301,13 @@ const KEV_SOURCE = {
|
|
|
301
301
|
errors.push(`KEV: no local entry for ${d.id}`);
|
|
302
302
|
continue;
|
|
303
303
|
}
|
|
304
|
+
// A de-listing flagged for review (a curated entry with strong
|
|
305
|
+
// exploitation signal that upstream KEV no longer lists) is surfaced
|
|
306
|
+
// in the report but NOT auto-applied: the curated flag, factor, score
|
|
307
|
+
// and dates are left intact so a maintainer can confirm a genuine
|
|
308
|
+
// CISA removal vs a transient / incomplete feed before the entry is
|
|
309
|
+
// downgraded. Skip the write here.
|
|
310
|
+
if (d.review_only) continue;
|
|
304
311
|
catalog[d.id][d.field] = d.after;
|
|
305
312
|
// A cisa_kev flip changes the entry's RWEP: the KEV factor carries
|
|
306
313
|
// RWEP_WEIGHTS.cisa_kev points, and the catalog invariant requires
|
|
@@ -864,6 +871,28 @@ function readCachedJson(cacheDir, source, id, opts) {
|
|
|
864
871
|
return parsed;
|
|
865
872
|
}
|
|
866
873
|
|
|
874
|
+
// A curated entry carries strong human-curated exploitation signal when its
|
|
875
|
+
// active_exploitation is confirmed/suspected, or it has a non-empty PoC
|
|
876
|
+
// description / verification sources. A de-listing of such an entry is far
|
|
877
|
+
// more likely a transient or incomplete upstream feed than a genuine CISA
|
|
878
|
+
// removal, so it is surfaced for review rather than auto-applied.
|
|
879
|
+
function hasCuratedExploitSignal(entry) {
|
|
880
|
+
if (!entry || typeof entry !== "object") return false;
|
|
881
|
+
const ae = typeof entry.active_exploitation === "string" ? entry.active_exploitation.toLowerCase() : "";
|
|
882
|
+
if (ae === "confirmed" || ae === "suspected") return true;
|
|
883
|
+
if (typeof entry.poc_description === "string" && entry.poc_description.trim()) return true;
|
|
884
|
+
if (Array.isArray(entry.verification_sources) && entry.verification_sources.length > 0) return true;
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Below this many entries the cached KEV feed is treated as truncated /
|
|
889
|
+
// incomplete rather than a genuine CISA snapshot. CISA KEV has carried well
|
|
890
|
+
// over a thousand entries since 2021 and only grows; a feed this small means
|
|
891
|
+
// a partial download or a tampered cache, and trusting it to de-list curated
|
|
892
|
+
// entries would silently erase confirmed-exploitation intel. De-listings are
|
|
893
|
+
// refused wholesale when the feed is implausibly small.
|
|
894
|
+
const KEV_FEED_MIN_PLAUSIBLE = 500;
|
|
895
|
+
|
|
867
896
|
function kevDiffFromCache(ctx) {
|
|
868
897
|
const feed = readCachedJson(ctx.cacheDir, "kev", "known_exploited_vulnerabilities", { forceStale: ctx.forceStale });
|
|
869
898
|
if (!feed) {
|
|
@@ -877,12 +906,39 @@ function kevDiffFromCache(ctx) {
|
|
|
877
906
|
if (v.dateAdded) kevDates.set(v.cveID, v.dateAdded);
|
|
878
907
|
}
|
|
879
908
|
}
|
|
909
|
+
// An implausibly small feed cannot be trusted to de-list curated entries.
|
|
910
|
+
// First-listings (false→true) still flow — a small feed never invents new
|
|
911
|
+
// exploitation; only the de-list direction is suppressed.
|
|
912
|
+
const feedComplete = kevSet.size >= KEV_FEED_MIN_PLAUSIBLE;
|
|
880
913
|
const diffs = [];
|
|
881
914
|
for (const [id, entry] of Object.entries(ctx.cveCatalog)) {
|
|
882
915
|
if (!/^CVE-\d{4}-\d{4,7}$/.test(id)) continue;
|
|
883
916
|
const upstream = kevSet.has(id);
|
|
884
917
|
if (typeof entry.cisa_kev === "boolean" && entry.cisa_kev !== upstream) {
|
|
885
|
-
|
|
918
|
+
const isDelist = entry.cisa_kev === true && upstream === false;
|
|
919
|
+
// Symmetric with the NVD path's curated-downgrade guard: never silently
|
|
920
|
+
// regress curated exploitation intel against an upstream that disagrees.
|
|
921
|
+
// A de-listing of a curated entry with strong exploitation signal, OR
|
|
922
|
+
// any de-listing when the feed is implausibly small, is re-tagged as a
|
|
923
|
+
// review-only diff — surfaced in the report so a maintainer confirms a
|
|
924
|
+
// genuine CISA removal, but NOT auto-applied (applyDiff skips
|
|
925
|
+
// review_only diffs, leaving cisa_kev / rwep / dates intact).
|
|
926
|
+
if (isDelist && (!feedComplete || hasCuratedExploitSignal(entry))) {
|
|
927
|
+
diffs.push({
|
|
928
|
+
id,
|
|
929
|
+
field: "cisa_kev",
|
|
930
|
+
before: entry.cisa_kev,
|
|
931
|
+
after: upstream,
|
|
932
|
+
severity: "high",
|
|
933
|
+
review_only: true,
|
|
934
|
+
kev_delist_review: true,
|
|
935
|
+
note: !feedComplete
|
|
936
|
+
? `KEV de-listing held for review: cached feed has only ${kevSet.size} entries (< ${KEV_FEED_MIN_PLAUSIBLE}), likely incomplete. Confirm against a complete CISA KEV snapshot before de-listing.`
|
|
937
|
+
: "KEV de-listing held for review: entry carries curated exploitation signal (active_exploitation / PoC / verification sources). Confirm a genuine CISA removal vs a transient feed gap before de-listing.",
|
|
938
|
+
});
|
|
939
|
+
} else {
|
|
940
|
+
diffs.push({ id, field: "cisa_kev", before: entry.cisa_kev, after: upstream, severity: "high" });
|
|
941
|
+
}
|
|
886
942
|
}
|
|
887
943
|
const upDate = kevDates.get(id) || null;
|
|
888
944
|
// First listings arrive with a null local date — emit the date diff
|
|
@@ -1553,10 +1609,54 @@ async function main() {
|
|
|
1553
1609
|
// refresh loop and could egress + write refresh-report.json despite
|
|
1554
1610
|
// --no-network.
|
|
1555
1611
|
if (opts.prefetch || opts.noNetwork) {
|
|
1612
|
+
// Validate --source against the prefetchable (cache-backed) subset BEFORE
|
|
1613
|
+
// delegating to prefetch.js. prefetch.js only knows kev/nvd/epss/rfc/pins;
|
|
1614
|
+
// the refresh-only sources (ghsa, osv, advisories, cve-regression-watcher)
|
|
1615
|
+
// resolve advisories by live id lookup and have no cache layer. Without
|
|
1616
|
+
// this guard, `refresh --prefetch --source osv` reached prefetch.js and
|
|
1617
|
+
// died with `prefetch: fatal: unknown source "osv"` — leaking the internal
|
|
1618
|
+
// verb name (the operator typed `refresh`) and calling a source "unknown"
|
|
1619
|
+
// that the refresh help just listed as valid. Emit a refresh-prefixed,
|
|
1620
|
+
// actionable message instead and forward only the cacheable subset.
|
|
1621
|
+
const PREFETCHABLE = new Set(["kev", "nvd", "epss", "rfc", "pins"]);
|
|
1622
|
+
let forwardSource = opts.source;
|
|
1623
|
+
if (opts.source) {
|
|
1624
|
+
const names = opts.source.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1625
|
+
if (names.length === 0) {
|
|
1626
|
+
process.stderr.write(JSON.stringify({
|
|
1627
|
+
ok: false,
|
|
1628
|
+
verb: "refresh",
|
|
1629
|
+
error: `refresh: --source given but resolved to no source names (empty or comma-only value); prefetchable sources: ${[...PREFETCHABLE].join(",")}`,
|
|
1630
|
+
}) + "\n");
|
|
1631
|
+
process.exitCode = 2;
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
const unsupported = names.filter((n) => !PREFETCHABLE.has(n) && ALL_SOURCES[n]);
|
|
1635
|
+
const unknown = names.filter((n) => !PREFETCHABLE.has(n) && !ALL_SOURCES[n]);
|
|
1636
|
+
if (unknown.length > 0) {
|
|
1637
|
+
process.stderr.write(JSON.stringify({
|
|
1638
|
+
ok: false,
|
|
1639
|
+
verb: "refresh",
|
|
1640
|
+
error: `refresh: unknown source ${unknown.map((n) => `"${n}"`).join(", ")}; prefetchable sources: ${[...PREFETCHABLE].join(",")}`,
|
|
1641
|
+
}) + "\n");
|
|
1642
|
+
process.exitCode = 2;
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
if (unsupported.length > 0) {
|
|
1646
|
+
process.stderr.write(JSON.stringify({
|
|
1647
|
+
ok: false,
|
|
1648
|
+
verb: "refresh",
|
|
1649
|
+
error: `refresh: source ${unsupported.map((n) => `"${n}"`).join(", ")} has no prefetch cache layer (live id lookup only); prefetchable sources: ${[...PREFETCHABLE].join(",")}`,
|
|
1650
|
+
}) + "\n");
|
|
1651
|
+
process.exitCode = 2;
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
forwardSource = names.join(",");
|
|
1655
|
+
}
|
|
1556
1656
|
const { spawnSync } = require("child_process");
|
|
1557
1657
|
const pfArgs = [require.resolve("./prefetch.js")];
|
|
1558
1658
|
if (opts.noNetwork) pfArgs.push("--no-network");
|
|
1559
|
-
if (
|
|
1659
|
+
if (forwardSource) pfArgs.push("--source", forwardSource);
|
|
1560
1660
|
if (opts.quiet) pfArgs.push("--quiet");
|
|
1561
1661
|
const r = spawnSync(process.execPath, pfArgs, { stdio: "inherit" });
|
|
1562
1662
|
process.exitCode = r.status == null ? 1 : r.status;
|
|
@@ -1740,4 +1840,4 @@ if (require.main === module) {
|
|
|
1740
1840
|
});
|
|
1741
1841
|
}
|
|
1742
1842
|
|
|
1743
|
-
module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic, nvdDiffFromCache };
|
|
1843
|
+
module.exports = { ALL_SOURCES, loadCtx, parseArgs, seedSingleAdvisory, withCatalogLock, writeJsonAtomic, nvdDiffFromCache, kevDiffFromCache };
|
package/lib/scoring.js
CHANGED
|
@@ -214,7 +214,23 @@ function scoreCustom(factors, opts) {
|
|
|
214
214
|
// which propagates NaN through the final clamp, defeating the [0,100]
|
|
215
215
|
// contract. Number.isFinite + Number() coercion catches all four classes:
|
|
216
216
|
// NaN, Infinity, undefined, stringified-number.
|
|
217
|
-
|
|
217
|
+
// Match validateFactors' contract exactly. Bare `Number()` coercion would
|
|
218
|
+
// turn `true` into 1 and a single-element array like `[7]` into 7 — both of
|
|
219
|
+
// which validateFactors rejects as "expected number" — so the scorer would
|
|
220
|
+
// silently add a blast contribution the validator says is invalid. Accept
|
|
221
|
+
// only a finite number or a trimmed-nonempty numeric string; everything
|
|
222
|
+
// else (boolean, array, object, NaN, Infinity, empty/whitespace string)
|
|
223
|
+
// contributes 0.
|
|
224
|
+
let brRaw = 0;
|
|
225
|
+
if (typeof blast_radius === 'number' && Number.isFinite(blast_radius)) {
|
|
226
|
+
brRaw = blast_radius;
|
|
227
|
+
} else if (
|
|
228
|
+
typeof blast_radius === 'string' &&
|
|
229
|
+
blast_radius.trim() !== '' &&
|
|
230
|
+
Number.isFinite(Number(blast_radius))
|
|
231
|
+
) {
|
|
232
|
+
brRaw = Number(blast_radius);
|
|
233
|
+
}
|
|
218
234
|
const brClamped = Math.max(0, Math.min(RWEP_WEIGHTS.blast_radius, brRaw));
|
|
219
235
|
score += brClamped;
|
|
220
236
|
score += patch_available ? RWEP_WEIGHTS.patch_available : 0;
|
|
@@ -303,9 +319,21 @@ function compare(cveId, catalog, opts) {
|
|
|
303
319
|
} else {
|
|
304
320
|
rwep = entry.rwep_score;
|
|
305
321
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
322
|
+
// Normalize cvss before any arithmetic. An absent or non-finite cvss_score
|
|
323
|
+
// must not flow into `cvss * 10` — `undefined * 10 === NaN` poisons the
|
|
324
|
+
// delta, and NaN fails every `delta > 10` / `delta < -10` band, so the
|
|
325
|
+
// entry would otherwise fall through to the "broadly aligned" arm and
|
|
326
|
+
// assert an alignment that was never computed. Treat absent CVSS as
|
|
327
|
+
// not-comparable instead.
|
|
328
|
+
const cvss = (typeof entry.cvss_score === 'number' && Number.isFinite(entry.cvss_score))
|
|
329
|
+
? entry.cvss_score
|
|
330
|
+
: null;
|
|
331
|
+
const cvssAbsent = cvss == null;
|
|
332
|
+
const cvssEquivalent = cvssAbsent ? 0 : cvss * 10;
|
|
333
|
+
// delta is null (not NaN) when there is no CVSS to compare against, so the
|
|
334
|
+
// emitted result serializes cleanly and never claims a numeric divergence
|
|
335
|
+
// that does not exist.
|
|
336
|
+
const delta = cvssAbsent ? null : rwep - cvssEquivalent;
|
|
309
337
|
|
|
310
338
|
// narrow the "broadly aligned" band from ±20 to ±10. The old
|
|
311
339
|
// ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
|
|
@@ -321,6 +349,11 @@ function compare(cveId, catalog, opts) {
|
|
|
321
349
|
// catalog gap rather than a false sense of alignment.
|
|
322
350
|
if ((rwep == null || rwep === 0) && (cvss == null || cvss === 0)) {
|
|
323
351
|
explanation = 'No scoring signal — both RWEP and CVSS are zero/null. Investigate the catalog entry; this CVE has no usable risk score.';
|
|
352
|
+
} else if (cvssAbsent) {
|
|
353
|
+
// RWEP carries a real signal but there is no CVSS to compare it against.
|
|
354
|
+
// The two scores are not comparable, so do not assert alignment or a
|
|
355
|
+
// divergence direction — surface the missing CVSS instead.
|
|
356
|
+
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.';
|
|
324
357
|
} else if (delta > 10) {
|
|
325
358
|
explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
|
|
326
359
|
const driving = [];
|
|
@@ -460,13 +493,24 @@ function validate(catalog) {
|
|
|
460
493
|
active_exploitation: RWEP_WEIGHTS.active_exploitation * aeMultiplier,
|
|
461
494
|
patch_available: entry.patch_available === true ? RWEP_WEIGHTS.patch_available : 0,
|
|
462
495
|
live_patch_available: entry.live_patch_available === true ? RWEP_WEIGHTS.live_patch_available : 0,
|
|
463
|
-
reboot_required: entry.patch_required_reboot === true ? RWEP_WEIGHTS.reboot_required : 0,
|
|
464
496
|
};
|
|
465
497
|
for (const [k, want] of Object.entries(implied)) {
|
|
466
498
|
if (k in f && typeof f[k] === 'number' && f[k] !== want) {
|
|
467
499
|
errors.push(`${cveId}: rwep_factors.${k} is ${f[k]} but the entry's source fields imply ${want}`);
|
|
468
500
|
}
|
|
469
501
|
}
|
|
502
|
+
// The reboot contribution has ONE implied weight but TWO accepted
|
|
503
|
+
// spellings — `reboot_required` (canonical) and `patch_required_reboot`
|
|
504
|
+
// (the catalog field-name alias scoreCustom also honors). Check both
|
|
505
|
+
// against that single weight; keying only on `reboot_required` let a
|
|
506
|
+
// contradictory value stored under the alias slip past the coherence
|
|
507
|
+
// gate, inflating the derived score.
|
|
508
|
+
const rebootWant = entry.patch_required_reboot === true ? RWEP_WEIGHTS.reboot_required : 0;
|
|
509
|
+
for (const rebootKey of ['reboot_required', 'patch_required_reboot']) {
|
|
510
|
+
if (rebootKey in f && typeof f[rebootKey] === 'number' && f[rebootKey] !== rebootWant) {
|
|
511
|
+
errors.push(`${cveId}: rwep_factors.${rebootKey} is ${f[rebootKey]} but the entry's source fields imply ${rebootWant}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
470
514
|
}
|
|
471
515
|
const calculatedRwep = scoreCustom({
|
|
472
516
|
cisa_kev: entry.cisa_kev,
|
|
@@ -264,8 +264,20 @@ function additionalChecks(key, entry, ctx) {
|
|
|
264
264
|
for (const { field, set, file } of REF_FIELDS) {
|
|
265
265
|
if (!set) continue; // catalog absent — skip silently (defense-in-depth)
|
|
266
266
|
const refs = entry[field];
|
|
267
|
-
|
|
268
|
-
|
|
267
|
+
// Most ref fields are arrays of id strings (atlas_refs, cwe_refs, ...),
|
|
268
|
+
// but framework_control_gaps is a map keyed BY control id. An
|
|
269
|
+
// Array.isArray-only guard skipped the map form entirely, so orphaned
|
|
270
|
+
// control ids never surfaced. Derive the candidate ids from whichever
|
|
271
|
+
// shape the field uses: array elements, or the keys of a plain object.
|
|
272
|
+
let ids;
|
|
273
|
+
if (Array.isArray(refs)) {
|
|
274
|
+
ids = refs;
|
|
275
|
+
} else if (refs && typeof refs === 'object') {
|
|
276
|
+
ids = Object.keys(refs);
|
|
277
|
+
} else {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
for (const ref of ids) {
|
|
269
281
|
if (typeof ref !== 'string') continue;
|
|
270
282
|
if (!set.has(ref)) {
|
|
271
283
|
warnings.push(
|
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
* - phases.detect.indicators[].cve_ref → data/cve-catalog.json
|
|
26
26
|
* - phases.detect.false_positive_profile[].indicator_id
|
|
27
27
|
* → phases.detect.indicators[].id
|
|
28
|
+
* - directives[].applies_to.cve → data/cve-catalog.json keys
|
|
29
|
+
* - directives[].applies_to.atlas_ttp → data/atlas-ttps.json keys
|
|
30
|
+
* - directives[].applies_to.attack_technique → data/attack-techniques.json
|
|
31
|
+
* - directives[].phase_overrides.govern.jurisdiction_obligations[].clock_starts
|
|
32
|
+
* → closed clock_starts vocabulary
|
|
33
|
+
* - directives[].phase_overrides.direct.rwep_threshold → ordering + range
|
|
34
|
+
* - directives[].phase_overrides.close.notification_actions[].obligation_ref
|
|
35
|
+
* → effective jurisdiction_obligations
|
|
28
36
|
*
|
|
29
37
|
* Internal consistency:
|
|
30
38
|
* - Indicator ids are unique within a playbook.
|
|
@@ -438,28 +446,15 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
438
446
|
}
|
|
439
447
|
|
|
440
448
|
// rwep_threshold ordering. Hard error — a misordered threshold actively
|
|
441
|
-
// breaks the scoring path.
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
`phases.direct.rwep_threshold: ordering violation — expected close <= monitor <= escalate, got close=${rwep.close} monitor=${rwep.monitor} escalate=${rwep.escalate}`,
|
|
451
|
-
);
|
|
452
|
-
}
|
|
453
|
-
for (const [k, v] of [
|
|
454
|
-
['close', rwep.close],
|
|
455
|
-
['monitor', rwep.monitor],
|
|
456
|
-
['escalate', rwep.escalate],
|
|
457
|
-
]) {
|
|
458
|
-
if (v < 0 || v > 100) {
|
|
459
|
-
err(`phases.direct.rwep_threshold.${k}: ${v} outside 0..100`);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
}
|
|
449
|
+
// breaks the scoring path. Factored into a helper so the same check runs
|
|
450
|
+
// against the playbook-level direct phase AND any directive-level
|
|
451
|
+
// phase_overrides.direct copy the runner deep-merges at run time.
|
|
452
|
+
checkRwepThreshold(direct.rwep_threshold, 'phases.direct.rwep_threshold');
|
|
453
|
+
|
|
454
|
+
// clock_starts closed-vocab against the base govern phase. Factored into a
|
|
455
|
+
// helper for the same reason — an override-supplied jurisdiction_obligations
|
|
456
|
+
// copy must pass the same closed-vocabulary gate.
|
|
457
|
+
checkClockStarts(govern.jurisdiction_obligations, 'phases.govern.jurisdiction_obligations');
|
|
463
458
|
|
|
464
459
|
// notification_actions obligation_ref resolution.
|
|
465
460
|
const obligationKeys = new Set(
|
|
@@ -553,22 +548,9 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
553
548
|
);
|
|
554
549
|
}
|
|
555
550
|
|
|
556
|
-
//
|
|
557
|
-
// enum is an error, not a warning, so a typo cannot ship
|
|
558
|
-
//
|
|
559
|
-
// validator keeps at warning, these vocabularies are fixed and load-bearing
|
|
560
|
-
// (clock_starts decides when a notification deadline starts counting;
|
|
561
|
-
// frameworks_in_scope drives gap-analysis routing).
|
|
562
|
-
if (ctx.clockStartsEnum) {
|
|
563
|
-
for (const [i, o] of (govern.jurisdiction_obligations || []).entries()) {
|
|
564
|
-
if (!o || typeof o !== 'object') continue;
|
|
565
|
-
if (o.clock_starts !== undefined && !ctx.clockStartsEnum.has(o.clock_starts)) {
|
|
566
|
-
err(
|
|
567
|
-
`phases.govern.jurisdiction_obligations[${i}].clock_starts: invalid value ${JSON.stringify(o.clock_starts)} — not in closed vocabulary ${JSON.stringify([...ctx.clockStartsEnum])}`,
|
|
568
|
-
);
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
}
|
|
551
|
+
// frameworks_in_scope closed vocabulary. A value outside the schema's closed
|
|
552
|
+
// enum is an error, not a warning, so a typo cannot ship — frameworks_in_scope
|
|
553
|
+
// drives gap-analysis routing.
|
|
572
554
|
if (ctx.frameworksEnum) {
|
|
573
555
|
for (const [i, f] of (domain.frameworks_in_scope || []).entries()) {
|
|
574
556
|
if (typeof f === 'string' && !ctx.frameworksEnum.has(f)) {
|
|
@@ -579,7 +561,120 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
579
561
|
}
|
|
580
562
|
}
|
|
581
563
|
|
|
564
|
+
// Directive-level coverage. A directive's applies_to fields and its
|
|
565
|
+
// phase_overrides both reach the runner live (the runner selects directives
|
|
566
|
+
// by id, deep-merges phase_overrides into the base phase, and surfaces
|
|
567
|
+
// applies_to in the discovery API) but neither was cross-referenced or
|
|
568
|
+
// re-validated — so a stale CVE/TTP reference or a tampered override
|
|
569
|
+
// (bogus clock_starts, out-of-range rwep_threshold) shipped past this gate
|
|
570
|
+
// even though the identical content is a hard error at playbook level.
|
|
571
|
+
for (const [i, d] of (playbook.directives || []).entries()) {
|
|
572
|
+
if (!d || typeof d !== 'object') continue;
|
|
573
|
+
const label = d.id ? `directives[${i}] (${d.id})` : `directives[${i}]`;
|
|
574
|
+
|
|
575
|
+
// applies_to.{cve,atlas_ttp,attack_technique} resolution, mirroring the
|
|
576
|
+
// domain-ref checks at warning severity (promoted to error under --strict).
|
|
577
|
+
const at = d.applies_to;
|
|
578
|
+
if (at && typeof at === 'object') {
|
|
579
|
+
if (at.cve && !ctx.cveKeys.has(at.cve)) {
|
|
580
|
+
warn(`${label}.applies_to.cve: unresolved "${at.cve}" (not in data/cve-catalog.json)`);
|
|
581
|
+
}
|
|
582
|
+
if (at.atlas_ttp && !ctx.atlasKeys.has(at.atlas_ttp)) {
|
|
583
|
+
warn(`${label}.applies_to.atlas_ttp: unresolved "${at.atlas_ttp}" (not in data/atlas-ttps.json)`);
|
|
584
|
+
}
|
|
585
|
+
// Guard the attack catalog the same way domain.attack_refs does:
|
|
586
|
+
// attack-techniques.json is loaded via readJsonIfExists and may be null.
|
|
587
|
+
if (at.attack_technique && ctx.attackKeys && !ctx.attackKeys.has(at.attack_technique)) {
|
|
588
|
+
warn(`${label}.applies_to.attack_technique: unresolved "${at.attack_technique}" (not in data/attack-techniques.json)`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// phase_overrides re-validation. The runner merges these into the base
|
|
593
|
+
// phase before govern()/close() consume them, so an override-supplied
|
|
594
|
+
// clock_starts or rwep_threshold must pass the same gates as the base
|
|
595
|
+
// phase or the regulatory clock / scoring path breaks at run time.
|
|
596
|
+
const ov = d.phase_overrides;
|
|
597
|
+
if (ov && typeof ov === 'object') {
|
|
598
|
+
if (ov.govern && typeof ov.govern === 'object') {
|
|
599
|
+
checkClockStarts(
|
|
600
|
+
ov.govern.jurisdiction_obligations,
|
|
601
|
+
`${label}.phase_overrides.govern.jurisdiction_obligations`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
if (ov.direct && typeof ov.direct === 'object') {
|
|
605
|
+
checkRwepThreshold(
|
|
606
|
+
ov.direct.rwep_threshold,
|
|
607
|
+
`${label}.phase_overrides.direct.rwep_threshold`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
// An override-supplied notification obligation_ref must resolve against
|
|
611
|
+
// the EFFECTIVE obligation set the runner sees after the merge: the
|
|
612
|
+
// base govern obligations, plus any the override adds. Warning severity,
|
|
613
|
+
// matching the base-phase obligation_ref precedent.
|
|
614
|
+
if (ov.close && typeof ov.close === 'object' && Array.isArray(ov.close.notification_actions)) {
|
|
615
|
+
const overrideObligations =
|
|
616
|
+
(ov.govern && Array.isArray(ov.govern.jurisdiction_obligations))
|
|
617
|
+
? ov.govern.jurisdiction_obligations
|
|
618
|
+
: (govern.jurisdiction_obligations || []);
|
|
619
|
+
const effectiveKeys = new Set(overrideObligations.map(obligationKey));
|
|
620
|
+
for (const [j, na] of ov.close.notification_actions.entries()) {
|
|
621
|
+
if (!na || typeof na !== 'object') continue;
|
|
622
|
+
if (na.obligation_ref && !effectiveKeys.has(na.obligation_ref)) {
|
|
623
|
+
warn(
|
|
624
|
+
`${label}.phase_overrides.close.notification_actions[${j}].obligation_ref: unresolved "${na.obligation_ref}" — no matching jurisdiction_obligations entry (synthesized as "<jurisdiction>/<regulation> <window_hours>h")`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
582
632
|
return findings;
|
|
633
|
+
|
|
634
|
+
// ---- local helpers (hoisted; close over `findings`/`ctx`/`err`) ----
|
|
635
|
+
|
|
636
|
+
// rwep_threshold ordering + range. close <= monitor <= escalate, each in
|
|
637
|
+
// 0..100. Error severity — a misordered or out-of-range threshold actively
|
|
638
|
+
// breaks the scoring path. `pathPrefix` keeps the message accurate whether
|
|
639
|
+
// the source is the base phase or a directive override.
|
|
640
|
+
function checkRwepThreshold(rwepObj, pathPrefix) {
|
|
641
|
+
const rwep = rwepObj || {};
|
|
642
|
+
if (
|
|
643
|
+
typeof rwep.close === 'number' &&
|
|
644
|
+
typeof rwep.monitor === 'number' &&
|
|
645
|
+
typeof rwep.escalate === 'number'
|
|
646
|
+
) {
|
|
647
|
+
if (!(rwep.close <= rwep.monitor && rwep.monitor <= rwep.escalate)) {
|
|
648
|
+
err(
|
|
649
|
+
`${pathPrefix}: ordering violation — expected close <= monitor <= escalate, got close=${rwep.close} monitor=${rwep.monitor} escalate=${rwep.escalate}`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
for (const [k, v] of [
|
|
653
|
+
['close', rwep.close],
|
|
654
|
+
['monitor', rwep.monitor],
|
|
655
|
+
['escalate', rwep.escalate],
|
|
656
|
+
]) {
|
|
657
|
+
if (v < 0 || v > 100) {
|
|
658
|
+
err(`${pathPrefix}.${k}: ${v} outside 0..100`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// clock_starts closed-vocabulary check over a jurisdiction_obligations list.
|
|
665
|
+
// Error severity — clock_starts decides when a notification deadline starts
|
|
666
|
+
// counting; an out-of-vocabulary value silently never starts the clock.
|
|
667
|
+
function checkClockStarts(obligations, pathPrefix) {
|
|
668
|
+
if (!ctx.clockStartsEnum || !Array.isArray(obligations)) return;
|
|
669
|
+
for (const [i, o] of obligations.entries()) {
|
|
670
|
+
if (!o || typeof o !== 'object') continue;
|
|
671
|
+
if (o.clock_starts !== undefined && !ctx.clockStartsEnum.has(o.clock_starts)) {
|
|
672
|
+
err(
|
|
673
|
+
`${pathPrefix}[${i}].clock_starts: invalid value ${JSON.stringify(o.clock_starts)} — not in closed vocabulary ${JSON.stringify([...ctx.clockStartsEnum])}`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
583
678
|
}
|
|
584
679
|
|
|
585
680
|
/* Cross-playbook mutex-reciprocity check.
|