@blamejs/exceptd-skills 0.19.32 → 0.19.34
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 +896 -2824
- package/data/_indexes/_meta.json +8 -8
- package/data/_indexes/activity-feed.json +2 -2
- package/data/_indexes/catalog-summaries.json +7 -7
- package/data/_indexes/chains.json +60118 -0
- package/data/attack-techniques.json +267 -7
- package/data/cve-catalog.json +9991 -3
- package/data/cwe-catalog.json +109 -2
- package/data/framework-control-gaps.json +578 -3
- package/data/zeroday-lessons.json +8330 -1
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -152
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +35 -114
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -412
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +2 -2
- package/sbom.cdx.json +253 -253
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -140
|
@@ -1,76 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/*
|
|
3
|
-
* lib/validate-playbooks.js —
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* resolves every cross-playbook + cross-catalog reference the playbook
|
|
9
|
-
* shape carries.
|
|
10
|
-
*
|
|
11
|
-
* Cross-references checked:
|
|
12
|
-
* - _meta.feeds_into[].playbook_id → other playbook files
|
|
13
|
-
* - _meta.mutex[] → other playbook files
|
|
14
|
-
* - _meta.skill_chain[] → manifest.json.skills[]
|
|
15
|
-
* (legacy alias; the canonical chain lives under
|
|
16
|
-
* phases.direct.skill_chain[].skill — both are resolved)
|
|
17
|
-
* - phases.govern.skill_preload[] → manifest.json.skills[]
|
|
18
|
-
* - domain.atlas_refs[] → data/atlas-ttps.json keys
|
|
19
|
-
* - domain.attack_refs[] → data/attack-techniques.json keys
|
|
20
|
-
* - domain.cve_refs[] → data/cve-catalog.json keys
|
|
21
|
-
* - domain.cwe_refs[] → data/cwe-catalog.json keys
|
|
22
|
-
* - domain.d3fend_refs[] → data/d3fend-catalog.json keys
|
|
23
|
-
* - phases.detect.indicators[].attack_ref → data/attack-techniques.json
|
|
24
|
-
* - phases.detect.indicators[].atlas_ref → data/atlas-ttps.json
|
|
25
|
-
* - phases.detect.indicators[].cve_ref → data/cve-catalog.json
|
|
26
|
-
* - phases.detect.false_positive_profile[].indicator_id
|
|
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
|
|
36
|
-
*
|
|
37
|
-
* Internal consistency:
|
|
38
|
-
* - Indicator ids are unique within a playbook.
|
|
39
|
-
* - Every playbook maps to at least one TTP via domain.atlas_refs or
|
|
40
|
-
* domain.attack_refs (the cross-cutting correlation layer is exempt).
|
|
41
|
-
* - When _meta.air_gap_mode is true, network-sourced look.artifacts carry
|
|
42
|
-
* a non-empty air_gap_alternative (error if missing).
|
|
43
|
-
* - Closed controlled vocabularies (jurisdiction_obligations[].clock_starts,
|
|
44
|
-
* domain.frameworks_in_scope[]) are enforced at error severity, unlike the
|
|
45
|
-
* evolving-drift enums (artifact/indicator `type`) which stay warnings.
|
|
46
|
-
* - rwep_threshold ordering: close <= monitor <= escalate, each in 0..100.
|
|
47
|
-
* - close.notification_actions[].obligation_ref resolves to a synthesized
|
|
48
|
-
* "<jurisdiction>/<regulation> <window_hours>h" key from
|
|
49
|
-
* govern.jurisdiction_obligations[] (the schema does not give
|
|
50
|
-
* jurisdiction_obligations an explicit `id` field; the shipped playbooks
|
|
51
|
-
* reference them by this composite string).
|
|
52
|
-
* - _meta.mutex is symmetric across the whole playbook set: if A lists B,
|
|
53
|
-
* B must list A. Asymmetry surfaces as a warning by default (promoted to
|
|
54
|
-
* an error under --strict) — see checkMutexReciprocity().
|
|
55
|
-
*
|
|
56
|
-
* Finding severity:
|
|
57
|
-
* - error — structural problems that block the runner (missing required
|
|
58
|
-
* field, JSON parse error, internal ordering violation,
|
|
59
|
-
* duplicate indicator id).
|
|
60
|
-
* - warning — schema-shape drift the runner can still tolerate (enum
|
|
61
|
-
* vocabulary lag, cross-catalog refs introduced after the
|
|
62
|
-
* playbook last shipped). Surfaced to the operator without
|
|
63
|
-
* failing the gate by default; promoted to hard errors under
|
|
64
|
-
* --strict (predeploy `informational: false`).
|
|
65
|
-
*
|
|
66
|
-
* Exit code: 0 if no errors (warnings allowed), 1 if any errors, 2 on
|
|
67
|
-
* argv error.
|
|
68
|
-
*
|
|
69
|
-
* Usage:
|
|
70
|
-
* node lib/validate-playbooks.js validate every playbook
|
|
71
|
-
* node lib/validate-playbooks.js --quiet only print FAIL playbooks + summary
|
|
72
|
-
* node lib/validate-playbooks.js --strict treat warnings as errors (used by
|
|
73
|
-
* the predeploy gate).
|
|
3
|
+
* lib/validate-playbooks.js — validates every data/playbooks/*.json against
|
|
4
|
+
* lib/schemas/playbook.schema.json and resolves the cross-references it carries.
|
|
5
|
+
* Findings are `error` (blocks the runner) or `warning` (tolerated drift);
|
|
6
|
+
* --strict promotes every warning to an error. Exits 0 clean, 1 on any error,
|
|
7
|
+
* 2 on an argv error.
|
|
74
8
|
*/
|
|
75
9
|
|
|
76
10
|
'use strict';
|
|
@@ -79,33 +13,15 @@ const fs = require('node:fs');
|
|
|
79
13
|
const path = require('node:path');
|
|
80
14
|
const process = require('node:process');
|
|
81
15
|
const { safeExit } = require('./exit-codes');
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// engine cannot parse (condition_unparsed) — a prose / bare-token /
|
|
86
|
-
// unimplemented-syntax condition that would silently return false for every
|
|
87
|
-
// input and disable the escalation it gates. No cycle: playbook-runner does not
|
|
88
|
-
// require this validator. Loaded defensively: the engine carries a dependency
|
|
89
|
-
// closure (scoring / cross-ref-api / id-validation) that a stripped test mirror
|
|
90
|
-
// staging only the validator + schema does not copy, so a hard top-level require
|
|
91
|
-
// would crash the validator there. In the real repo and the shipped tarball the
|
|
92
|
-
// engine is always present, so the parse-gate always runs; when it is genuinely
|
|
93
|
-
// unresolvable the gate surfaces a WARNING (never a silent error-skip — see
|
|
94
|
-
// checkCrossRefs) so the degradation is observable rather than a false pass.
|
|
16
|
+
// The SAME parser the runner uses, so a condition the engine cannot parse is
|
|
17
|
+
// rejected here instead of silently returning false for every input. Required
|
|
18
|
+
// defensively: a stripped test mirror stages this validator without the engine.
|
|
95
19
|
let _evalCondition = null;
|
|
96
20
|
try { ({ _evalCondition } = require('./playbook-runner')); } catch { /* warned at the gate */ }
|
|
97
21
|
|
|
98
|
-
// Decompose a condition into
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
// evaluates AND via `.every` / OR via `.some`, which SHORT-CIRCUIT: a dead
|
|
102
|
-
// (unparseable) sub-clause behind a leading clause that resolves false (AND) or
|
|
103
|
-
// true (OR) in the empty validation context is never reached, so running the
|
|
104
|
-
// whole condition once never surfaces its condition_unparsed. Atomizing and
|
|
105
|
-
// parse-checking each leaf independently catches a dead sub-clause regardless of
|
|
106
|
-
// short-circuit (e.g. `blast_radius_score >= 3 AND any deterministic indicator
|
|
107
|
-
// fires` — the first clause is false with no signals, so the dead prose second
|
|
108
|
-
// clause never parsed). Mirrors evalCondition's splitAtTopLevel / stripOuterParens.
|
|
22
|
+
// Decompose a condition into leaf atoms the way evalCondition does. Checking each
|
|
23
|
+
// leaf independently catches a dead sub-clause: evalCondition short-circuits AND
|
|
24
|
+
// via `.every` and OR via `.some`, so a later clause never reports unparsed.
|
|
109
25
|
function _splitTopLevel(expr, sep) {
|
|
110
26
|
const parts = []; const needle = ' ' + sep + ' ';
|
|
111
27
|
let depth = 0, buf = '', i = 0, quote = null;
|
|
@@ -193,13 +109,9 @@ function typeOf(value) {
|
|
|
193
109
|
return typeof value;
|
|
194
110
|
}
|
|
195
111
|
|
|
196
|
-
// Strict ISO calendar
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
// malformed _meta.last_threat_review or changelog `date` (2026-02-30,
|
|
200
|
-
// 2026-13-40) would pass with only the YYYY-MM-DD shape test. Require the
|
|
201
|
-
// parsed Y-M-D to round-trip back to the input components, mirroring
|
|
202
|
-
// parseIsoDateStrict in lib/validate-catalog-meta.js.
|
|
112
|
+
// Strict ISO calendar check: the shape regex alone accepts impossible dates —
|
|
113
|
+
// `new Date('2026-02-30T00:00:00Z')` rolls over to March 2 — so the parsed Y-M-D
|
|
114
|
+
// must round-trip. Mirrors parseIsoDateStrict in lib/validate-catalog-meta.js.
|
|
203
115
|
function isStrictIsoDate(value) {
|
|
204
116
|
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
205
117
|
const d = new Date(value + 'T00:00:00Z');
|
|
@@ -219,12 +131,10 @@ function typeMatches(value, expected) {
|
|
|
219
131
|
return actual === expected;
|
|
220
132
|
}
|
|
221
133
|
|
|
222
|
-
/* Inline JSON-Schema subset validator. Returns a flat list of
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* between the schema and shipped playbooks does not hard-fail by default.
|
|
227
|
-
* Promoted to errors under --strict / predeploy informational:false. */
|
|
134
|
+
/* Inline JSON-Schema subset validator. Returns a flat list of
|
|
135
|
+
* { severity, message }: 'error' except enum mismatches and unknown properties
|
|
136
|
+
* under additionalProperties:false, which are 'warning' so vocabulary drift does
|
|
137
|
+
* not hard-fail outside --strict. */
|
|
228
138
|
function validate(value, schema, schemaName, pathStr) {
|
|
229
139
|
const findings = [];
|
|
230
140
|
const here = pathStr || schemaName;
|
|
@@ -239,8 +149,6 @@ function validate(value, schema, schemaName, pathStr) {
|
|
|
239
149
|
|
|
240
150
|
if (schema.enum !== undefined) {
|
|
241
151
|
if (!schema.enum.includes(value)) {
|
|
242
|
-
// Enum drift is downgraded to a warning so vocabulary-evolution does
|
|
243
|
-
// not break patch-class releases.
|
|
244
152
|
err(
|
|
245
153
|
`${here}: value ${JSON.stringify(value)} not in enum ${JSON.stringify(schema.enum)}`,
|
|
246
154
|
'warning',
|
|
@@ -315,8 +223,6 @@ function validate(value, schema, schemaName, pathStr) {
|
|
|
315
223
|
} else if (addlSchema) {
|
|
316
224
|
findings.push(...validate(v, addlSchema, schemaName, `${here}.${k}`));
|
|
317
225
|
} else if (!allowAdditional) {
|
|
318
|
-
// Drift between schema and shipped data: surface as warning, not
|
|
319
|
-
// an error. Promoted to an error under --strict.
|
|
320
226
|
err(`${here}: unexpected property "${k}"`, 'warning');
|
|
321
227
|
}
|
|
322
228
|
}
|
|
@@ -331,19 +237,11 @@ function loadContext() {
|
|
|
331
237
|
const cve = readJson(CVE_PATH);
|
|
332
238
|
const cwe = readJson(CWE_PATH);
|
|
333
239
|
const d3 = readJson(D3FEND_PATH);
|
|
334
|
-
// Required,
|
|
335
|
-
// always present; loading it optionally let attack_ref validation skip
|
|
336
|
-
// silently when it was absent — the asymmetry that allowed an unresolvable
|
|
337
|
-
// attack_ref to ship. Its absence must fail loud.
|
|
240
|
+
// Required, not optional: an optional load skips attack_ref validation silently.
|
|
338
241
|
const attack = readJson(ATTACK_PATH);
|
|
339
242
|
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// schema's own enum lists. A typo'd clock_starts must hard-fail the
|
|
343
|
-
// predeploy gate (it changes when a notification clock starts ticking),
|
|
344
|
-
// so unlike evolving-drift enums (artifact/indicator `type`) these are
|
|
345
|
-
// promoted to error severity rather than left as generic-validator
|
|
346
|
-
// warnings.
|
|
243
|
+
// Sourced from the schema so the hard-error checks in checkCrossRefs stay in
|
|
244
|
+
// lockstep with its enum lists.
|
|
347
245
|
let clockStartsEnum = null;
|
|
348
246
|
let frameworksEnum = null;
|
|
349
247
|
try {
|
|
@@ -354,13 +252,10 @@ function loadContext() {
|
|
|
354
252
|
frameworksEnum =
|
|
355
253
|
schema.properties.domain.properties.frameworks_in_scope.items.enum || null;
|
|
356
254
|
} catch (e) {
|
|
357
|
-
// The playbook schema is a committed, required file. Swallowing a read error
|
|
358
|
-
// here silently disabled the clock_starts / frameworks_in_scope closed-vocab
|
|
359
|
-
// checks, so a typo in those fields could ship. Fail loud instead.
|
|
360
255
|
throw new Error(`validate-playbooks: cannot read playbook schema ${SCHEMA_PATH} — ${e && e.message}. The closed-vocabulary checks must not silently disable.`);
|
|
361
256
|
}
|
|
362
|
-
// A
|
|
363
|
-
//
|
|
257
|
+
// A wrong-shape parse leaves the enums null and disables the checks, so it is
|
|
258
|
+
// fatal too.
|
|
364
259
|
if (!Array.isArray(clockStartsEnum) || !Array.isArray(frameworksEnum)) {
|
|
365
260
|
throw new Error(`validate-playbooks: playbook schema ${SCHEMA_PATH} did not yield the clock_starts / frameworks_in_scope enums (shape changed). Refusing to validate with the closed-vocab checks silently disabled — fix the schema path expressions in loadContext().`);
|
|
366
261
|
}
|
|
@@ -397,21 +292,14 @@ function loadPlaybooks() {
|
|
|
397
292
|
}
|
|
398
293
|
|
|
399
294
|
function obligationKey(o) {
|
|
400
|
-
//
|
|
401
|
-
// jurisdiction_obligations entries; the shipped playbooks reference them
|
|
402
|
-
// by the composite "<jurisdiction>/<regulation> <window_hours>h" string.
|
|
295
|
+
// Obligations carry no `id`; playbooks reference one by this composite string.
|
|
403
296
|
return `${o.jurisdiction}/${o.regulation} ${o.window_hours}h`;
|
|
404
297
|
}
|
|
405
298
|
|
|
406
299
|
function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
407
300
|
const findings = [];
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
// it (main() line ~755). Guarding here turns the uncaught `playbook._meta`
|
|
411
|
-
// TypeError on a literal-null playbook file into a clean no-op so main()
|
|
412
|
-
// still reports the FAIL and continues to the remaining playbooks instead of
|
|
413
|
-
// aborting the whole gate. The FAIL is preserved — it originates in
|
|
414
|
-
// validate(), not here.
|
|
301
|
+
// validate() already emits the type error; returning empty keeps main()
|
|
302
|
+
// reporting the FAIL instead of dying on `playbook._meta`.
|
|
415
303
|
if (!playbook || typeof playbook !== 'object' || Array.isArray(playbook)) {
|
|
416
304
|
return findings;
|
|
417
305
|
}
|
|
@@ -431,9 +319,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
431
319
|
warn(`_meta.mutex: unresolved playbook_id "${m}"`);
|
|
432
320
|
}
|
|
433
321
|
}
|
|
434
|
-
//
|
|
435
|
-
// canonical chain lives at phases.direct.skill_chain[].skill but we still
|
|
436
|
-
// resolve a flat list if present, per the task brief.
|
|
322
|
+
// _meta.skill_chain[] aliases the canonical phases.direct.skill_chain[].skill.
|
|
437
323
|
for (const s of meta.skill_chain || []) {
|
|
438
324
|
if (typeof s === 'string' && !ctx.skillKeys.has(s)) {
|
|
439
325
|
warn(`_meta.skill_chain: unresolved skill "${s}"`);
|
|
@@ -459,10 +345,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
459
345
|
warn(`domain.atlas_refs: unresolved "${a}" (not in data/atlas-ttps.json)`);
|
|
460
346
|
}
|
|
461
347
|
}
|
|
462
|
-
// Hard Rule #4
|
|
463
|
-
// the ATT&CK technique catalog, mirroring the atlas_refs block above and the
|
|
464
|
-
// detect.indicators[].attack_ref check below. Without this, every TTP listed
|
|
465
|
-
// at the domain level bypassed catalog cross-referencing.
|
|
348
|
+
// Hard Rule #4: domain-level TTPs resolve against the ATT&CK catalog.
|
|
466
349
|
for (const a of domain.attack_refs || []) {
|
|
467
350
|
if (ctx.attackKeys && !ctx.attackKeys.has(a)) {
|
|
468
351
|
warn(`domain.attack_refs: unresolved "${a}" (not in data/attack-techniques.json)`);
|
|
@@ -484,7 +367,6 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
484
367
|
}
|
|
485
368
|
}
|
|
486
369
|
|
|
487
|
-
// Indicators: id uniqueness, attack_ref / atlas_ref / cve_ref resolution.
|
|
488
370
|
const detect = phases.detect || {};
|
|
489
371
|
const indIds = new Set();
|
|
490
372
|
const indicators = detect.indicators || [];
|
|
@@ -516,10 +398,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
516
398
|
}
|
|
517
399
|
}
|
|
518
400
|
|
|
519
|
-
//
|
|
520
|
-
// A dangling reference means an FP-distinguishing test is wired to nothing,
|
|
521
|
-
// so the runner can never apply it. Warning severity (vocabulary-style
|
|
522
|
-
// drift, not a structural break).
|
|
401
|
+
// A dangling indicator_id wires an FP test to nothing the runner can apply.
|
|
523
402
|
for (const [i, fp] of (detect.false_positive_profile || []).entries()) {
|
|
524
403
|
if (!fp || typeof fp !== 'object') continue;
|
|
525
404
|
if (fp.indicator_id && !indIds.has(fp.indicator_id)) {
|
|
@@ -529,12 +408,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
529
408
|
}
|
|
530
409
|
}
|
|
531
410
|
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
// back to priority-1 without surfacing the intended finding-specific link —
|
|
535
|
-
// exactly the kind of "looks wired, does nothing" drift this gate exists to
|
|
536
|
-
// catch. Warning severity (promoted to a hard error under --strict, matching
|
|
537
|
-
// the false_positive_profile precedent above).
|
|
411
|
+
// A dangling for_signals never matches: selected_remediation falls back to
|
|
412
|
+
// priority 1 without the finding-specific link.
|
|
538
413
|
const validatePhase = phases.validate || {};
|
|
539
414
|
for (const [i, rp] of (validatePhase.remediation_paths || []).entries()) {
|
|
540
415
|
if (!rp || typeof rp !== 'object' || !Array.isArray(rp.for_signals)) continue;
|
|
@@ -547,18 +422,11 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
547
422
|
}
|
|
548
423
|
}
|
|
549
424
|
|
|
550
|
-
//
|
|
551
|
-
// breaks the scoring path. Factored into a helper so the same check runs
|
|
552
|
-
// against the playbook-level direct phase AND any directive-level
|
|
553
|
-
// phase_overrides.direct copy the runner deep-merges at run time.
|
|
425
|
+
// Helpers, so the same checks run against a directive's phase_overrides copy.
|
|
554
426
|
checkRwepThreshold(direct.rwep_threshold, 'phases.direct.rwep_threshold');
|
|
555
427
|
|
|
556
|
-
// clock_starts closed-vocab against the base govern phase. Factored into a
|
|
557
|
-
// helper for the same reason — an override-supplied jurisdiction_obligations
|
|
558
|
-
// copy must pass the same closed-vocabulary gate.
|
|
559
428
|
checkClockStarts(govern.jurisdiction_obligations, 'phases.govern.jurisdiction_obligations');
|
|
560
429
|
|
|
561
|
-
// notification_actions obligation_ref resolution.
|
|
562
430
|
const obligationKeys = new Set(
|
|
563
431
|
(govern.jurisdiction_obligations || []).map(obligationKey),
|
|
564
432
|
);
|
|
@@ -572,15 +440,9 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
572
440
|
}
|
|
573
441
|
}
|
|
574
442
|
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
// and `finding` roots; close()'s feeds_into context additionally resolves
|
|
579
|
-
// `validate` and `theater_score`. A dotted path rooted at any OTHER phase
|
|
580
|
-
// name can never resolve at evaluation time — the condition would
|
|
581
|
-
// silently never fire — so it is rejected here. Bare (un-dotted)
|
|
582
|
-
// identifiers are agent-signal names, an open vocabulary, and are not
|
|
583
|
-
// checked.
|
|
443
|
+
// The escalation context resolves the flat keys plus `analyze` and `finding`;
|
|
444
|
+
// feeds_into also resolves `validate`. A dotted path rooted at any other phase
|
|
445
|
+
// name never resolves. Bare identifiers are agent-signal names, not checked.
|
|
584
446
|
const conditionPathRoots = (cond) => {
|
|
585
447
|
if (typeof cond !== 'string') return [];
|
|
586
448
|
const stripped = cond.replace(/'[^']*'|"[^"]*"|\/[^/\n]*\//g, ' ');
|
|
@@ -614,31 +476,12 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
614
476
|
}
|
|
615
477
|
}
|
|
616
478
|
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
// (`<tok> fired`, `not in`, `is`), or an apostrophe-bearing token that fails
|
|
623
|
-
// the LHS pattern. Every such condition falls through evalCondition to a
|
|
624
|
-
// silent `false` for every input, so the raise_severity / trigger_playbook /
|
|
625
|
-
// remediation-precondition it gates never fires — and nothing flagged it,
|
|
626
|
-
// because the validator never ran the actual evaluator. Run each condition in
|
|
627
|
-
// the THREE locations the runner threads through evalCondition (analyze
|
|
628
|
-
// escalation_criteria, _meta.feeds_into, validate remediation_paths
|
|
629
|
-
// preconditions) and hard-fail on condition_unparsed. Scoped to exactly those
|
|
630
|
-
// three: scoring_rubric entries are descriptive blast-level prose (never
|
|
631
|
-
// evaluated — the score comes from agentSignals.blast_radius_score) and
|
|
632
|
-
// regression_trigger conditions are consumed by computeRegressionNextRun's own
|
|
633
|
-
// cadence/event parser, so neither is in scope here. Gate ONLY on
|
|
634
|
-
// condition_unparsed — a genuine PARSE failure is input-independent;
|
|
635
|
-
// condition_path_unresolved / condition_type_mismatch are runtime data
|
|
636
|
-
// diagnostics that would false-positive against this empty validation context
|
|
637
|
-
// (a dotted path legitimately resolves null when no run has populated it).
|
|
479
|
+
// Parseability gate over the three places the runner threads a condition through
|
|
480
|
+
// evalCondition: analyze escalation_criteria, _meta.feeds_into, and validate
|
|
481
|
+
// remediation_paths preconditions. Only condition_unparsed gates: a parse failure
|
|
482
|
+
// is input-independent, where condition_path_unresolved and
|
|
483
|
+
// condition_type_mismatch are runtime diagnostics that false-positive here.
|
|
638
484
|
if (!_evalCondition) {
|
|
639
|
-
// Engine module not resolvable from this validator location (a stripped
|
|
640
|
-
// test mirror). Surface it as a WARNING so the skip is observable — in the
|
|
641
|
-
// real repo + shipped tarball the engine always loads and this never fires.
|
|
642
485
|
warn(
|
|
643
486
|
'escalation/feeds_into/precondition parse-gate skipped: lib/playbook-runner.js (the condition evaluator) was not resolvable from this validator location, so condition parseability was not checked',
|
|
644
487
|
);
|
|
@@ -646,9 +489,6 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
646
489
|
const conditionParses = (cond) => {
|
|
647
490
|
if (typeof cond !== 'string' || !cond.trim()) return true;
|
|
648
491
|
if (!_evalCondition) return true; // engine unavailable — warned above, never a silent error-skip
|
|
649
|
-
// Check EACH atomic leaf, not the whole condition once: evalCondition's
|
|
650
|
-
// AND/OR short-circuit hides a dead sub-clause behind a leading clause that
|
|
651
|
-
// resolves false (AND) / true (OR) in the empty validation context.
|
|
652
492
|
for (const atom of atomizeCondition(cond)) {
|
|
653
493
|
const runErrors = [];
|
|
654
494
|
try { _evalCondition(atom, { _runErrors: runErrors }, { _runErrors: runErrors }); }
|
|
@@ -682,26 +522,15 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
682
522
|
}
|
|
683
523
|
}
|
|
684
524
|
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
// non-empty air_gap_alternative or the run is silently incomplete. The
|
|
689
|
-
// schema encodes this as an allOf/if/then block, but the inline validator
|
|
690
|
-
// does not implement conditional keywords, so it is enforced imperatively
|
|
691
|
-
// here at error severity.
|
|
525
|
+
// Under _meta.air_gap_mode the runner refuses the network, so a network-sourced
|
|
526
|
+
// artifact needs a non-empty air_gap_alternative. The schema states this as an
|
|
527
|
+
// allOf/if/then block the inline validator does not implement.
|
|
692
528
|
if (meta.air_gap_mode === true) {
|
|
693
529
|
const look = phases.look || {};
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
// /directoryRoles via Graph", "Entra ID", "Okta", "Microsoft Graph") so a
|
|
699
|
-
// REST/Graph endpoint described in prose still flags under air_gap_mode and
|
|
700
|
-
// is not silently collected offline-incomplete. `api/v\d` is deliberately
|
|
701
|
-
// NOT a token — it false-positives on local code-scan artifacts that merely
|
|
702
|
-
// reference an API path. NOTE: lib/schemas/playbook.schema.json carries the
|
|
703
|
-
// same narrow `source` pattern and must be broadened in lockstep with this
|
|
704
|
-
// regex (main-thread item — that file is not edited here).
|
|
530
|
+
// `api/v\d` is deliberately not a token — it false-positives on local
|
|
531
|
+
// code-scan artifacts that merely reference an API path. The same `source`
|
|
532
|
+
// pattern lives in lib/schemas/playbook.schema.json and must be broadened
|
|
533
|
+
// in lockstep with this one.
|
|
705
534
|
const netSourceRe = /(https?:\/\/|\bgh (?:api|release)\b|\bcurl\b|\bwget\b|\bfetch\b|\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/|\bGraph\b|\b(?:Okta|Entra ID|Microsoft Graph)\b)/i;
|
|
706
535
|
for (const [i, art] of (look.artifacts || []).entries()) {
|
|
707
536
|
if (!art || typeof art !== 'object') continue;
|
|
@@ -716,11 +545,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
716
545
|
}
|
|
717
546
|
}
|
|
718
547
|
|
|
719
|
-
// TTP-mapping floor (Hard Rule #4): every playbook
|
|
720
|
-
//
|
|
721
|
-
// exemption is the cross-cutting correlation layer (_meta.scope ===
|
|
722
|
-
// "cross-cutting"), which has no first-party TTPs — it correlates findings
|
|
723
|
-
// produced by the other playbooks. Error severity for everything else.
|
|
548
|
+
// TTP-mapping floor (Hard Rule #4): every playbook maps to at least one
|
|
549
|
+
// technique. The cross-cutting correlation layer has no first-party TTPs.
|
|
724
550
|
const atlasCount = (domain.atlas_refs || []).length;
|
|
725
551
|
const attackCount = (domain.attack_refs || []).length;
|
|
726
552
|
if (atlasCount === 0 && attackCount === 0 && meta.scope !== 'cross-cutting') {
|
|
@@ -729,9 +555,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
729
555
|
);
|
|
730
556
|
}
|
|
731
557
|
|
|
732
|
-
// frameworks_in_scope
|
|
733
|
-
//
|
|
734
|
-
// drives gap-analysis routing.
|
|
558
|
+
// frameworks_in_scope drives gap-analysis routing, so a value outside the
|
|
559
|
+
// schema's closed enum errors rather than warns.
|
|
735
560
|
if (ctx.frameworksEnum) {
|
|
736
561
|
for (const [i, f] of (domain.frameworks_in_scope || []).entries()) {
|
|
737
562
|
if (typeof f === 'string' && !ctx.frameworksEnum.has(f)) {
|
|
@@ -742,19 +567,12 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
742
567
|
}
|
|
743
568
|
}
|
|
744
569
|
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
// by id, deep-merges phase_overrides into the base phase, and surfaces
|
|
748
|
-
// applies_to in the discovery API) but neither was cross-referenced or
|
|
749
|
-
// re-validated — so a stale CVE/TTP reference or a tampered override
|
|
750
|
-
// (bogus clock_starts, out-of-range rwep_threshold) shipped past this gate
|
|
751
|
-
// even though the identical content is a hard error at playbook level.
|
|
570
|
+
// Directives reach the runner live — phase_overrides deep-merged into the base
|
|
571
|
+
// phase — so they face the same cross-reference and override checks.
|
|
752
572
|
for (const [i, d] of (playbook.directives || []).entries()) {
|
|
753
573
|
if (!d || typeof d !== 'object') continue;
|
|
754
574
|
const label = d.id ? `directives[${i}] (${d.id})` : `directives[${i}]`;
|
|
755
575
|
|
|
756
|
-
// applies_to.{cve,atlas_ttp,attack_technique} resolution, mirroring the
|
|
757
|
-
// domain-ref checks at warning severity (promoted to error under --strict).
|
|
758
576
|
const at = d.applies_to;
|
|
759
577
|
if (at && typeof at === 'object') {
|
|
760
578
|
if (at.cve && !ctx.cveKeys.has(at.cve)) {
|
|
@@ -763,17 +581,13 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
763
581
|
if (at.atlas_ttp && !ctx.atlasKeys.has(at.atlas_ttp)) {
|
|
764
582
|
warn(`${label}.applies_to.atlas_ttp: unresolved "${at.atlas_ttp}" (not in data/atlas-ttps.json)`);
|
|
765
583
|
}
|
|
766
|
-
// Guard the attack catalog the same way domain.attack_refs does:
|
|
767
|
-
// attack-techniques.json is loaded via readJsonIfExists and may be null.
|
|
768
584
|
if (at.attack_technique && ctx.attackKeys && !ctx.attackKeys.has(at.attack_technique)) {
|
|
769
585
|
warn(`${label}.applies_to.attack_technique: unresolved "${at.attack_technique}" (not in data/attack-techniques.json)`);
|
|
770
586
|
}
|
|
771
587
|
}
|
|
772
588
|
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
// clock_starts or rwep_threshold must pass the same gates as the base
|
|
776
|
-
// phase or the regulatory clock / scoring path breaks at run time.
|
|
589
|
+
// The runner merges these into the base phase, so an override-supplied
|
|
590
|
+
// clock_starts or rwep_threshold must clear the same gates.
|
|
777
591
|
const ov = d.phase_overrides;
|
|
778
592
|
if (ov && typeof ov === 'object') {
|
|
779
593
|
if (ov.govern && typeof ov.govern === 'object') {
|
|
@@ -788,10 +602,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
788
602
|
`${label}.phase_overrides.direct.rwep_threshold`,
|
|
789
603
|
);
|
|
790
604
|
}
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
// base govern obligations, plus any the override adds. Warning severity,
|
|
794
|
-
// matching the base-phase obligation_ref precedent.
|
|
605
|
+
// Resolved against the effective set the runner sees after the merge: the
|
|
606
|
+
// override's own obligations when it supplies them, else the base phase's.
|
|
795
607
|
if (ov.close && typeof ov.close === 'object' && Array.isArray(ov.close.notification_actions)) {
|
|
796
608
|
const overrideObligations =
|
|
797
609
|
(ov.govern && Array.isArray(ov.govern.jurisdiction_obligations))
|
|
@@ -812,29 +624,21 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
812
624
|
|
|
813
625
|
return findings;
|
|
814
626
|
|
|
815
|
-
//
|
|
627
|
+
// Hoisted for the calls above; they close over `findings`, `ctx` and `err`.
|
|
816
628
|
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
// breaks the scoring path. `pathPrefix` keeps the message accurate whether
|
|
820
|
-
// the source is the base phase or a directive override.
|
|
629
|
+
// close <= monitor <= escalate, each in 0..100. `pathPrefix` keeps the message
|
|
630
|
+
// accurate for both the base phase and a directive override.
|
|
821
631
|
function checkRwepThreshold(rwepObj, pathPrefix) {
|
|
822
632
|
const rwep = rwepObj || {};
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
// deep-merges, so it may carry only one of the three keys — gating the
|
|
826
|
-
// whole check on all-three-present let an out-of-range single-key override
|
|
827
|
-
// (e.g. {escalate: 150} or {close: -5}) ship unchecked and reach the
|
|
828
|
-
// runner. Validate each present numeric key on its own.
|
|
633
|
+
// Per-key and independent: an override is a partial fragment, so gating the
|
|
634
|
+
// range check on all three keys present lets `{escalate: 150}` through.
|
|
829
635
|
for (const k of ['close', 'monitor', 'escalate']) {
|
|
830
636
|
const v = rwep[k];
|
|
831
637
|
if (typeof v === 'number' && (v < 0 || v > 100)) {
|
|
832
638
|
err(`${pathPrefix}.${k}: ${v} outside 0..100`);
|
|
833
639
|
}
|
|
834
640
|
}
|
|
835
|
-
// Ordering
|
|
836
|
-
// override inherits the missing edges from the base phase, so close <=
|
|
837
|
-
// monitor <= escalate can't be evaluated on the fragment alone).
|
|
641
|
+
// Ordering needs the full triple: a partial override inherits its missing edges.
|
|
838
642
|
if (
|
|
839
643
|
typeof rwep.close === 'number' &&
|
|
840
644
|
typeof rwep.monitor === 'number' &&
|
|
@@ -848,9 +652,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
848
652
|
}
|
|
849
653
|
}
|
|
850
654
|
|
|
851
|
-
// clock_starts
|
|
852
|
-
//
|
|
853
|
-
// counting; an out-of-vocabulary value silently never starts the clock.
|
|
655
|
+
// clock_starts decides when a notification deadline begins counting; an
|
|
656
|
+
// out-of-vocabulary value never starts the clock.
|
|
854
657
|
function checkClockStarts(obligations, pathPrefix) {
|
|
855
658
|
if (!ctx.clockStartsEnum || !Array.isArray(obligations)) return;
|
|
856
659
|
for (const [i, o] of obligations.entries()) {
|
|
@@ -864,19 +667,10 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
864
667
|
}
|
|
865
668
|
}
|
|
866
669
|
|
|
867
|
-
/*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
* blocks concurrent execution from whichever side declared the conflict, so
|
|
872
|
-
* an asymmetric declaration silently degrades to a race condition when the
|
|
873
|
-
* undeclared side is started first.
|
|
874
|
-
*
|
|
875
|
-
* Emits one warning per asymmetric pair (keyed off the side that declares
|
|
876
|
-
* the edge). Kept at warning severity by default per the patch-class
|
|
877
|
-
* cadence; promoted to an error under --strict / predeploy
|
|
878
|
-
* `informational: false`.
|
|
879
|
-
*/
|
|
670
|
+
/* `_meta.mutex` is symmetric: if playbook A lists B, B must list A. The engine
|
|
671
|
+
* blocks concurrency only from the side that declared the conflict, so an
|
|
672
|
+
* asymmetric declaration degrades to a race. Returns one warning per asymmetric
|
|
673
|
+
* pair, keyed by the declaring playbook. */
|
|
880
674
|
function checkMutexReciprocity(playbooks) {
|
|
881
675
|
const mutexMap = new Map();
|
|
882
676
|
for (const pb of playbooks) {
|