@blamejs/exceptd-skills 0.13.18 → 0.13.20

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.
@@ -329,6 +329,15 @@ function extractPlaybookIds(content) {
329
329
  return { indicators: ind, artifacts: arts };
330
330
  }
331
331
 
332
+ // Canonical-form recursive equality replaces JSON.stringify comparison.
333
+ // Pre-v0.13.20 the comparator was JSON.stringify(before.iocs) !==
334
+ // JSON.stringify(after.iocs) — non-canonical: key order, trailing
335
+ // whitespace, and numeric format differences all flagged as "changed"
336
+ // when the operator made no semantic change. Symptoms were patched
337
+ // twice with skip rules (_auto_imported, _iocs_stub) instead of fixing
338
+ // the comparator. v0.13.20 fixes the root cause.
339
+ const { canonicalEqual } = require("../lib/canonical-eq");
340
+
332
341
  function extractCveIocChanges(beforeStr, afterStr) {
333
342
  const before = safeParse(beforeStr) || {};
334
343
  const after = safeParse(afterStr) || {};
@@ -336,19 +345,16 @@ function extractCveIocChanges(beforeStr, afterStr) {
336
345
  const ids = new Set([...Object.keys(before), ...Object.keys(after)]);
337
346
  for (const id of ids) {
338
347
  if (!/^CVE-\d{4}-\d+/.test(id)) continue;
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.
348
+ // v0.13.18 retained skip rule: bulk-imported rows whose IoCs are
349
+ // stub-by-design on both sides pure intake-class events, not
350
+ // operator curation. Removing this would surface every fresh KEV
351
+ // bulk-import as a per-CVE iocs-modified finding.
346
352
  const beforeAuto = !!(before[id] && before[id]._auto_imported);
347
353
  const afterAuto = !!(after[id] && after[id]._auto_imported);
348
354
  if (beforeAuto && afterAuto) continue;
349
- const b = JSON.stringify((before[id] && before[id].iocs) || null);
350
- const a = JSON.stringify((after[id] && after[id].iocs) || null);
351
- if (b !== a) changed.add(id);
355
+ const bIocs = (before[id] && before[id].iocs) || null;
356
+ const aIocs = (after[id] && after[id].iocs) || null;
357
+ if (!canonicalEqual(bIocs, aIocs)) changed.add(id);
352
358
  }
353
359
  return changed;
354
360
  }
@@ -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); });
@@ -42,6 +42,19 @@ const path = require("path");
42
42
  const ROOT = path.join(__dirname, "..");
43
43
  const TODAY = new Date().toISOString().slice(0, 10);
44
44
 
45
+ // v0.13.20 class-3.11 fix: refreshers read their required-context list
46
+ // from the audit SPEC. Eliminates the parallel hardcoded field arrays
47
+ // that v0.13.17→19 carried (and forgot to keep in sync — the v0.13.19
48
+ // audit found 106 ATT&CK rows missing `description` + `tactic` because
49
+ // the v0.13.18 backfill list omitted those fields). One source of truth
50
+ // = the audit-catalog-gaps SPEC.
51
+ const AUDIT_SPEC = require("./audit-catalog-gaps.js").SPEC;
52
+ function specRequiredFields(catalogKey) {
53
+ const spec = AUDIT_SPEC[catalogKey];
54
+ if (!spec || !Array.isArray(spec.required_context)) return [];
55
+ return spec.required_context.map((r) => r.field);
56
+ }
57
+
45
58
  function fetchUrl(url) {
46
59
  return new Promise((resolve, reject) => {
47
60
  https.get(url, { headers: { "User-Agent": "exceptd-refresh-upstream-catalogs" } }, (r) => {
@@ -190,53 +203,56 @@ async function refreshRfc({ dry = false } = {}) {
190
203
  const body = await fetchUrl(RFC_SRC);
191
204
  console.log(`[refresh-upstream:rfc] index size: ${(body.length / 1e6).toFixed(2)} MB`);
192
205
  const re = /<rfc-entry>([\s\S]*?)<\/rfc-entry>/g;
193
- const entries = [];
206
+ const entries = []; // current — eligible for new-add
207
+ const backfillable = []; // any-status — eligible for backfill on existing rows
194
208
  let m;
195
209
  while ((m = re.exec(body)) !== null) {
196
210
  const e = parseRfcEntry(m[1]);
197
211
  if (!e) continue;
212
+ backfillable.push(e);
198
213
  if (e.obsoleted || e.status === "HISTORIC" || e.status === "UNKNOWN") continue;
199
214
  entries.push(e);
200
215
  }
201
- console.log(`[refresh-upstream:rfc] current entries: ${entries.length}`);
216
+ console.log(`[refresh-upstream:rfc] current entries: ${entries.length} (+ ${backfillable.length - entries.length} obsoleted/historic available for backfill on existing rows)`);
202
217
  const cat = loadCatalog("rfc-references.json");
203
218
  const existing = new Set(Object.keys(cat).filter((k) => k !== "_meta"));
204
- let added = 0, statusBumped = 0;
205
- for (const e of entries) {
219
+ let added = 0, statusBumped = 0, backfilledCount = 0;
220
+ // First pass: backfill ALL existing rows from the broader entry set
221
+ // (including obsoleted historics). Operator may have curated an
222
+ // obsoleted RFC in for documentation; we still want abstract/authors.
223
+ for (const e of backfillable) {
206
224
  const id = `RFC-${e.num}`;
207
- if (existing.has(id)) {
208
- const cur = cat[id];
209
- let touched = false;
210
- // Status bumps only on auto-imported rows — operator-curated rows
211
- // may have a deliberately-different status (e.g. tracking the
212
- // older standard while a successor is in Proposed Standard limbo).
213
- if (cur && cur._auto_imported && cur.status !== RFC_STATUS_MAP[e.status]) {
214
- cur.status = RFC_STATUS_MAP[e.status];
215
- touched = true;
216
- statusBumped++;
217
- }
218
- // Backfill context-search fields on ANY row (auto-imported or
219
- // operator-curated) when the local field is absent. Backfill is
220
- // additive — it never overwrites a value an operator chose, only
221
- // fills holes left by the pre-v0.13.18 catalog shape that stored
222
- // only title + status.
223
- if (cur && !cur.abstract && e.abstract) { cur.abstract = e.abstract; touched = true; }
224
- if (cur && (!cur.keywords || cur.keywords.length === 0) && e.keywords.length) { cur.keywords = e.keywords; touched = true; }
225
- if (cur && !cur.area && e.area) { cur.area = e.area; touched = true; }
226
- if (cur && !cur.working_group && e.wg) { cur.working_group = e.wg; touched = true; }
227
- if (cur && !cur.stream && e.stream) { cur.stream = e.stream; touched = true; }
228
- if (cur && (!cur.authors || cur.authors.length === 0) && e.authors.length) { cur.authors = e.authors; touched = true; }
229
- if (cur && !cur.doi && e.doi) { cur.doi = e.doi; touched = true; }
230
- if (cur && !cur.page_count && e.pageCount) { cur.page_count = e.pageCount; touched = true; }
231
- if (cur && (!cur.obsoletes || cur.obsoletes.length === 0) && e.obsoletes.length) { cur.obsoletes = e.obsoletes; touched = true; }
232
- if (cur && (!cur.updates || cur.updates.length === 0) && e.updates.length) { cur.updates = e.updates; touched = true; }
233
- if (cur && (!cur.updated_by || cur.updated_by.length === 0) && e.updatedBy.length) { cur.updated_by = e.updatedBy; touched = true; }
234
- if (cur && (!cur.is_also || cur.is_also.length === 0) && e.isAlso.length) { cur.is_also = e.isAlso; touched = true; }
235
- if (cur && !cur.txt_url) { cur.txt_url = `https://www.rfc-editor.org/rfc/rfc${e.num}.txt`; touched = true; }
236
- if (cur && !cur.html_url) { cur.html_url = `https://www.rfc-editor.org/rfc/rfc${e.num}.html`; touched = true; }
237
- if (touched && cur) cur.last_verified = TODAY;
238
- continue;
225
+ if (!existing.has(id)) continue;
226
+ const cur = cat[id];
227
+ if (!cur) continue;
228
+ let touched = false;
229
+ if (cur._auto_imported && cur.status !== RFC_STATUS_MAP[e.status]) {
230
+ cur.status = RFC_STATUS_MAP[e.status];
231
+ touched = true;
232
+ statusBumped++;
239
233
  }
234
+ if (!cur.abstract && e.abstract) { cur.abstract = e.abstract; touched = true; }
235
+ if ((!cur.keywords || cur.keywords.length === 0) && e.keywords.length) { cur.keywords = e.keywords; touched = true; }
236
+ if (!cur.area && e.area) { cur.area = e.area; touched = true; }
237
+ if (!cur.working_group && e.wg) { cur.working_group = e.wg; touched = true; }
238
+ if (!cur.stream && e.stream) { cur.stream = e.stream; touched = true; }
239
+ if ((!cur.authors || cur.authors.length === 0) && e.authors.length) { cur.authors = e.authors; touched = true; }
240
+ if (!cur.doi && e.doi) { cur.doi = e.doi; touched = true; }
241
+ if (!cur.page_count && e.pageCount) { cur.page_count = e.pageCount; touched = true; }
242
+ if ((!cur.obsoletes || cur.obsoletes.length === 0) && e.obsoletes.length) { cur.obsoletes = e.obsoletes; touched = true; }
243
+ if ((!cur.updates || cur.updates.length === 0) && e.updates.length) { cur.updates = e.updates; touched = true; }
244
+ if ((!cur.updated_by || cur.updated_by.length === 0) && e.updatedBy.length) { cur.updated_by = e.updatedBy; touched = true; }
245
+ if ((!cur.obsoleted_by || cur.obsoleted_by.length === 0) && e.obsoletedBy.length) { cur.obsoleted_by = e.obsoletedBy; touched = true; }
246
+ if ((!cur.is_also || cur.is_also.length === 0) && e.isAlso.length) { cur.is_also = e.isAlso; touched = true; }
247
+ if (!cur.txt_url) { cur.txt_url = `https://www.rfc-editor.org/rfc/rfc${e.num}.txt`; touched = true; }
248
+ if (!cur.html_url) { cur.html_url = `https://www.rfc-editor.org/rfc/rfc${e.num}.html`; touched = true; }
249
+ if (touched) { cur.last_verified = TODAY; backfilledCount++; }
250
+ }
251
+ // Second pass: add new "current" entries that weren't in the catalog.
252
+ for (const e of entries) {
253
+ const id = `RFC-${e.num}`;
254
+ // Existing rows handled in the first-pass backfill above.
255
+ if (existing.has(id)) continue;
240
256
  cat[id] = {
241
257
  number: e.num,
242
258
  title: e.title,
@@ -272,12 +288,12 @@ async function refreshRfc({ dry = false } = {}) {
272
288
  cat._meta.last_threat_review = TODAY;
273
289
  }
274
290
  if (dry) {
275
- console.log(`[refresh-upstream:rfc] DRY-RUN: +${added} new, ${statusBumped} status bumps.`);
276
- return { added, statusBumped };
291
+ console.log(`[refresh-upstream:rfc] DRY-RUN: +${added} new, ${backfilledCount} backfilled, ${statusBumped} status bumps.`);
292
+ return { added, statusBumped, backfilled: backfilledCount };
277
293
  }
278
294
  writeCatalog("rfc-references.json", cat);
279
- console.log(`[ok] rfc-references.json: +${added} entries, ${statusBumped} status bumps (now ${existing.size} total)`);
280
- return { added, statusBumped };
295
+ console.log(`[ok] rfc-references.json: +${added} entries, ${backfilledCount} backfilled, ${statusBumped} status bumps (now ${existing.size} total)`);
296
+ return { added, statusBumped, backfilled: backfilledCount };
281
297
  }
282
298
 
283
299
  // ---------------- ATT&CK ----------------
@@ -340,6 +356,17 @@ function backfillAttack(cur, fresh) {
340
356
  if (!cur[key] && val) { cur[key] = val; touched = true; }
341
357
  }
342
358
  };
359
+ // v0.13.19: include description (short) + tactic in the backfill set.
360
+ // Existing rows from the original 110-entry catalog often have only
361
+ // {name, version} — they need tactic + short-description too, not just
362
+ // the v0.13.18 description_full / platforms / detection additions.
363
+ fillIfEmpty("description", fresh.description);
364
+ // tactic: arrays only (existing rows may have a string tactic; do
365
+ // not overwrite a stringified tactic with an array form).
366
+ if ((!cur.tactic || (Array.isArray(cur.tactic) && cur.tactic.length === 0)) && Array.isArray(fresh.tactic) && fresh.tactic.length) {
367
+ cur.tactic = fresh.tactic;
368
+ touched = true;
369
+ }
343
370
  fillIfEmpty("description_full", fresh.description_full);
344
371
  fillIfEmpty("platforms", fresh.platforms);
345
372
  fillIfEmpty("data_sources", fresh.data_sources);
@@ -359,10 +386,20 @@ async function refreshAttack({ dry = false, cap = Infinity } = {}) {
359
386
  console.log("[refresh-upstream:attack] fetching MITRE ATT&CK STIX...");
360
387
  const body = await fetchUrl(ATTACK_SRC);
361
388
  const stix = JSON.parse(body);
362
- const techs = (stix.objects || []).filter(
389
+ // For NEW adds: live techniques only (skip revoked / deprecated).
390
+ // For BACKFILL on existing rows: include revoked too — an operator-
391
+ // curated row that references a now-revoked MITRE ID may still want
392
+ // the context fields (name / description / platforms) from the
393
+ // pre-revocation STIX record. Same logic as the RFC obsoleted-but-
394
+ // backfillable two-pass design.
395
+ const liveTechs = (stix.objects || []).filter(
363
396
  (o) => o.type === "attack-pattern" && !o.revoked && !o.x_mitre_deprecated
364
397
  );
365
- console.log(`[refresh-upstream:attack] STIX live techniques: ${techs.length}`);
398
+ const backfillTechs = (stix.objects || []).filter(
399
+ (o) => o.type === "attack-pattern"
400
+ );
401
+ console.log(`[refresh-upstream:attack] STIX live techniques: ${liveTechs.length} (+ ${backfillTechs.length - liveTechs.length} revoked/deprecated available for backfill on existing rows)`);
402
+ const techs = liveTechs;
366
403
  const local = loadCatalog("attack-techniques.json");
367
404
  const existing = new Set(Object.keys(local).filter((k) => k !== "_meta"));
368
405
  techs.sort((a, b) => {
@@ -375,28 +412,106 @@ async function refreshAttack({ dry = false, cap = Infinity } = {}) {
375
412
  });
376
413
  let added = 0;
377
414
  let backfilled = 0;
415
+ // First pass: backfill existing rows against the FULL technique set
416
+ // (including revoked) so operator-curated rows still get context.
417
+ for (const t of backfillTechs) {
418
+ const extRef = (t.external_references || []).find((r) => r.source_name === "mitre-attack");
419
+ if (!extRef || !extRef.external_id) continue;
420
+ const id = extRef.external_id;
421
+ if (!existing.has(id)) continue;
422
+ const fresh = attackEntryFromStix(t, extRef);
423
+ const cur = local[id];
424
+ if (backfillAttack(cur, fresh)) {
425
+ cur.last_verified = TODAY;
426
+ backfilled++;
427
+ }
428
+ }
429
+ // Second pass: add new entries from live techniques only.
378
430
  for (const t of techs) {
379
431
  const extRef = (t.external_references || []).find((r) => r.source_name === "mitre-attack");
380
432
  if (!extRef || !extRef.external_id) continue;
381
433
  const id = extRef.external_id;
434
+ if (existing.has(id)) continue;
435
+ if (added >= cap) continue;
436
+ local[id] = attackEntryFromStix(t, extRef);
437
+ existing.add(id);
438
+ added++;
439
+ }
440
+ if (dry) { console.log(`[refresh-upstream:attack] DRY-RUN: +${added} new, ${backfilled} context backfills`); return { added, backfilled }; }
441
+ if (local._meta) { local._meta.last_updated = TODAY; local._meta.last_threat_review = TODAY; }
442
+ writeCatalog("attack-techniques.json", local);
443
+ console.log(`[ok] attack-techniques.json: +${added} entries, ${backfilled} context backfills (now ${existing.size} total)`);
444
+ return { added, backfilled };
445
+ }
446
+
447
+ // ---------------- ICS-ATT&CK ----------------
448
+
449
+ const ICS_ATTACK_SRC = "https://raw.githubusercontent.com/mitre/cti/master/ics-attack/ics-attack.json";
450
+ const ICS_TACTIC_NAME = {
451
+ "initial-access": "Initial Access (ICS)",
452
+ "execution": "Execution (ICS)",
453
+ "persistence": "Persistence (ICS)",
454
+ "privilege-escalation": "Privilege Escalation (ICS)",
455
+ "evasion": "Evasion (ICS)",
456
+ "discovery": "Discovery (ICS)",
457
+ "lateral-movement": "Lateral Movement (ICS)",
458
+ "collection": "Collection (ICS)",
459
+ "command-and-control": "Command and Control (ICS)",
460
+ "inhibit-response-function": "Inhibit Response Function",
461
+ "impair-process-control": "Impair Process Control",
462
+ "impact": "Impact (ICS)"
463
+ };
464
+
465
+ async function refreshIcsAttack({ dry = false, cap = Infinity } = {}) {
466
+ console.log("[refresh-upstream:ics-attack] fetching MITRE ICS-attack STIX...");
467
+ const body = await fetchUrl(ICS_ATTACK_SRC);
468
+ const stix = JSON.parse(body);
469
+ const techs = (stix.objects || []).filter(
470
+ (o) => o.type === "attack-pattern" && !o.revoked && !o.x_mitre_deprecated
471
+ );
472
+ console.log(`[refresh-upstream:ics-attack] STIX live ICS techniques: ${techs.length}`);
473
+ const local = loadCatalog("attack-techniques.json");
474
+ const existing = new Set(Object.keys(local).filter((k) => k !== "_meta"));
475
+ let added = 0, backfilled = 0;
476
+ for (const t of techs) {
477
+ const extRef = (t.external_references || []).find((r) => r.source_name === "mitre-ics-attack" || r.source_name === "mitre-attack");
478
+ if (!extRef || !extRef.external_id) continue;
479
+ const id = extRef.external_id;
480
+ const tactics = (t.kill_chain_phases || [])
481
+ .filter((p) => (p.kill_chain_name || "").includes("ics"))
482
+ .map((p) => ICS_TACTIC_NAME[p.phase_name] || `${p.phase_name} (ICS)`);
483
+ const fullDesc = String(t.description || "").replace(/\s+/g, " ").trim();
484
+ let shortDesc = fullDesc.split(/\.\s/)[0];
485
+ if (shortDesc.length > 500) shortDesc = shortDesc.slice(0, 497) + "...";
486
+ if (shortDesc && !shortDesc.endsWith(".")) shortDesc += ".";
487
+ const fresh = {
488
+ id, name: t.name, version: "ics-attack-v15",
489
+ tactic: tactics,
490
+ description: shortDesc,
491
+ description_full: fullDesc,
492
+ platforms: Array.isArray(t.x_mitre_platforms) ? t.x_mitre_platforms : [],
493
+ detection: (t.x_mitre_detection || "").replace(/\s+/g, " ").trim() || null,
494
+ reference_url: extRef.url || `https://attack.mitre.org/techniques/${id}/`,
495
+ stix_id: t.id || null,
496
+ last_verified: TODAY,
497
+ _auto_imported: true,
498
+ _intake_method: "mitre-ics-attack-stix",
499
+ _matrix: "ics-attack"
500
+ };
382
501
  if (existing.has(id)) {
383
- const fresh = attackEntryFromStix(t, extRef);
384
502
  const cur = local[id];
385
- if (backfillAttack(cur, fresh)) {
386
- cur.last_verified = TODAY;
387
- backfilled++;
388
- }
503
+ if (backfillAttack(cur, fresh)) { cur.last_verified = TODAY; backfilled++; }
389
504
  continue;
390
505
  }
391
506
  if (added >= cap) continue;
392
- local[id] = attackEntryFromStix(t, extRef);
507
+ local[id] = fresh;
393
508
  existing.add(id);
394
509
  added++;
395
510
  }
396
- if (dry) { console.log(`[refresh-upstream:attack] DRY-RUN: +${added} new, ${backfilled} context backfills`); return { added, backfilled }; }
511
+ if (dry) { console.log(`[refresh-upstream:ics-attack] DRY-RUN: +${added} new, ${backfilled} backfills`); return { added, backfilled }; }
397
512
  if (local._meta) { local._meta.last_updated = TODAY; local._meta.last_threat_review = TODAY; }
398
513
  writeCatalog("attack-techniques.json", local);
399
- console.log(`[ok] attack-techniques.json: +${added} entries, ${backfilled} context backfills (now ${existing.size} total)`);
514
+ console.log(`[ok] attack-techniques.json: +${added} ICS entries, ${backfilled} backfills (now ${existing.size} total)`);
400
515
  return { added, backfilled };
401
516
  }
402
517
 
@@ -645,10 +760,11 @@ async function refreshD3fend({ dry = false, cap = Infinity } = {}) {
645
760
  // ---------------- CLI dispatcher ----------------
646
761
 
647
762
  const SOURCES = {
648
- rfc: { name: "ietf-rfc-index", run: refreshRfc },
649
- attack: { name: "mitre-attack-stix", run: refreshAttack },
650
- atlas: { name: "mitre-atlas-stix", run: refreshAtlas },
651
- d3fend: { name: "mitre-d3fend-owl", run: refreshD3fend }
763
+ rfc: { name: "ietf-rfc-index", run: refreshRfc },
764
+ attack: { name: "mitre-attack-stix", run: refreshAttack },
765
+ "ics-attack": { name: "mitre-ics-attack-stix", run: refreshIcsAttack },
766
+ atlas: { name: "mitre-atlas-stix", run: refreshAtlas },
767
+ d3fend: { name: "mitre-d3fend-owl", run: refreshD3fend }
652
768
  };
653
769
 
654
770
  function parseArgs(argv) {
@@ -690,6 +806,7 @@ if (require.main === module) {
690
806
  module.exports = {
691
807
  refreshRfc,
692
808
  refreshAttack,
809
+ refreshIcsAttack,
693
810
  refreshAtlas,
694
811
  refreshD3fend,
695
812
  SOURCES,