@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.
@@ -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
+ }