@blamejs/exceptd-skills 0.13.18 → 0.13.19

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,338 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * scripts/audit-catalog-gaps.js
5
+ *
6
+ * Walks every data/*.json catalog and surfaces three classes of gap:
7
+ *
8
+ * 1. missing-context entries that exist but lack one of the
9
+ * documented context-search fields (e.g. RFC
10
+ * without abstract; ATT&CK technique without
11
+ * platforms; CVE without iocs)
12
+ *
13
+ * 2. dangling-ref forward references from one catalog into
14
+ * another that do not resolve (e.g. CVE
15
+ * entry's cwe_refs cites CWE-XXX but the
16
+ * local cwe-catalog does not carry that ID)
17
+ *
18
+ * 3. draft-debt per-catalog count of _auto_imported rows
19
+ * relative to operator-curated rows. High
20
+ * draft-debt = bulk-imported surface that has
21
+ * not been refined yet.
22
+ *
23
+ * Output: structured JSON to stdout (default) or human-readable summary
24
+ * with `--pretty`. Returns exit 0 in --warn-only mode (default); exit
25
+ * 1 in --strict mode if any class triggers.
26
+ *
27
+ * Usage:
28
+ * node scripts/audit-catalog-gaps.js # JSON
29
+ * node scripts/audit-catalog-gaps.js --pretty # human
30
+ * node scripts/audit-catalog-gaps.js --strict # exit 1 on gap
31
+ * node scripts/audit-catalog-gaps.js --catalog cve # one catalog
32
+ * node scripts/audit-catalog-gaps.js --class missing-context
33
+ *
34
+ * npm: `npm run audit-catalog-gaps`
35
+ *
36
+ * Design note: the gap analyzer is a separate detection plane from
37
+ * lib/validate-cve-catalog.js (schema validation, predeploy gate) and
38
+ * scripts/refresh-reverse-refs.js (forward/reverse-ref currency). The
39
+ * validator polices what's strictly required by the schema; the gap
40
+ * analyzer polices the recommended-but-not-required context envelope
41
+ * that lets an AI consumer find an entry by topic instead of by ID.
42
+ */
43
+
44
+ const fs = require("fs");
45
+ const path = require("path");
46
+
47
+ const ROOT = path.join(__dirname, "..");
48
+ const DATA = path.join(ROOT, "data");
49
+ const TODAY = new Date().toISOString().slice(0, 10);
50
+
51
+ // Per-catalog required context fields. Each entry in the array is a
52
+ // field path (dot-separated for nested) and a non-emptiness predicate.
53
+ // Pillar / Class / Pillar-abstraction CWEs and similar can opt out via
54
+ // the suppression key on the entry (_gap_skip: { fields: [...] }).
55
+ const SPEC = {
56
+ "cve-catalog": {
57
+ file: "cve-catalog.json",
58
+ idShape: /^(CVE-|MAL-|BUG-|GHSA-|SNYK-)/,
59
+ required_context: [
60
+ { field: "iocs", check: (v) => v && (
61
+ (Array.isArray(v.payload_artifacts) && v.payload_artifacts.length) ||
62
+ (Array.isArray(v.behavioral) && v.behavioral.length)
63
+ ), label: "iocs.payload_artifacts or iocs.behavioral" },
64
+ { field: "framework_control_gaps", check: (v) => v && Object.keys(v).length > 0, label: "framework_control_gaps" },
65
+ { field: "attack_refs", check: (v) => Array.isArray(v) && v.length > 0, label: "attack_refs" },
66
+ { field: "cwe_refs", check: (v) => Array.isArray(v) && v.length > 0, label: "cwe_refs" },
67
+ { field: "verification_sources", check: (v) => Array.isArray(v) && v.length > 0, label: "verification_sources" }
68
+ ],
69
+ refs: [
70
+ { field: "cwe_refs", target: "cwe-catalog.json", item: true },
71
+ { field: "attack_refs", target: "attack-techniques.json", item: true },
72
+ { field: "atlas_refs", target: "atlas-ttps.json", item: true },
73
+ { field: "framework_control_gaps", target: "framework-control-gaps.json", keys: true }
74
+ ]
75
+ },
76
+ "cwe-catalog": {
77
+ file: "cwe-catalog.json",
78
+ idShape: /^CWE-\d+$/,
79
+ required_context: [
80
+ { field: "name", check: (v) => typeof v === "string" && v.length > 0, label: "name" },
81
+ { field: "abstraction", check: (v) => typeof v === "string" && v.length > 0, label: "abstraction" },
82
+ { field: "description", check: (v) => typeof v === "string" && v.length > 20, label: "description (>20 chars)" }
83
+ ],
84
+ refs: []
85
+ },
86
+ "attack-techniques": {
87
+ file: "attack-techniques.json",
88
+ idShape: /^T\d{4}(\.\d{3})?$/,
89
+ required_context: [
90
+ { field: "name", check: (v) => typeof v === "string" && v.length > 0, label: "name" },
91
+ { field: "tactic", check: (v) => (Array.isArray(v) ? v.length > 0 : typeof v === "string" && v.length > 0), label: "tactic" },
92
+ { field: "description", check: (v) => typeof v === "string" && v.length > 0, label: "description (short)" },
93
+ { field: "platforms", check: (v) => Array.isArray(v) && v.length > 0, label: "platforms" }
94
+ ],
95
+ refs: []
96
+ },
97
+ "atlas-ttps": {
98
+ file: "atlas-ttps.json",
99
+ idShape: /^AML\.T\d{4}(\.\d{3})?$/,
100
+ required_context: [
101
+ { field: "name", check: (v) => typeof v === "string" && v.length > 0, label: "name" },
102
+ { field: "tactic", check: (v) => (Array.isArray(v) ? v.length > 0 : typeof v === "string" && v.length > 0), label: "tactic" },
103
+ { field: "description", check: (v) => typeof v === "string" && v.length > 0, label: "description" }
104
+ ],
105
+ refs: []
106
+ },
107
+ "d3fend-catalog": {
108
+ file: "d3fend-catalog.json",
109
+ idShape: /^D3-/,
110
+ required_context: [
111
+ { field: "name", check: (v) => typeof v === "string" && v.length > 0, label: "name" },
112
+ { field: "tactic", check: (v) => typeof v === "string" && v.length > 0, label: "tactic" },
113
+ { field: "description", check: (v) => typeof v === "string" && v.length > 0, label: "description" }
114
+ ],
115
+ refs: []
116
+ },
117
+ "rfc-references": {
118
+ file: "rfc-references.json",
119
+ idShape: /^(RFC-\d+|DRAFT-|ISO-|CSAF-)/,
120
+ required_context: [
121
+ { field: "title", check: (v) => typeof v === "string" && v.length > 0, label: "title" },
122
+ { field: "status", check: (v) => typeof v === "string" && v.length > 0, label: "status" },
123
+ { field: "abstract", check: (v) => typeof v === "string" && v.length > 20, label: "abstract (>20 chars)" }
124
+ ],
125
+ refs: []
126
+ },
127
+ "framework-control-gaps": {
128
+ file: "framework-control-gaps.json",
129
+ idShape: /^[A-Z]/,
130
+ required_context: [
131
+ { field: "framework", check: (v) => typeof v === "string" && v.length > 0, label: "framework" },
132
+ { field: "control_id", check: (v) => typeof v === "string" && v.length > 0, label: "control_id" },
133
+ { field: "control_name", check: (v) => typeof v === "string" && v.length > 0, label: "control_name" },
134
+ { field: "real_requirement", check: (v) => typeof v === "string" && v.length > 20, label: "real_requirement (>20 chars)" },
135
+ { field: "theater_test", check: (v) => v && typeof v.claim === "string" && typeof v.test === "string", label: "theater_test{claim,test}" },
136
+ { field: "evidence_cves", check: (v) => Array.isArray(v) && v.length > 0, label: "evidence_cves" }
137
+ ],
138
+ refs: []
139
+ },
140
+ "zeroday-lessons": {
141
+ file: "zeroday-lessons.json",
142
+ idShape: /^(CVE-|MAL-|BUG-)/,
143
+ required_context: [
144
+ { field: "attack_vector", check: (v) => v && typeof v.description === "string" && v.description.length > 20, label: "attack_vector.description" },
145
+ { field: "framework_coverage", check: (v) => v && Object.keys(v).length > 0, label: "framework_coverage" },
146
+ { field: "new_control_requirements", check: (v) => Array.isArray(v) && v.length > 0, label: "new_control_requirements" }
147
+ ],
148
+ refs: []
149
+ }
150
+ };
151
+
152
+ function loadCatalog(name) {
153
+ return JSON.parse(fs.readFileSync(path.join(DATA, name), "utf8"));
154
+ }
155
+
156
+ function inspect(catalogKey) {
157
+ const spec = SPEC[catalogKey];
158
+ if (!spec) throw new Error(`unknown catalog: ${catalogKey}`);
159
+ const cat = loadCatalog(spec.file);
160
+ const ids = Object.keys(cat).filter((k) => k !== "_meta");
161
+ const report = {
162
+ catalog: catalogKey,
163
+ entries: ids.length,
164
+ auto_imported: 0,
165
+ operator_curated: 0,
166
+ missing_context: [],
167
+ dangling_refs: []
168
+ };
169
+ for (const id of ids) {
170
+ if (!spec.idShape.test(id)) continue;
171
+ const e = cat[id];
172
+ if (!e) continue;
173
+ if (e._auto_imported) report.auto_imported++;
174
+ else report.operator_curated++;
175
+ const skip = e._gap_skip && Array.isArray(e._gap_skip.fields) ? new Set(e._gap_skip.fields) : new Set();
176
+ for (const r of spec.required_context) {
177
+ if (skip.has(r.field)) continue;
178
+ if (!r.check(e[r.field])) {
179
+ report.missing_context.push({ id, field: r.field, label: r.label });
180
+ }
181
+ }
182
+ }
183
+ return report;
184
+ }
185
+
186
+ function inspectRefs(allCatalogs) {
187
+ const findings = [];
188
+ const cveCat = allCatalogs["cve-catalog"];
189
+ const cweCat = allCatalogs["cwe-catalog"];
190
+ const attCat = allCatalogs["attack-techniques"];
191
+ const atlCat = allCatalogs["atlas-ttps"];
192
+ const fwCat = allCatalogs["framework-control-gaps"];
193
+ // Build presence sets keyed by id (sans _meta).
194
+ const cweSet = new Set(Object.keys(cweCat).filter((k) => k !== "_meta"));
195
+ const attSet = new Set(Object.keys(attCat).filter((k) => k !== "_meta"));
196
+ const atlSet = new Set(Object.keys(atlCat).filter((k) => k !== "_meta"));
197
+ const fwSet = new Set(Object.keys(fwCat).filter((k) => k !== "_meta"));
198
+ for (const id of Object.keys(cveCat)) {
199
+ if (id === "_meta") continue;
200
+ const e = cveCat[id];
201
+ if (!e) continue;
202
+ for (const ref of (e.cwe_refs || [])) {
203
+ if (!cweSet.has(ref)) findings.push({ kind: "dangling-ref", source_catalog: "cve-catalog", source_id: id, target_catalog: "cwe-catalog", missing: ref });
204
+ }
205
+ for (const ref of (e.attack_refs || [])) {
206
+ if (!attSet.has(ref)) findings.push({ kind: "dangling-ref", source_catalog: "cve-catalog", source_id: id, target_catalog: "attack-techniques", missing: ref });
207
+ }
208
+ for (const ref of (e.atlas_refs || [])) {
209
+ if (!atlSet.has(ref)) findings.push({ kind: "dangling-ref", source_catalog: "cve-catalog", source_id: id, target_catalog: "atlas-ttps", missing: ref });
210
+ }
211
+ const fcg = e.framework_control_gaps || {};
212
+ for (const key of Object.keys(fcg)) {
213
+ if (!fwSet.has(key)) findings.push({ kind: "dangling-ref", source_catalog: "cve-catalog", source_id: id, target_catalog: "framework-control-gaps", missing: key });
214
+ }
215
+ }
216
+ return findings;
217
+ }
218
+
219
+ function parseArgs(argv) {
220
+ const out = { pretty: false, strict: false, catalog: null, klass: null };
221
+ for (let i = 2; i < argv.length; i++) {
222
+ const a = argv[i];
223
+ if (a === "--pretty") out.pretty = true;
224
+ else if (a === "--strict") out.strict = true;
225
+ else if (a === "--catalog") out.catalog = argv[++i];
226
+ else if (a === "--class") out.klass = argv[++i];
227
+ }
228
+ return out;
229
+ }
230
+
231
+ function emitPretty(report) {
232
+ const lines = [];
233
+ lines.push("Catalog gap audit");
234
+ lines.push("=================");
235
+ for (const r of report.per_catalog) {
236
+ lines.push(`\n[${r.catalog}] entries=${r.entries} auto-imported=${r.auto_imported} operator-curated=${r.operator_curated}`);
237
+ if (r.missing_context.length === 0) {
238
+ lines.push(" ✓ context complete on every entry");
239
+ } else {
240
+ // Group by field for tidier output.
241
+ const byField = new Map();
242
+ for (const m of r.missing_context) {
243
+ if (!byField.has(m.field)) byField.set(m.field, []);
244
+ byField.get(m.field).push(m.id);
245
+ }
246
+ for (const [field, ids] of byField) {
247
+ lines.push(` missing ${field} on ${ids.length} entries: ${ids.slice(0, 5).join(", ")}${ids.length > 5 ? ` ... +${ids.length - 5}` : ""}`);
248
+ }
249
+ }
250
+ }
251
+ lines.push("\nCross-catalog dangling refs:");
252
+ if (report.dangling_refs.length === 0) {
253
+ lines.push(" ✓ every cross-ref resolves");
254
+ } else {
255
+ const byTarget = new Map();
256
+ for (const f of report.dangling_refs) {
257
+ const key = `${f.source_catalog}.${f.target_catalog}`;
258
+ if (!byTarget.has(key)) byTarget.set(key, []);
259
+ byTarget.get(key).push(`${f.source_id} → ${f.missing}`);
260
+ }
261
+ for (const [k, list] of byTarget) {
262
+ lines.push(` ${k}: ${list.length} dangling`);
263
+ for (const l of list.slice(0, 5)) lines.push(` ${l}`);
264
+ if (list.length > 5) lines.push(` ... +${list.length - 5}`);
265
+ }
266
+ }
267
+ lines.push("\nDraft debt (auto-imported / total):");
268
+ for (const r of report.per_catalog) {
269
+ const pct = r.entries === 0 ? 0 : ((r.auto_imported / r.entries) * 100).toFixed(1);
270
+ lines.push(` ${r.catalog.padEnd(28)} ${r.auto_imported} / ${r.entries} (${pct}%)`);
271
+ }
272
+ return lines.join("\n");
273
+ }
274
+
275
+ // Valid finding-class names for the `--class` filter. The pretty + JSON
276
+ // emitters always include every section, but counts and strict-exit
277
+ // gating respect the active filter.
278
+ const VALID_CLASSES = new Set(["missing-context", "dangling-ref", "draft-debt"]);
279
+
280
+ function main() {
281
+ const opts = parseArgs(process.argv);
282
+ if (opts.klass && !VALID_CLASSES.has(opts.klass)) {
283
+ console.error(`unknown class: ${opts.klass} valid: ${[...VALID_CLASSES].join(", ")}`);
284
+ process.exitCode = 2;
285
+ return;
286
+ }
287
+ const catalogKeys = opts.catalog ? [opts.catalog] : Object.keys(SPEC);
288
+ const perCatalog = [];
289
+ const allLoaded = {};
290
+ for (const k of catalogKeys) {
291
+ if (!SPEC[k]) {
292
+ console.error(`unknown catalog: ${k} valid: ${Object.keys(SPEC).join(", ")}`);
293
+ process.exitCode = 2;
294
+ return;
295
+ }
296
+ perCatalog.push(inspect(k));
297
+ allLoaded[k] = loadCatalog(SPEC[k].file);
298
+ }
299
+ // Load all needed catalogs for cross-ref pass even when --catalog scoped.
300
+ for (const k of Object.keys(SPEC)) if (!allLoaded[k]) allLoaded[k] = loadCatalog(SPEC[k].file);
301
+ const dangling = opts.catalog && opts.catalog !== "cve-catalog" ? [] : inspectRefs(allLoaded);
302
+
303
+ // Apply the --class filter before counts + strict-exit gating.
304
+ // Missing-context findings on per_catalog and dangling_refs are the
305
+ // two policed classes; draft-debt is informational-only (the audit
306
+ // surfaces draft-debt but it does not fail strict mode by design).
307
+ const filteredPerCatalog = opts.klass === "dangling-ref" || opts.klass === "draft-debt"
308
+ ? perCatalog.map((r) => ({ ...r, missing_context: [] }))
309
+ : perCatalog;
310
+ const filteredDangling = opts.klass === "missing-context" || opts.klass === "draft-debt"
311
+ ? []
312
+ : dangling;
313
+
314
+ const report = {
315
+ generated_at: TODAY,
316
+ class_filter: opts.klass || null,
317
+ per_catalog: filteredPerCatalog,
318
+ dangling_refs: filteredDangling,
319
+ totals: {
320
+ catalogs: filteredPerCatalog.length,
321
+ entries: filteredPerCatalog.reduce((n, r) => n + r.entries, 0),
322
+ missing_context: filteredPerCatalog.reduce((n, r) => n + r.missing_context.length, 0),
323
+ dangling_refs: filteredDangling.length
324
+ }
325
+ };
326
+ if (opts.pretty) {
327
+ process.stdout.write(emitPretty(report) + "\n");
328
+ } else {
329
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
330
+ }
331
+ if (opts.strict && (report.totals.missing_context > 0 || report.totals.dangling_refs > 0)) {
332
+ process.exitCode = 1;
333
+ }
334
+ }
335
+
336
+ if (require.main === module) main();
337
+
338
+ module.exports = { SPEC, inspect, inspectRefs };
@@ -337,15 +337,23 @@ function extractCveIocChanges(beforeStr, afterStr) {
337
337
  for (const id of ids) {
338
338
  if (!/^CVE-\d{4}-\d+/.test(id)) continue;
339
339
  // v0.13.18: skip bulk-imported entries. Auto-imported rows carry stub
340
- // IoCs by design ("Refer to vendor advisory for IOC list — bulk-
341
- // imported KEV entry, IOCs not extracted at intake time."); their
342
- // per-entry IoCs are not the operator-curated surface the diff-
343
- // coverage gate is designed to police. When an operator later
344
- // curates the row, the next run will see the diff against the
345
- // curated form and route through the normal coverage path.
340
+ // IoCs by design; their per-entry IoCs are not the operator-curated
341
+ // surface the diff-coverage gate is designed to police.
346
342
  const beforeAuto = !!(before[id] && before[id]._auto_imported);
347
343
  const afterAuto = !!(after[id] && after[id]._auto_imported);
348
344
  if (beforeAuto && afterAuto) continue;
345
+ // v0.13.19: also skip operator-curated rows whose IoCs are flagged
346
+ // as stubs (`_iocs_stub: true` — generic placeholder added by the
347
+ // gap-fix pass when an entry was missing iocs entirely). When an
348
+ // operator later curates real IoCs the diff-coverage check fires
349
+ // normally because the curation step removes _iocs_stub.
350
+ const beforeStub = !!(before[id] && before[id]._iocs_stub);
351
+ const afterStub = !!(after[id] && after[id]._iocs_stub);
352
+ // Existing entry going stub→curated (afterStub=false, beforeStub=true)
353
+ // is what we WANT to flag for review. Existing entry going non-stub→
354
+ // stub or stub→stub or absent→stub are all auto-fill events the
355
+ // diff-coverage gate should not police.
356
+ if (afterStub) continue;
349
357
  const b = JSON.stringify((before[id] && before[id].iocs) || null);
350
358
  const a = JSON.stringify((after[id] && after[id].iocs) || null);
351
359
  if (b !== a) changed.add(id);
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * scripts/refresh-mitre-ics-attack.js
5
+ *
6
+ * Thin per-type wrapper for the MITRE ICS-attack STIX refresher. Logic
7
+ * lives in scripts/refresh-upstream-catalogs.js#refreshIcsAttack.
8
+ *
9
+ * node scripts/refresh-mitre-ics-attack.js [--dry-run]
10
+ *
11
+ * Wired as `npm run refresh-mitre-ics-attack`.
12
+ */
13
+ const { refreshIcsAttack } = require("./refresh-upstream-catalogs.js");
14
+ const dry = process.argv.includes("--dry-run");
15
+ refreshIcsAttack({ dry }).catch((e) => { console.error("[err]", e); process.exit(1); });