@blamejs/exceptd-skills 0.19.33 → 0.19.35
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 +28 -0
- package/bin/exceptd.js +895 -2828
- package/data/_indexes/_meta.json +2 -2
- 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 +170 -76
- package/lib/collectors/cicd-pipeline-compromise.js +113 -136
- 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 +198 -211
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +130 -118
- package/lib/collectors/scan-excludes.js +33 -139
- 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 -155
- 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 +39 -113
- 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 +88 -236
- package/lib/playbook-runner.js +759 -2107
- package/lib/prefetch.js +101 -376
- package/lib/refresh-external.js +199 -633
- package/lib/refresh-network.js +78 -311
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +85 -146
- 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 +28 -27
- package/lib/upstream-check-cli.js +36 -29
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +52 -121
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +78 -286
- 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 -413
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +1 -1
- package/sbom.cdx.json +242 -242
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +29 -28
- 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 +21 -31
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +26 -57
- 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 +63 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +62 -81
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +83 -198
- 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 +7 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +7 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +7 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +63 -148
- package/scripts/release.js +69 -234
- package/scripts/run-e2e-scenarios.js +26 -73
- 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 -141
|
@@ -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;
|
|
@@ -182,24 +98,15 @@ function readJson(p) {
|
|
|
182
98
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
183
99
|
}
|
|
184
100
|
|
|
185
|
-
function readJsonIfExists(p) {
|
|
186
|
-
if (!fs.existsSync(p)) return null;
|
|
187
|
-
return readJson(p);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
101
|
function typeOf(value) {
|
|
191
102
|
if (value === null) return 'null';
|
|
192
103
|
if (Array.isArray(value)) return 'array';
|
|
193
104
|
return typeof value;
|
|
194
105
|
}
|
|
195
106
|
|
|
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.
|
|
107
|
+
// Strict ISO calendar check: the shape regex alone accepts impossible dates —
|
|
108
|
+
// `new Date('2026-02-30T00:00:00Z')` rolls over to March 2 — so the parsed Y-M-D
|
|
109
|
+
// must round-trip. Mirrors parseIsoDateStrict in lib/validate-catalog-meta.js.
|
|
203
110
|
function isStrictIsoDate(value) {
|
|
204
111
|
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
205
112
|
const d = new Date(value + 'T00:00:00Z');
|
|
@@ -219,12 +126,10 @@ function typeMatches(value, expected) {
|
|
|
219
126
|
return actual === expected;
|
|
220
127
|
}
|
|
221
128
|
|
|
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. */
|
|
129
|
+
/* Inline JSON-Schema subset validator. Returns a flat list of
|
|
130
|
+
* { severity, message }: 'error' except enum mismatches and unknown properties
|
|
131
|
+
* under additionalProperties:false, which are 'warning' so vocabulary drift does
|
|
132
|
+
* not hard-fail outside --strict. */
|
|
228
133
|
function validate(value, schema, schemaName, pathStr) {
|
|
229
134
|
const findings = [];
|
|
230
135
|
const here = pathStr || schemaName;
|
|
@@ -239,8 +144,6 @@ function validate(value, schema, schemaName, pathStr) {
|
|
|
239
144
|
|
|
240
145
|
if (schema.enum !== undefined) {
|
|
241
146
|
if (!schema.enum.includes(value)) {
|
|
242
|
-
// Enum drift is downgraded to a warning so vocabulary-evolution does
|
|
243
|
-
// not break patch-class releases.
|
|
244
147
|
err(
|
|
245
148
|
`${here}: value ${JSON.stringify(value)} not in enum ${JSON.stringify(schema.enum)}`,
|
|
246
149
|
'warning',
|
|
@@ -315,8 +218,6 @@ function validate(value, schema, schemaName, pathStr) {
|
|
|
315
218
|
} else if (addlSchema) {
|
|
316
219
|
findings.push(...validate(v, addlSchema, schemaName, `${here}.${k}`));
|
|
317
220
|
} else if (!allowAdditional) {
|
|
318
|
-
// Drift between schema and shipped data: surface as warning, not
|
|
319
|
-
// an error. Promoted to an error under --strict.
|
|
320
221
|
err(`${here}: unexpected property "${k}"`, 'warning');
|
|
321
222
|
}
|
|
322
223
|
}
|
|
@@ -331,19 +232,14 @@ function loadContext() {
|
|
|
331
232
|
const cve = readJson(CVE_PATH);
|
|
332
233
|
const cwe = readJson(CWE_PATH);
|
|
333
234
|
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.
|
|
235
|
+
// Required, not optional: an optional load skips attack_ref validation silently.
|
|
338
236
|
const attack = readJson(ATTACK_PATH);
|
|
237
|
+
if (!attack || typeof attack !== 'object' || Array.isArray(attack)) {
|
|
238
|
+
throw new Error(`validate-playbooks: ${ATTACK_PATH} did not parse to a technique map (got ${attack === null ? 'null' : typeof attack}). Refusing to validate with the attack_ref checks silently disabled.`);
|
|
239
|
+
}
|
|
339
240
|
|
|
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.
|
|
241
|
+
// Sourced from the schema so the hard-error checks in checkCrossRefs stay in
|
|
242
|
+
// lockstep with its enum lists.
|
|
347
243
|
let clockStartsEnum = null;
|
|
348
244
|
let frameworksEnum = null;
|
|
349
245
|
try {
|
|
@@ -354,13 +250,10 @@ function loadContext() {
|
|
|
354
250
|
frameworksEnum =
|
|
355
251
|
schema.properties.domain.properties.frameworks_in_scope.items.enum || null;
|
|
356
252
|
} 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
253
|
throw new Error(`validate-playbooks: cannot read playbook schema ${SCHEMA_PATH} — ${e && e.message}. The closed-vocabulary checks must not silently disable.`);
|
|
361
254
|
}
|
|
362
|
-
// A
|
|
363
|
-
//
|
|
255
|
+
// A wrong-shape parse leaves the enums null and disables the checks, so it is
|
|
256
|
+
// fatal too.
|
|
364
257
|
if (!Array.isArray(clockStartsEnum) || !Array.isArray(frameworksEnum)) {
|
|
365
258
|
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
259
|
}
|
|
@@ -371,9 +264,9 @@ function loadContext() {
|
|
|
371
264
|
cveKeys: new Set(Object.keys(cve).filter((k) => !k.startsWith('_'))),
|
|
372
265
|
cweKeys: new Set(Object.keys(cwe).filter((k) => !k.startsWith('_'))),
|
|
373
266
|
d3fendKeys: new Set(Object.keys(d3).filter((k) => !k.startsWith('_'))),
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
267
|
+
// Never nullable: a null here silently disables every attack_ref check, the
|
|
268
|
+
// exact failure the required load above exists to prevent.
|
|
269
|
+
attackKeys: new Set(Object.keys(attack).filter((k) => !k.startsWith('_'))),
|
|
377
270
|
clockStartsEnum: clockStartsEnum ? new Set(clockStartsEnum) : null,
|
|
378
271
|
frameworksEnum: frameworksEnum ? new Set(frameworksEnum) : null,
|
|
379
272
|
};
|
|
@@ -397,21 +290,14 @@ function loadPlaybooks() {
|
|
|
397
290
|
}
|
|
398
291
|
|
|
399
292
|
function obligationKey(o) {
|
|
400
|
-
//
|
|
401
|
-
// jurisdiction_obligations entries; the shipped playbooks reference them
|
|
402
|
-
// by the composite "<jurisdiction>/<regulation> <window_hours>h" string.
|
|
293
|
+
// Obligations carry no `id`; playbooks reference one by this composite string.
|
|
403
294
|
return `${o.jurisdiction}/${o.regulation} ${o.window_hours}h`;
|
|
404
295
|
}
|
|
405
296
|
|
|
406
297
|
function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
407
298
|
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.
|
|
299
|
+
// validate() already emits the type error; returning empty keeps main()
|
|
300
|
+
// reporting the FAIL instead of dying on `playbook._meta`.
|
|
415
301
|
if (!playbook || typeof playbook !== 'object' || Array.isArray(playbook)) {
|
|
416
302
|
return findings;
|
|
417
303
|
}
|
|
@@ -431,9 +317,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
431
317
|
warn(`_meta.mutex: unresolved playbook_id "${m}"`);
|
|
432
318
|
}
|
|
433
319
|
}
|
|
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.
|
|
320
|
+
// _meta.skill_chain[] aliases the canonical phases.direct.skill_chain[].skill.
|
|
437
321
|
for (const s of meta.skill_chain || []) {
|
|
438
322
|
if (typeof s === 'string' && !ctx.skillKeys.has(s)) {
|
|
439
323
|
warn(`_meta.skill_chain: unresolved skill "${s}"`);
|
|
@@ -459,12 +343,9 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
459
343
|
warn(`domain.atlas_refs: unresolved "${a}" (not in data/atlas-ttps.json)`);
|
|
460
344
|
}
|
|
461
345
|
}
|
|
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.
|
|
346
|
+
// Hard Rule #4: domain-level TTPs resolve against the ATT&CK catalog.
|
|
466
347
|
for (const a of domain.attack_refs || []) {
|
|
467
|
-
if (
|
|
348
|
+
if (!ctx.attackKeys.has(a)) {
|
|
468
349
|
warn(`domain.attack_refs: unresolved "${a}" (not in data/attack-techniques.json)`);
|
|
469
350
|
}
|
|
470
351
|
}
|
|
@@ -484,7 +365,6 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
484
365
|
}
|
|
485
366
|
}
|
|
486
367
|
|
|
487
|
-
// Indicators: id uniqueness, attack_ref / atlas_ref / cve_ref resolution.
|
|
488
368
|
const detect = phases.detect || {};
|
|
489
369
|
const indIds = new Set();
|
|
490
370
|
const indicators = detect.indicators || [];
|
|
@@ -499,7 +379,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
499
379
|
}
|
|
500
380
|
indIds.add(ind.id);
|
|
501
381
|
}
|
|
502
|
-
if (ind.attack_ref &&
|
|
382
|
+
if (ind.attack_ref && !ctx.attackKeys.has(ind.attack_ref)) {
|
|
503
383
|
warn(
|
|
504
384
|
`phases.detect.indicators[${i}].attack_ref: unresolved "${ind.attack_ref}" (not in data/attack-techniques.json)`,
|
|
505
385
|
);
|
|
@@ -516,10 +396,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
516
396
|
}
|
|
517
397
|
}
|
|
518
398
|
|
|
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).
|
|
399
|
+
// A dangling indicator_id wires an FP test to nothing the runner can apply.
|
|
523
400
|
for (const [i, fp] of (detect.false_positive_profile || []).entries()) {
|
|
524
401
|
if (!fp || typeof fp !== 'object') continue;
|
|
525
402
|
if (fp.indicator_id && !indIds.has(fp.indicator_id)) {
|
|
@@ -529,12 +406,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
529
406
|
}
|
|
530
407
|
}
|
|
531
408
|
|
|
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).
|
|
409
|
+
// A dangling for_signals never matches: selected_remediation falls back to
|
|
410
|
+
// priority 1 without the finding-specific link.
|
|
538
411
|
const validatePhase = phases.validate || {};
|
|
539
412
|
for (const [i, rp] of (validatePhase.remediation_paths || []).entries()) {
|
|
540
413
|
if (!rp || typeof rp !== 'object' || !Array.isArray(rp.for_signals)) continue;
|
|
@@ -547,18 +420,11 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
547
420
|
}
|
|
548
421
|
}
|
|
549
422
|
|
|
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.
|
|
423
|
+
// Helpers, so the same checks run against a directive's phase_overrides copy.
|
|
554
424
|
checkRwepThreshold(direct.rwep_threshold, 'phases.direct.rwep_threshold');
|
|
555
425
|
|
|
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
426
|
checkClockStarts(govern.jurisdiction_obligations, 'phases.govern.jurisdiction_obligations');
|
|
560
427
|
|
|
561
|
-
// notification_actions obligation_ref resolution.
|
|
562
428
|
const obligationKeys = new Set(
|
|
563
429
|
(govern.jurisdiction_obligations || []).map(obligationKey),
|
|
564
430
|
);
|
|
@@ -572,15 +438,9 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
572
438
|
}
|
|
573
439
|
}
|
|
574
440
|
|
|
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.
|
|
441
|
+
// The escalation context resolves the flat keys plus `analyze` and `finding`;
|
|
442
|
+
// feeds_into also resolves `validate`. A dotted path rooted at any other phase
|
|
443
|
+
// name never resolves. Bare identifiers are agent-signal names, not checked.
|
|
584
444
|
const conditionPathRoots = (cond) => {
|
|
585
445
|
if (typeof cond !== 'string') return [];
|
|
586
446
|
const stripped = cond.replace(/'[^']*'|"[^"]*"|\/[^/\n]*\//g, ' ');
|
|
@@ -614,31 +474,12 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
614
474
|
}
|
|
615
475
|
}
|
|
616
476
|
|
|
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).
|
|
477
|
+
// Parseability gate over the three places the runner threads a condition through
|
|
478
|
+
// evalCondition: analyze escalation_criteria, _meta.feeds_into, and validate
|
|
479
|
+
// remediation_paths preconditions. Only condition_unparsed gates: a parse failure
|
|
480
|
+
// is input-independent, where condition_path_unresolved and
|
|
481
|
+
// condition_type_mismatch are runtime diagnostics that false-positive here.
|
|
638
482
|
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
483
|
warn(
|
|
643
484
|
'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
485
|
);
|
|
@@ -646,9 +487,6 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
646
487
|
const conditionParses = (cond) => {
|
|
647
488
|
if (typeof cond !== 'string' || !cond.trim()) return true;
|
|
648
489
|
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
490
|
for (const atom of atomizeCondition(cond)) {
|
|
653
491
|
const runErrors = [];
|
|
654
492
|
try { _evalCondition(atom, { _runErrors: runErrors }, { _runErrors: runErrors }); }
|
|
@@ -682,26 +520,15 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
682
520
|
}
|
|
683
521
|
}
|
|
684
522
|
|
|
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.
|
|
523
|
+
// Under _meta.air_gap_mode the runner refuses the network, so a network-sourced
|
|
524
|
+
// artifact needs a non-empty air_gap_alternative. The schema states this as an
|
|
525
|
+
// allOf/if/then block the inline validator does not implement.
|
|
692
526
|
if (meta.air_gap_mode === true) {
|
|
693
527
|
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).
|
|
528
|
+
// `api/v\d` is deliberately not a token — it false-positives on local
|
|
529
|
+
// code-scan artifacts that merely reference an API path. The same `source`
|
|
530
|
+
// pattern lives in lib/schemas/playbook.schema.json and must be broadened
|
|
531
|
+
// in lockstep with this one.
|
|
705
532
|
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
533
|
for (const [i, art] of (look.artifacts || []).entries()) {
|
|
707
534
|
if (!art || typeof art !== 'object') continue;
|
|
@@ -716,11 +543,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
716
543
|
}
|
|
717
544
|
}
|
|
718
545
|
|
|
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.
|
|
546
|
+
// TTP-mapping floor (Hard Rule #4): every playbook maps to at least one
|
|
547
|
+
// technique. The cross-cutting correlation layer has no first-party TTPs.
|
|
724
548
|
const atlasCount = (domain.atlas_refs || []).length;
|
|
725
549
|
const attackCount = (domain.attack_refs || []).length;
|
|
726
550
|
if (atlasCount === 0 && attackCount === 0 && meta.scope !== 'cross-cutting') {
|
|
@@ -729,9 +553,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
729
553
|
);
|
|
730
554
|
}
|
|
731
555
|
|
|
732
|
-
// frameworks_in_scope
|
|
733
|
-
//
|
|
734
|
-
// drives gap-analysis routing.
|
|
556
|
+
// frameworks_in_scope drives gap-analysis routing, so a value outside the
|
|
557
|
+
// schema's closed enum errors rather than warns.
|
|
735
558
|
if (ctx.frameworksEnum) {
|
|
736
559
|
for (const [i, f] of (domain.frameworks_in_scope || []).entries()) {
|
|
737
560
|
if (typeof f === 'string' && !ctx.frameworksEnum.has(f)) {
|
|
@@ -742,19 +565,12 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
742
565
|
}
|
|
743
566
|
}
|
|
744
567
|
|
|
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.
|
|
568
|
+
// Directives reach the runner live — phase_overrides deep-merged into the base
|
|
569
|
+
// phase — so they face the same cross-reference and override checks.
|
|
752
570
|
for (const [i, d] of (playbook.directives || []).entries()) {
|
|
753
571
|
if (!d || typeof d !== 'object') continue;
|
|
754
572
|
const label = d.id ? `directives[${i}] (${d.id})` : `directives[${i}]`;
|
|
755
573
|
|
|
756
|
-
// applies_to.{cve,atlas_ttp,attack_technique} resolution, mirroring the
|
|
757
|
-
// domain-ref checks at warning severity (promoted to error under --strict).
|
|
758
574
|
const at = d.applies_to;
|
|
759
575
|
if (at && typeof at === 'object') {
|
|
760
576
|
if (at.cve && !ctx.cveKeys.has(at.cve)) {
|
|
@@ -763,17 +579,13 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
763
579
|
if (at.atlas_ttp && !ctx.atlasKeys.has(at.atlas_ttp)) {
|
|
764
580
|
warn(`${label}.applies_to.atlas_ttp: unresolved "${at.atlas_ttp}" (not in data/atlas-ttps.json)`);
|
|
765
581
|
}
|
|
766
|
-
|
|
767
|
-
// attack-techniques.json is loaded via readJsonIfExists and may be null.
|
|
768
|
-
if (at.attack_technique && ctx.attackKeys && !ctx.attackKeys.has(at.attack_technique)) {
|
|
582
|
+
if (at.attack_technique && !ctx.attackKeys.has(at.attack_technique)) {
|
|
769
583
|
warn(`${label}.applies_to.attack_technique: unresolved "${at.attack_technique}" (not in data/attack-techniques.json)`);
|
|
770
584
|
}
|
|
771
585
|
}
|
|
772
586
|
|
|
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.
|
|
587
|
+
// The runner merges these into the base phase, so an override-supplied
|
|
588
|
+
// clock_starts or rwep_threshold must clear the same gates.
|
|
777
589
|
const ov = d.phase_overrides;
|
|
778
590
|
if (ov && typeof ov === 'object') {
|
|
779
591
|
if (ov.govern && typeof ov.govern === 'object') {
|
|
@@ -788,10 +600,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
788
600
|
`${label}.phase_overrides.direct.rwep_threshold`,
|
|
789
601
|
);
|
|
790
602
|
}
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
// base govern obligations, plus any the override adds. Warning severity,
|
|
794
|
-
// matching the base-phase obligation_ref precedent.
|
|
603
|
+
// Resolved against the effective set the runner sees after the merge: the
|
|
604
|
+
// override's own obligations when it supplies them, else the base phase's.
|
|
795
605
|
if (ov.close && typeof ov.close === 'object' && Array.isArray(ov.close.notification_actions)) {
|
|
796
606
|
const overrideObligations =
|
|
797
607
|
(ov.govern && Array.isArray(ov.govern.jurisdiction_obligations))
|
|
@@ -812,29 +622,21 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
812
622
|
|
|
813
623
|
return findings;
|
|
814
624
|
|
|
815
|
-
//
|
|
625
|
+
// Hoisted for the calls above; they close over `findings`, `ctx` and `err`.
|
|
816
626
|
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
// breaks the scoring path. `pathPrefix` keeps the message accurate whether
|
|
820
|
-
// the source is the base phase or a directive override.
|
|
627
|
+
// close <= monitor <= escalate, each in 0..100. `pathPrefix` keeps the message
|
|
628
|
+
// accurate for both the base phase and a directive override.
|
|
821
629
|
function checkRwepThreshold(rwepObj, pathPrefix) {
|
|
822
630
|
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.
|
|
631
|
+
// Per-key and independent: an override is a partial fragment, so gating the
|
|
632
|
+
// range check on all three keys present lets `{escalate: 150}` through.
|
|
829
633
|
for (const k of ['close', 'monitor', 'escalate']) {
|
|
830
634
|
const v = rwep[k];
|
|
831
635
|
if (typeof v === 'number' && (v < 0 || v > 100)) {
|
|
832
636
|
err(`${pathPrefix}.${k}: ${v} outside 0..100`);
|
|
833
637
|
}
|
|
834
638
|
}
|
|
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).
|
|
639
|
+
// Ordering needs the full triple: a partial override inherits its missing edges.
|
|
838
640
|
if (
|
|
839
641
|
typeof rwep.close === 'number' &&
|
|
840
642
|
typeof rwep.monitor === 'number' &&
|
|
@@ -848,9 +650,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
848
650
|
}
|
|
849
651
|
}
|
|
850
652
|
|
|
851
|
-
// clock_starts
|
|
852
|
-
//
|
|
853
|
-
// counting; an out-of-vocabulary value silently never starts the clock.
|
|
653
|
+
// clock_starts decides when a notification deadline begins counting; an
|
|
654
|
+
// out-of-vocabulary value never starts the clock.
|
|
854
655
|
function checkClockStarts(obligations, pathPrefix) {
|
|
855
656
|
if (!ctx.clockStartsEnum || !Array.isArray(obligations)) return;
|
|
856
657
|
for (const [i, o] of obligations.entries()) {
|
|
@@ -864,19 +665,10 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
|
864
665
|
}
|
|
865
666
|
}
|
|
866
667
|
|
|
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
|
-
*/
|
|
668
|
+
/* `_meta.mutex` is symmetric: if playbook A lists B, B must list A. The engine
|
|
669
|
+
* blocks concurrency only from the side that declared the conflict, so an
|
|
670
|
+
* asymmetric declaration degrades to a race. Returns one warning per asymmetric
|
|
671
|
+
* pair, keyed by the declaring playbook. */
|
|
880
672
|
function checkMutexReciprocity(playbooks) {
|
|
881
673
|
const mutexMap = new Map();
|
|
882
674
|
for (const pb of playbooks) {
|