@blamejs/exceptd-skills 0.12.10 → 0.12.13
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 +131 -0
- package/README.md +3 -1
- package/bin/exceptd.js +152 -39
- package/data/_indexes/_meta.json +10 -9
- package/data/_indexes/activity-feed.json +11 -3
- package/data/_indexes/catalog-summaries.json +24 -2
- package/data/_indexes/frequency.json +2 -0
- package/data/attack-techniques.json +96 -0
- package/data/cve-catalog.json +9 -9
- package/data/cwe-catalog.json +4 -3
- package/data/framework-control-gaps.json +52 -0
- package/data/playbooks/library-author.json +3 -3
- package/lib/cve-curation.js +491 -46
- package/lib/lint-skills.js +212 -15
- package/lib/playbook-runner.js +485 -108
- package/lib/prefetch.js +121 -8
- package/lib/refresh-external.js +257 -81
- package/lib/refresh-network.js +15 -1
- package/lib/schemas/manifest.schema.json +16 -0
- package/lib/scoring.js +68 -5
- package/lib/sign.js +112 -3
- package/lib/source-ghsa.js +7 -1
- package/lib/source-osv.js +228 -57
- package/lib/validate-cve-catalog.js +171 -3
- package/lib/validate-playbooks.js +469 -0
- package/lib/verify.js +241 -16
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/orchestrator/scheduler.js +50 -7
- package/package.json +1 -1
- package/sbom.cdx.json +8 -8
- package/scripts/predeploy.js +31 -5
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* lib/validate-playbooks.js — exceptd playbook validator.
|
|
4
|
+
*
|
|
5
|
+
* Walks every JSON file in data/playbooks/, validates it against
|
|
6
|
+
* lib/schemas/playbook.schema.json (using the same inline JSON-Schema
|
|
7
|
+
* subset validator as lib/validate-cve-catalog.js), and additionally
|
|
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.cve_refs[] → data/cve-catalog.json keys
|
|
20
|
+
* - domain.cwe_refs[] → data/cwe-catalog.json keys
|
|
21
|
+
* - domain.d3fend_refs[] → data/d3fend-catalog.json keys
|
|
22
|
+
* - phases.detect.indicators[].attack_ref → data/attack-techniques.json
|
|
23
|
+
* - phases.detect.indicators[].atlas_ref → data/atlas-ttps.json
|
|
24
|
+
* - phases.detect.indicators[].cve_ref → data/cve-catalog.json
|
|
25
|
+
*
|
|
26
|
+
* Internal consistency:
|
|
27
|
+
* - Indicator ids are unique within a playbook.
|
|
28
|
+
* - rwep_threshold ordering: close <= monitor <= escalate, each in 0..100.
|
|
29
|
+
* - close.notification_actions[].obligation_ref resolves to a synthesized
|
|
30
|
+
* "<jurisdiction>/<regulation> <window_hours>h" key from
|
|
31
|
+
* govern.jurisdiction_obligations[] (the schema does not give
|
|
32
|
+
* jurisdiction_obligations an explicit `id` field; the shipped playbooks
|
|
33
|
+
* reference them by this composite string).
|
|
34
|
+
*
|
|
35
|
+
* Finding severity:
|
|
36
|
+
* - error — structural problems that block the runner (missing required
|
|
37
|
+
* field, JSON parse error, internal ordering violation,
|
|
38
|
+
* duplicate indicator id).
|
|
39
|
+
* - warning — schema-shape drift the runner can still tolerate (enum
|
|
40
|
+
* vocabulary lag, cross-catalog refs introduced after the
|
|
41
|
+
* playbook last shipped). v0.12.12 surfaces these to the
|
|
42
|
+
* operator without failing the gate; v0.13.0 will flip them
|
|
43
|
+
* to hard errors via predeploy `informational: false`.
|
|
44
|
+
*
|
|
45
|
+
* Exit code: 0 if no errors (warnings allowed), 1 if any errors, 2 on
|
|
46
|
+
* argv error.
|
|
47
|
+
*
|
|
48
|
+
* Usage:
|
|
49
|
+
* node lib/validate-playbooks.js validate every playbook
|
|
50
|
+
* node lib/validate-playbooks.js --quiet only print FAIL playbooks + summary
|
|
51
|
+
* node lib/validate-playbooks.js --strict treat warnings as errors (v0.13.0
|
|
52
|
+
* preview).
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
'use strict';
|
|
56
|
+
|
|
57
|
+
const fs = require('node:fs');
|
|
58
|
+
const path = require('node:path');
|
|
59
|
+
const process = require('node:process');
|
|
60
|
+
|
|
61
|
+
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
62
|
+
const SCHEMA_PATH = path.join(REPO_ROOT, 'lib', 'schemas', 'playbook.schema.json');
|
|
63
|
+
const PLAYBOOKS_DIR = path.join(REPO_ROOT, 'data', 'playbooks');
|
|
64
|
+
const MANIFEST_PATH = path.join(REPO_ROOT, 'manifest.json');
|
|
65
|
+
const ATLAS_PATH = path.join(REPO_ROOT, 'data', 'atlas-ttps.json');
|
|
66
|
+
const CVE_PATH = path.join(REPO_ROOT, 'data', 'cve-catalog.json');
|
|
67
|
+
const CWE_PATH = path.join(REPO_ROOT, 'data', 'cwe-catalog.json');
|
|
68
|
+
const D3FEND_PATH = path.join(REPO_ROOT, 'data', 'd3fend-catalog.json');
|
|
69
|
+
const ATTACK_PATH = path.join(REPO_ROOT, 'data', 'attack-techniques.json');
|
|
70
|
+
|
|
71
|
+
function parseArgs(argv) {
|
|
72
|
+
const opts = { quiet: false, strict: false };
|
|
73
|
+
for (let i = 2; i < argv.length; i++) {
|
|
74
|
+
const a = argv[i];
|
|
75
|
+
if (a === '--quiet' || a === '-q') opts.quiet = true;
|
|
76
|
+
else if (a === '--strict') opts.strict = true;
|
|
77
|
+
else if (a === '--help' || a === '-h') {
|
|
78
|
+
console.log(
|
|
79
|
+
'Usage: node lib/validate-playbooks.js [--quiet] [--strict]\n' +
|
|
80
|
+
'\n' +
|
|
81
|
+
' --quiet Suppress per-playbook PASS output; show failures only.\n' +
|
|
82
|
+
' --strict Treat warnings as errors (v0.13.0 preview).\n',
|
|
83
|
+
);
|
|
84
|
+
process.exit(0);
|
|
85
|
+
} else {
|
|
86
|
+
console.error(`Unknown argument: ${a}`);
|
|
87
|
+
process.exit(2);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return opts;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readJson(p) {
|
|
94
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readJsonIfExists(p) {
|
|
98
|
+
if (!fs.existsSync(p)) return null;
|
|
99
|
+
return readJson(p);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function typeOf(value) {
|
|
103
|
+
if (value === null) return 'null';
|
|
104
|
+
if (Array.isArray(value)) return 'array';
|
|
105
|
+
return typeof value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function typeMatches(value, expected) {
|
|
109
|
+
if (Array.isArray(expected)) return expected.some((t) => typeMatches(value, t));
|
|
110
|
+
const actual = typeOf(value);
|
|
111
|
+
if (expected === 'integer') return actual === 'number' && Number.isInteger(value);
|
|
112
|
+
return actual === expected;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* Inline JSON-Schema subset validator. Returns a flat list of finding objects
|
|
116
|
+
* shaped as { severity, message }. Severity defaults to 'error'; enum
|
|
117
|
+
* mismatches and unknown additional properties under
|
|
118
|
+
* additionalProperties:false are downgraded to 'warning' so vocabulary drift
|
|
119
|
+
* between the schema and shipped playbooks does not hard-fail v0.12.12.
|
|
120
|
+
* v0.13.0 will flip via --strict / predeploy informational:false. */
|
|
121
|
+
function validate(value, schema, schemaName, pathStr) {
|
|
122
|
+
const findings = [];
|
|
123
|
+
const here = pathStr || schemaName;
|
|
124
|
+
const err = (message, severity = 'error') => findings.push({ severity, message });
|
|
125
|
+
|
|
126
|
+
if (schema.type !== undefined) {
|
|
127
|
+
if (!typeMatches(value, schema.type)) {
|
|
128
|
+
err(`${here}: expected type ${JSON.stringify(schema.type)}, got ${typeOf(value)}`);
|
|
129
|
+
return findings;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (schema.enum !== undefined) {
|
|
134
|
+
if (!schema.enum.includes(value)) {
|
|
135
|
+
// Enum drift is downgraded to a warning so vocabulary-evolution does
|
|
136
|
+
// not break patch-class releases.
|
|
137
|
+
err(
|
|
138
|
+
`${here}: value ${JSON.stringify(value)} not in enum ${JSON.stringify(schema.enum)}`,
|
|
139
|
+
'warning',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const t = typeOf(value);
|
|
145
|
+
|
|
146
|
+
if (t === 'string') {
|
|
147
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
148
|
+
err(`${here}: string shorter than minLength ${schema.minLength}`);
|
|
149
|
+
}
|
|
150
|
+
if (schema.pattern !== undefined) {
|
|
151
|
+
const re = new RegExp(schema.pattern);
|
|
152
|
+
if (!re.test(value)) {
|
|
153
|
+
err(`${here}: string ${JSON.stringify(value)} does not match pattern /${schema.pattern}/`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (schema.format === 'uri') {
|
|
157
|
+
try {
|
|
158
|
+
new URL(value);
|
|
159
|
+
} catch {
|
|
160
|
+
err(`${here}: value ${JSON.stringify(value)} is not a valid URI`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (schema.format === 'date') {
|
|
164
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
165
|
+
err(`${here}: value ${JSON.stringify(value)} is not an ISO date (YYYY-MM-DD)`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (t === 'number') {
|
|
171
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
172
|
+
err(`${here}: value ${value} < minimum ${schema.minimum}`);
|
|
173
|
+
}
|
|
174
|
+
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
175
|
+
err(`${here}: value ${value} > maximum ${schema.maximum}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (t === 'array') {
|
|
180
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
181
|
+
err(`${here}: array shorter than minItems ${schema.minItems}`);
|
|
182
|
+
}
|
|
183
|
+
if (schema.items !== undefined) {
|
|
184
|
+
value.forEach((item, idx) => {
|
|
185
|
+
findings.push(...validate(item, schema.items, schemaName, `${here}[${idx}]`));
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (t === 'object') {
|
|
191
|
+
if (schema.required) {
|
|
192
|
+
for (const req of schema.required) {
|
|
193
|
+
if (!(req in value)) {
|
|
194
|
+
err(`${here}: missing required field "${req}"`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (schema.minProperties !== undefined && Object.keys(value).length < schema.minProperties) {
|
|
199
|
+
err(`${here}: object has fewer than ${schema.minProperties} properties`);
|
|
200
|
+
}
|
|
201
|
+
const props = schema.properties || {};
|
|
202
|
+
const allowAdditional = schema.additionalProperties !== false;
|
|
203
|
+
const addlSchema =
|
|
204
|
+
typeof schema.additionalProperties === 'object' ? schema.additionalProperties : null;
|
|
205
|
+
for (const [k, v] of Object.entries(value)) {
|
|
206
|
+
if (k in props) {
|
|
207
|
+
findings.push(...validate(v, props[k], schemaName, `${here}.${k}`));
|
|
208
|
+
} else if (addlSchema) {
|
|
209
|
+
findings.push(...validate(v, addlSchema, schemaName, `${here}.${k}`));
|
|
210
|
+
} else if (!allowAdditional) {
|
|
211
|
+
// Drift between schema and shipped data: surface as warning, not
|
|
212
|
+
// an error. v0.13.0 will flip these.
|
|
213
|
+
err(`${here}: unexpected property "${k}"`, 'warning');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return findings;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function loadContext() {
|
|
222
|
+
const manifest = readJson(MANIFEST_PATH);
|
|
223
|
+
const atlas = readJson(ATLAS_PATH);
|
|
224
|
+
const cve = readJson(CVE_PATH);
|
|
225
|
+
const cwe = readJson(CWE_PATH);
|
|
226
|
+
const d3 = readJson(D3FEND_PATH);
|
|
227
|
+
const attack = readJsonIfExists(ATTACK_PATH);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
skillKeys: new Set(manifest.skills.map((s) => s.name)),
|
|
231
|
+
atlasKeys: new Set(Object.keys(atlas).filter((k) => !k.startsWith('_'))),
|
|
232
|
+
cveKeys: new Set(Object.keys(cve).filter((k) => !k.startsWith('_'))),
|
|
233
|
+
cweKeys: new Set(Object.keys(cwe).filter((k) => !k.startsWith('_'))),
|
|
234
|
+
d3fendKeys: new Set(Object.keys(d3).filter((k) => !k.startsWith('_'))),
|
|
235
|
+
attackKeys: attack
|
|
236
|
+
? new Set(Object.keys(attack).filter((k) => !k.startsWith('_')))
|
|
237
|
+
: null,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function loadPlaybooks() {
|
|
242
|
+
if (!fs.existsSync(PLAYBOOKS_DIR)) return [];
|
|
243
|
+
const out = [];
|
|
244
|
+
for (const f of fs.readdirSync(PLAYBOOKS_DIR)) {
|
|
245
|
+
if (!f.endsWith('.json')) continue;
|
|
246
|
+
const p = path.join(PLAYBOOKS_DIR, f);
|
|
247
|
+
const entry = { file: f, path: p };
|
|
248
|
+
try {
|
|
249
|
+
entry.data = readJson(p);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
entry.parseError = e.message;
|
|
252
|
+
}
|
|
253
|
+
out.push(entry);
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function obligationKey(o) {
|
|
259
|
+
// The schema does not define an explicit `id` field on
|
|
260
|
+
// jurisdiction_obligations entries; the shipped playbooks reference them
|
|
261
|
+
// by the composite "<jurisdiction>/<regulation> <window_hours>h" string.
|
|
262
|
+
return `${o.jurisdiction}/${o.regulation} ${o.window_hours}h`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function checkCrossRefs(playbook, ctx, playbookIds) {
|
|
266
|
+
const findings = [];
|
|
267
|
+
const meta = playbook._meta || {};
|
|
268
|
+
const phases = playbook.phases || {};
|
|
269
|
+
const domain = playbook.domain || {};
|
|
270
|
+
const warn = (message) => findings.push({ severity: 'warning', message });
|
|
271
|
+
const err = (message) => findings.push({ severity: 'error', message });
|
|
272
|
+
|
|
273
|
+
for (const fi of meta.feeds_into || []) {
|
|
274
|
+
if (fi && fi.playbook_id && !playbookIds.has(fi.playbook_id)) {
|
|
275
|
+
warn(`_meta.feeds_into: unresolved playbook_id "${fi.playbook_id}"`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const m of meta.mutex || []) {
|
|
279
|
+
if (m && !playbookIds.has(m)) {
|
|
280
|
+
warn(`_meta.mutex: unresolved playbook_id "${m}"`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Some playbooks may carry a legacy _meta.skill_chain[] (string list); the
|
|
284
|
+
// canonical chain lives at phases.direct.skill_chain[].skill but we still
|
|
285
|
+
// resolve a flat list if present, per the task brief.
|
|
286
|
+
for (const s of meta.skill_chain || []) {
|
|
287
|
+
if (typeof s === 'string' && !ctx.skillKeys.has(s)) {
|
|
288
|
+
warn(`_meta.skill_chain: unresolved skill "${s}"`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const govern = phases.govern || {};
|
|
293
|
+
for (const s of govern.skill_preload || []) {
|
|
294
|
+
if (!ctx.skillKeys.has(s)) {
|
|
295
|
+
warn(`phases.govern.skill_preload: unresolved skill "${s}"`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const direct = phases.direct || {};
|
|
300
|
+
for (const sc of direct.skill_chain || []) {
|
|
301
|
+
if (sc && sc.skill && !ctx.skillKeys.has(sc.skill)) {
|
|
302
|
+
warn(`phases.direct.skill_chain: unresolved skill "${sc.skill}"`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const a of domain.atlas_refs || []) {
|
|
307
|
+
if (!ctx.atlasKeys.has(a)) {
|
|
308
|
+
warn(`domain.atlas_refs: unresolved "${a}" (not in data/atlas-ttps.json)`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const c of domain.cve_refs || []) {
|
|
312
|
+
if (!ctx.cveKeys.has(c)) {
|
|
313
|
+
warn(`domain.cve_refs: unresolved "${c}" (not in data/cve-catalog.json)`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const w of domain.cwe_refs || []) {
|
|
317
|
+
if (!ctx.cweKeys.has(w)) {
|
|
318
|
+
warn(`domain.cwe_refs: unresolved "${w}" (not in data/cwe-catalog.json)`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
for (const d of domain.d3fend_refs || []) {
|
|
322
|
+
if (!ctx.d3fendKeys.has(d)) {
|
|
323
|
+
warn(`domain.d3fend_refs: unresolved "${d}" (not in data/d3fend-catalog.json)`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Indicators: id uniqueness, attack_ref / atlas_ref / cve_ref resolution.
|
|
328
|
+
const detect = phases.detect || {};
|
|
329
|
+
const indIds = new Set();
|
|
330
|
+
const indicators = detect.indicators || [];
|
|
331
|
+
for (let i = 0; i < indicators.length; i++) {
|
|
332
|
+
const ind = indicators[i];
|
|
333
|
+
if (!ind || typeof ind !== 'object') continue;
|
|
334
|
+
if (ind.id) {
|
|
335
|
+
if (indIds.has(ind.id)) {
|
|
336
|
+
err(
|
|
337
|
+
`phases.detect.indicators[${i}]: duplicate indicator id "${ind.id}"`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
indIds.add(ind.id);
|
|
341
|
+
}
|
|
342
|
+
if (ind.attack_ref && ctx.attackKeys && !ctx.attackKeys.has(ind.attack_ref)) {
|
|
343
|
+
warn(
|
|
344
|
+
`phases.detect.indicators[${i}].attack_ref: unresolved "${ind.attack_ref}" (not in data/attack-techniques.json)`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (ind.atlas_ref && !ctx.atlasKeys.has(ind.atlas_ref)) {
|
|
348
|
+
warn(
|
|
349
|
+
`phases.detect.indicators[${i}].atlas_ref: unresolved "${ind.atlas_ref}" (not in data/atlas-ttps.json)`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
if (ind.cve_ref && !ctx.cveKeys.has(ind.cve_ref)) {
|
|
353
|
+
warn(
|
|
354
|
+
`phases.detect.indicators[${i}].cve_ref: unresolved "${ind.cve_ref}" (not in data/cve-catalog.json)`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// rwep_threshold ordering. Hard error — a misordered threshold actively
|
|
360
|
+
// breaks the scoring path.
|
|
361
|
+
const rwep = direct.rwep_threshold || {};
|
|
362
|
+
if (
|
|
363
|
+
typeof rwep.close === 'number' &&
|
|
364
|
+
typeof rwep.monitor === 'number' &&
|
|
365
|
+
typeof rwep.escalate === 'number'
|
|
366
|
+
) {
|
|
367
|
+
if (!(rwep.close <= rwep.monitor && rwep.monitor <= rwep.escalate)) {
|
|
368
|
+
err(
|
|
369
|
+
`phases.direct.rwep_threshold: ordering violation — expected close <= monitor <= escalate, got close=${rwep.close} monitor=${rwep.monitor} escalate=${rwep.escalate}`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
for (const [k, v] of [
|
|
373
|
+
['close', rwep.close],
|
|
374
|
+
['monitor', rwep.monitor],
|
|
375
|
+
['escalate', rwep.escalate],
|
|
376
|
+
]) {
|
|
377
|
+
if (v < 0 || v > 100) {
|
|
378
|
+
err(`phases.direct.rwep_threshold.${k}: ${v} outside 0..100`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// notification_actions obligation_ref resolution.
|
|
384
|
+
const obligationKeys = new Set(
|
|
385
|
+
(govern.jurisdiction_obligations || []).map(obligationKey),
|
|
386
|
+
);
|
|
387
|
+
const close = phases.close || {};
|
|
388
|
+
for (const [i, na] of (close.notification_actions || []).entries()) {
|
|
389
|
+
if (!na || typeof na !== 'object') continue;
|
|
390
|
+
if (na.obligation_ref && !obligationKeys.has(na.obligation_ref)) {
|
|
391
|
+
warn(
|
|
392
|
+
`phases.close.notification_actions[${i}].obligation_ref: unresolved "${na.obligation_ref}" — no matching govern.jurisdiction_obligations entry (synthesized as "<jurisdiction>/<regulation> <window_hours>h")`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return findings;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function main() {
|
|
401
|
+
const opts = parseArgs(process.argv);
|
|
402
|
+
const schema = readJson(SCHEMA_PATH);
|
|
403
|
+
const ctx = loadContext();
|
|
404
|
+
const playbooks = loadPlaybooks();
|
|
405
|
+
const playbookIds = new Set();
|
|
406
|
+
for (const pb of playbooks) {
|
|
407
|
+
if (pb.data && pb.data._meta && pb.data._meta.id) {
|
|
408
|
+
playbookIds.add(pb.data._meta.id);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let errored = 0;
|
|
413
|
+
let warned = 0;
|
|
414
|
+
for (const pb of playbooks) {
|
|
415
|
+
const label = pb.data && pb.data._meta && pb.data._meta.id
|
|
416
|
+
? pb.data._meta.id
|
|
417
|
+
: pb.file;
|
|
418
|
+
if (pb.parseError) {
|
|
419
|
+
errored++;
|
|
420
|
+
console.log(`FAIL ${label}`);
|
|
421
|
+
console.log(` - [error] JSON parse error: ${pb.parseError}`);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const findings = [
|
|
425
|
+
...validate(pb.data, schema, 'playbook', label),
|
|
426
|
+
...checkCrossRefs(pb.data, ctx, playbookIds),
|
|
427
|
+
];
|
|
428
|
+
const effective = opts.strict
|
|
429
|
+
? findings.map((f) => ({ ...f, severity: 'error' }))
|
|
430
|
+
: findings;
|
|
431
|
+
const errs = effective.filter((f) => f.severity === 'error');
|
|
432
|
+
const warns = effective.filter((f) => f.severity === 'warning');
|
|
433
|
+
if (errs.length === 0 && warns.length === 0) {
|
|
434
|
+
if (!opts.quiet) console.log(`PASS ${label}`);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (errs.length === 0) {
|
|
438
|
+
warned++;
|
|
439
|
+
if (!opts.quiet) console.log(`WARN ${label}`);
|
|
440
|
+
for (const f of warns) console.log(` - [warn] ${f.message}`);
|
|
441
|
+
} else {
|
|
442
|
+
errored++;
|
|
443
|
+
console.log(`FAIL ${label}`);
|
|
444
|
+
for (const f of errs) console.log(` - [error] ${f.message}`);
|
|
445
|
+
for (const f of warns) console.log(` - [warn] ${f.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const total = playbooks.length;
|
|
450
|
+
const passed = total - errored - warned;
|
|
451
|
+
console.log(
|
|
452
|
+
`\n${passed}/${total} playbooks validated` +
|
|
453
|
+
(warned ? `, ${warned} with warnings` : '') +
|
|
454
|
+
(errored ? `, ${errored} failed` : '') + '.',
|
|
455
|
+
);
|
|
456
|
+
process.exit(errored === 0 ? 0 : 1);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
module.exports = {
|
|
460
|
+
validate,
|
|
461
|
+
checkCrossRefs,
|
|
462
|
+
loadContext,
|
|
463
|
+
loadPlaybooks,
|
|
464
|
+
obligationKey,
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
if (require.main === module) {
|
|
468
|
+
main();
|
|
469
|
+
}
|