@blamejs/exceptd-skills 0.19.34 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/bin/exceptd.js +0 -5
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/collectors/ai-api.js +150 -22
  5. package/lib/collectors/cicd-pipeline-compromise.js +73 -28
  6. package/lib/collectors/library-author.js +141 -5
  7. package/lib/collectors/sbom.js +96 -12
  8. package/lib/collectors/scan-excludes.js +3 -2
  9. package/lib/cve-regression-watcher.js +0 -3
  10. package/lib/framework-gap.js +5 -0
  11. package/lib/lint-skills.js +24 -4
  12. package/lib/playbook-runner.js +68 -14
  13. package/lib/prefetch.js +3 -2
  14. package/lib/refresh-external.js +0 -6
  15. package/lib/refresh-network.js +3 -4
  16. package/lib/scoring.js +8 -1
  17. package/lib/ttp-mapper.js +14 -3
  18. package/lib/upstream-check-cli.js +26 -1
  19. package/lib/validate-cve-catalog.js +9 -2
  20. package/lib/validate-playbooks.js +9 -11
  21. package/manifest.json +53 -53
  22. package/orchestrator/index.js +0 -1
  23. package/package.json +1 -1
  24. package/sbom.cdx.json +78 -78
  25. package/scripts/audit-perf.js +24 -13
  26. package/scripts/builders/theater-fingerprints.js +9 -4
  27. package/scripts/check-agents-md-collectors.js +15 -3
  28. package/scripts/check-codebase-patterns.js +16 -3
  29. package/scripts/check-manifest-snapshot.js +49 -8
  30. package/scripts/check-test-coverage.js +17 -1
  31. package/scripts/refresh-mitre-atlas.js +5 -1
  32. package/scripts/refresh-mitre-ics-attack.js +5 -1
  33. package/scripts/refresh-rfc-index.js +6 -1
  34. package/scripts/refresh-upstream-catalogs.js +25 -13
  35. package/scripts/release.js +0 -2
  36. package/scripts/run-e2e-scenarios.js +2 -2
  37. package/scripts/verify-shipped-tarball.js +0 -1
@@ -8,9 +8,10 @@
8
8
  * - file-level, in the first 50 lines: // codebase-patterns:allow-file <class> — <reason>
9
9
  * - per-line, on the same line or up to 2 lines above: // allow:<class> — <reason>
10
10
  *
11
- * Owned elsewhere: phase/version vocabulary (check-version-tags.js), CLI-dispatch
12
- * process.exit (tests/safe-exit-grep.test.js), test assertions
13
- * (check-test-coverage.js), operator-output path leaks (operator-leak-grep.test.js).
11
+ * Owned elsewhere: phase/version vocabulary (check-version-tags.js), test
12
+ * assertions (check-test-coverage.js), and — both now in tests/cli.test.js,
13
+ * which absorbed the separate safe-exit-grep and operator-leak-grep files —
14
+ * the per-file CLI-dispatch process.exit ban and operator-output path leaks.
14
15
  */
15
16
 
16
17
  const fs = require("node:fs");
@@ -209,6 +210,18 @@ function inRanges(ranges, lineNo) {
209
210
  // stopping there leaves a real exit-after-write unflagged.
210
211
  const FUNCTION_START = /(^|[^.\w])function\b|=>\s*\{?\s*$|^\s*(async\s+)?(?!(?:if|for|while|switch|catch|do|else|with|finally|return)\b)[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/;
211
212
 
213
+ // Scope, stated so it is not mistaken for full coverage of the class: the
214
+ // backward scan recognises a result-channel write only where it is written
215
+ // LITERALLY — `process.stdout.write(` or `console.log(`. A write reached
216
+ // INDIRECTLY, through a helper called from the exiting function (`printHelp()`,
217
+ // `renderSummary()`), is invisible to it, so an exit-after-write of that shape
218
+ // passes this gate and has to be caught by review or a per-file test.
219
+ //
220
+ // Not closed by matching call sites of same-file writer functions: measured over
221
+ // lib/, orchestrator/, scripts/ and bin/, that heuristic cannot tell a helper
222
+ // that writes to STDOUT from one that writes to stderr via console.error, and it
223
+ // fires on exits that are correct. Closing it properly needs call-graph
224
+ // resolution of the write target, not another regex.
212
225
  function detectProcessExitAfterStdout(files) {
213
226
  const hits = [];
214
227
  for (const rel of (files || filesUnder(["bin/exceptd.js", "lib", "orchestrator", "scripts"]))) {
@@ -37,12 +37,47 @@ function captureSurface(manifest) {
37
37
  };
38
38
  }
39
39
 
40
+ // Absent and corrupt are different states and must not collapse into one.
41
+ //
42
+ // ABSENT (key not in the object) is the stale baseline this gate exists to
43
+ // report on: a snapshot committed before a surface field existed carries no key
44
+ // for it, and an absent field is an empty surface, not a crash. Reading it
45
+ // unguarded threw a TypeError that the CLI's outer catch turned into exit 2 with
46
+ // a stack trace instead of an additive-change report.
47
+ //
48
+ // PRESENT-BUT-NOT-AN-ARRAY (a string, an object, null) is corruption. Coercing
49
+ // it to [] would report every live entry as additive and exit 0 — a gate that
50
+ // passes without checking anything. It raises instead, and the outer catch turns
51
+ // that into exit 2 naming the skill and the field.
52
+ function asArray(value, skillName, field) {
53
+ if (value === undefined) return [];
54
+ if (Array.isArray(value)) return value;
55
+ throw new Error(
56
+ `${skillName}: ${field} is ${value === null ? "null" : typeof value}, not an array. ` +
57
+ "The baseline or manifest is corrupt, not merely stale — regenerate the baseline " +
58
+ "with `node scripts/refresh-manifest-snapshot.js` and re-check the manifest."
59
+ );
60
+ }
61
+
62
+ // captureSurface() always writes `skills`, so unlike the per-field case above
63
+ // there is no legitimate historical baseline without it: absent and non-array
64
+ // are both corruption here, and treating either as [] would report every skill
65
+ // as added and exit 0.
66
+ function skillList(surface, which) {
67
+ const skills = surface && surface.skills;
68
+ if (Array.isArray(skills)) return skills;
69
+ throw new Error(
70
+ `${which}.skills is ${skills === null ? "null" : typeof skills}, not an array. ` +
71
+ "Regenerate the baseline with `node scripts/refresh-manifest-snapshot.js`."
72
+ );
73
+ }
74
+
40
75
  function diff(baseline, current) {
41
76
  const breaking = [];
42
77
  const additive = [];
43
78
 
44
- const bSkills = new Map(baseline.skills.map(s => [s.name, s]));
45
- const cSkills = new Map(current.skills.map(s => [s.name, s]));
79
+ const bSkills = new Map(skillList(baseline, "baseline").map(s => [s.name, s]));
80
+ const cSkills = new Map(skillList(current, "current").map(s => [s.name, s]));
46
81
 
47
82
  for (const name of bSkills.keys()) {
48
83
  if (!cSkills.has(name)) {
@@ -70,32 +105,38 @@ function diff(baseline, current) {
70
105
  }
71
106
 
72
107
  // Removed trigger keywords break downstream skill matchers.
73
- const removedTriggers = b.triggers.filter(t => !c.triggers.includes(t));
108
+ const bTriggers = asArray(b.triggers, name, "baseline triggers");
109
+ const cTriggers = asArray(c.triggers, name, "triggers");
110
+ const removedTriggers = bTriggers.filter(t => !cTriggers.includes(t));
74
111
  if (removedTriggers.length > 0) {
75
112
  breaking.push(`${name}: removed trigger keywords: ${removedTriggers.join(", ")}`);
76
113
  }
77
- const addedTriggers = c.triggers.filter(t => !b.triggers.includes(t));
114
+ const addedTriggers = cTriggers.filter(t => !bTriggers.includes(t));
78
115
  if (addedTriggers.length > 0) {
79
116
  additive.push(`${name}: added trigger keywords: ${addedTriggers.join(", ")}`);
80
117
  }
81
118
 
82
119
  // Removed data deps break the skill at load time.
83
- const removedDeps = b.data_deps.filter(d => !c.data_deps.includes(d));
120
+ const bDeps = asArray(b.data_deps, name, "baseline data_deps");
121
+ const cDeps = asArray(c.data_deps, name, "data_deps");
122
+ const removedDeps = bDeps.filter(d => !cDeps.includes(d));
84
123
  if (removedDeps.length > 0) {
85
124
  breaking.push(`${name}: removed data deps: ${removedDeps.join(", ")}`);
86
125
  }
87
- const addedDeps = c.data_deps.filter(d => !b.data_deps.includes(d));
126
+ const addedDeps = cDeps.filter(d => !bDeps.includes(d));
88
127
  if (addedDeps.length > 0) {
89
128
  additive.push(`${name}: added data deps: ${addedDeps.join(", ")}`);
90
129
  }
91
130
 
92
131
  // Narrowing the cited surface is deliberate (AGENTS.md #4, #12), so removal is breaking.
93
132
  for (const field of ["atlas_refs", "attack_refs", "framework_gaps", "rfc_refs", "cwe_refs", "d3fend_refs", "dlp_refs"]) {
94
- const removed = b[field].filter(r => !c[field].includes(r));
133
+ const bRefs = asArray(b[field], name, `baseline ${field}`);
134
+ const cRefs = asArray(c[field], name, field);
135
+ const removed = bRefs.filter(r => !cRefs.includes(r));
95
136
  if (removed.length > 0) {
96
137
  breaking.push(`${name}: removed ${field}: ${removed.join(", ")}`);
97
138
  }
98
- const added = c[field].filter(r => !b[field].includes(r));
139
+ const added = cRefs.filter(r => !bRefs.includes(r));
99
140
  if (added.length > 0) {
100
141
  additive.push(`${name}: added ${field}: ${added.join(", ")}`);
101
142
  }
@@ -37,10 +37,26 @@ function parseArgs(argv) {
37
37
 
38
38
  function printHelp() {
39
39
  const banner =
40
+ "check-test-coverage — report changed surface that no test covers.\n" +
41
+ "\n" +
40
42
  "Usage: node scripts/check-test-coverage.js [--base <ref>] [--staged]\n" +
41
43
  " [--json] [--warn-only]\n" +
42
44
  "\n" +
43
- "See file header for full surface + categorization rules.\n";
45
+ "Flags:\n" +
46
+ " --base <ref> compare against <ref>..HEAD (default: origin/main)\n" +
47
+ " --staged compare the staged set instead of a ref range\n" +
48
+ " --json emit the result as JSON instead of a text report\n" +
49
+ " --warn-only report findings but always exit 0\n" +
50
+ "\n" +
51
+ "Surfaces checked: CLI verbs and flags in bin/exceptd.js, exported functions\n" +
52
+ "in lib / orchestrator / scripts, playbook detect-indicator and look-artifact\n" +
53
+ "ids, and CVE entries whose iocs changed.\n" +
54
+ "\n" +
55
+ "Categorization: docs, tooling dotfiles, tests/ itself and derived indexes are\n" +
56
+ "allowlisted; workflows, manifests, schemas, SBOM and unclassified files are\n" +
57
+ "surfaced as manual-review, never auto-green.\n" +
58
+ "\n" +
59
+ "Exit codes: 0 clean or --warn-only, 1 uncovered surface, 2 runner error.\n";
44
60
  process.stdout.write(banner);
45
61
  }
46
62
 
@@ -4,7 +4,11 @@
4
4
  * Thin per-type wrapper for the MITRE ATLAS refresher; the logic lives in
5
5
  * scripts/refresh-upstream-catalogs.js#refreshAtlas. Wired as
6
6
  * `npm run refresh-mitre-atlas`, and takes --dry-run.
7
+ *
8
+ * `CAP=<n>` bounds how many NEW techniques one run may add, matching every
9
+ * other per-type wrapper. Context backfill onto existing rows is never capped.
7
10
  */
8
11
  const { refreshAtlas } = require("./refresh-upstream-catalogs.js");
9
12
  const dry = process.argv.includes("--dry-run");
10
- refreshAtlas({ dry }).catch((e) => { console.error("[err]", e); process.exit(1); });
13
+ const cap = Number(process.env.CAP || Infinity);
14
+ refreshAtlas({ dry, cap }).catch((e) => { console.error("[err]", e); process.exit(1); });
@@ -4,7 +4,11 @@
4
4
  * `npm run refresh-mitre-ics-attack [-- --dry-run]` — per-type wrapper for the
5
5
  * MITRE ICS-attack STIX refresher; the logic lives in
6
6
  * scripts/refresh-upstream-catalogs.js#refreshIcsAttack.
7
+ *
8
+ * `CAP=<n>` bounds how many NEW techniques one run may add, matching every
9
+ * other per-type wrapper. Context backfill onto existing rows is never capped.
7
10
  */
8
11
  const { refreshIcsAttack } = require("./refresh-upstream-catalogs.js");
9
12
  const dry = process.argv.includes("--dry-run");
10
- refreshIcsAttack({ dry }).catch((e) => { console.error("[err]", e); process.exit(1); });
13
+ const cap = Number(process.env.CAP || Infinity);
14
+ refreshIcsAttack({ dry, cap }).catch((e) => { console.error("[err]", e); process.exit(1); });
@@ -3,7 +3,12 @@
3
3
  /**
4
4
  * Refreshes only the RFC catalog, leaving ATT&CK / ATLAS / D3FEND untouched.
5
5
  * Logic lives in scripts/refresh-upstream-catalogs.js#refreshRfc.
6
+ *
7
+ * `CAP=<n>` bounds how many NEW rows one run may add — the upstream RFC index
8
+ * carries ~9000 entries, so an uncapped first run imports all of them at once.
9
+ * Context backfill onto rows already curated is never capped.
6
10
  */
7
11
  const { refreshRfc } = require("./refresh-upstream-catalogs.js");
8
12
  const dry = process.argv.includes("--dry-run");
9
- refreshRfc({ dry }).catch((e) => { console.error("[err]", e); process.exit(1); });
13
+ const cap = Number(process.env.CAP || Infinity);
14
+ refreshRfc({ dry, cap }).catch((e) => { console.error("[err]", e); process.exit(1); });
@@ -14,15 +14,6 @@ const path = require("path");
14
14
  const ROOT = path.join(__dirname, "..");
15
15
  const TODAY = new Date().toISOString().slice(0, 10);
16
16
 
17
- // Required-context field names come from the audit-catalog-gaps SPEC, never a
18
- // second hardcoded list that would drift from it.
19
- const AUDIT_SPEC = require("./audit-catalog-gaps.js").SPEC;
20
- function specRequiredFields(catalogKey) {
21
- const spec = AUDIT_SPEC[catalogKey];
22
- if (!spec || !Array.isArray(spec.required_context)) return [];
23
- return spec.required_context.map((r) => r.field);
24
- }
25
-
26
17
  const MAX_REDIRECTS = 5;
27
18
 
28
19
  // Rejects on anything but a 2xx, so an error body can never reach a consumer as
@@ -186,7 +177,7 @@ function parseRfcEntry(blk) {
186
177
  };
187
178
  }
188
179
 
189
- async function refreshRfc({ dry = false, _deps = {} } = {}) {
180
+ async function refreshRfc({ dry = false, cap = Infinity, _deps = {} } = {}) {
190
181
  const _fetchUrl = _deps.fetchUrl || fetchUrl;
191
182
  const _loadCatalog = _deps.loadCatalog || loadCatalog;
192
183
  const _writeCatalog = _deps.writeCatalog || writeCatalog;
@@ -254,6 +245,9 @@ async function refreshRfc({ dry = false, _deps = {} } = {}) {
254
245
  // Existing rows handled in the first-pass backfill above.
255
246
  if (existing.has(id)) continue;
256
247
  if (e.status === "UNKNOWN") continue;
248
+ // The cap bounds new adds only; the backfill pass above is unbounded, so a
249
+ // capped run still completes context on every row already curated.
250
+ if (added >= cap) continue;
257
251
  const obsoleted = !!e.obsoleted || e.status === "HISTORIC";
258
252
  cat[id] = {
259
253
  number: e.num,
@@ -479,6 +473,7 @@ async function refreshIcsAttack({ dry = false, cap = Infinity, _deps = {} } = {}
479
473
  const local = _loadCatalog("attack-techniques.json");
480
474
  const existing = new Set(Object.keys(local).filter((k) => k !== "_meta"));
481
475
  let added = 0, backfilled = 0;
476
+ const skippedNoIcsTactic = [];
482
477
  for (const t of techs) {
483
478
  const extRef = (t.external_references || []).find((r) => r.source_name === "mitre-ics-attack" || r.source_name === "mitre-attack");
484
479
  if (!extRef || !extRef.external_id) continue;
@@ -486,6 +481,17 @@ async function refreshIcsAttack({ dry = false, cap = Infinity, _deps = {} } = {}
486
481
  const tactics = (t.kill_chain_phases || [])
487
482
  .filter((p) => (p.kill_chain_name || "").includes("ics"))
488
483
  .map((p) => ICS_TACTIC_NAME[p.phase_name] || `${p.phase_name} (ICS)`);
484
+ // The external-reference match above accepts a cross-listed enterprise
485
+ // reference, while the tactic map keeps ICS kill-chain phases only. An
486
+ // object matched by the first and not the second would land with
487
+ // `tactic: []`, which audit-catalog-gaps counts as a missing-context gap
488
+ // the moment it is written — the import spending gap budget on itself.
489
+ // Enterprise-only techniques belong to refreshAttack, so skip them here,
490
+ // and report the count so the omission is observable rather than silent.
491
+ if (tactics.length === 0) {
492
+ skippedNoIcsTactic.push(id);
493
+ continue;
494
+ }
489
495
  const fullDesc = String(t.description || "").replace(/\s+/g, " ").trim();
490
496
  let shortDesc = fullDesc.split(/\.\s/)[0];
491
497
  if (shortDesc.length > 500) shortDesc = shortDesc.slice(0, 497) + "...";
@@ -514,7 +520,10 @@ async function refreshIcsAttack({ dry = false, cap = Infinity, _deps = {} } = {}
514
520
  existing.add(id);
515
521
  added++;
516
522
  }
517
- if (dry) { console.log(`[refresh-upstream:ics-attack] DRY-RUN: +${added} new, ${backfilled} backfills`); return { added, backfilled }; }
523
+ if (skippedNoIcsTactic.length) {
524
+ console.log(`[refresh-upstream:ics-attack] skipped ${skippedNoIcsTactic.length} technique(s) with no ICS kill-chain phase (enterprise-only, handled by the ATT&CK refresher): ${skippedNoIcsTactic.slice(0, 10).join(", ")}${skippedNoIcsTactic.length > 10 ? ", ..." : ""}`);
525
+ }
526
+ if (dry) { console.log(`[refresh-upstream:ics-attack] DRY-RUN: +${added} new, ${backfilled} backfills`); return { added, backfilled, skipped_no_ics_tactic: skippedNoIcsTactic.length }; }
518
527
  const changed = added > 0 || backfilled > 0;
519
528
  if (changed) {
520
529
  if (local._meta) { local._meta.last_updated = TODAY; local._meta.last_threat_review = TODAY; }
@@ -523,7 +532,7 @@ async function refreshIcsAttack({ dry = false, cap = Infinity, _deps = {} } = {}
523
532
  } else {
524
533
  console.log("[ok] attack-techniques.json: no upstream ICS changes — file unchanged");
525
534
  }
526
- return { added, backfilled };
535
+ return { added, backfilled, skipped_no_ics_tactic: skippedNoIcsTactic.length };
527
536
  }
528
537
 
529
538
  const ATLAS_SRC = "https://raw.githubusercontent.com/mitre-atlas/atlas-navigator-data/main/dist/stix-atlas.json";
@@ -595,7 +604,7 @@ function backfillAtlas(cur, fresh) {
595
604
  return touched;
596
605
  }
597
606
 
598
- async function refreshAtlas({ dry = false, _deps = {} } = {}) {
607
+ async function refreshAtlas({ dry = false, cap = Infinity, _deps = {} } = {}) {
599
608
  const _fetchUrl = _deps.fetchUrl || fetchUrl;
600
609
  const _loadCatalog = _deps.loadCatalog || loadCatalog;
601
610
  const _writeCatalog = _deps.writeCatalog || writeCatalog;
@@ -637,6 +646,9 @@ async function refreshAtlas({ dry = false, _deps = {} } = {}) {
637
646
  if (backfillAtlas(cur, fresh)) { cur.last_verified = TODAY; backfilled++; }
638
647
  continue;
639
648
  }
649
+ // Bounds new adds only, matching every other refresher; backfill above runs
650
+ // on the full upstream set regardless of the cap.
651
+ if (added >= cap) continue;
640
652
  local[id] = atlasEntryFromStix(t, ext);
641
653
  existing.add(id);
642
654
  added++;
@@ -14,8 +14,6 @@ var ROOT = path.resolve(__dirname, "..");
14
14
  var REPO = "blamejs/exceptd-skills";
15
15
  var PKG_NAME = "@blamejs/exceptd-skills";
16
16
 
17
- var RERUN_LIMIT = 2;
18
-
19
17
  // Windows resolves `npm` / `npx` as `.cmd` shims, which child_process can only
20
18
  // invoke through a shell; `git`, `gh` and `node` are native exes that spawn directly.
21
19
  function _needsShell(cmd) {
@@ -52,7 +52,7 @@ function stderrBanFailures(expect, stderr) {
52
52
  }
53
53
 
54
54
  // Positive matchers against the parsed JSON body only; the stderr ban is separate.
55
- function diffExpect(jsonBody, expect, ctx) {
55
+ function diffExpect(jsonBody, expect) {
56
56
  const failures = [];
57
57
  if (expect.json_path_equals) {
58
58
  for (const [p, want] of Object.entries(expect.json_path_equals)) {
@@ -136,7 +136,7 @@ function evaluateScenario(scenario, expect, res) {
136
136
  if (!body && hasJsonAssertion) {
137
137
  failures.push(`stdout did not parse as JSON; first 200 chars: ${stdout.slice(0, 200)}`);
138
138
  }
139
- if (body) failures.push(...diffExpect(body, expect, { stdout, stderr, status }));
139
+ if (body) failures.push(...diffExpect(body, expect));
140
140
 
141
141
  // Unconditional, outside every `if (body)` branch above.
142
142
  failures.push(...stderrBanFailures(expect, stderr));
@@ -39,7 +39,6 @@ function canonicalizeForTarball(value) {
39
39
  function canonicalManifestBytesForTarball(manifest) {
40
40
  const clone = Object.assign({}, manifest);
41
41
  delete clone.manifest_signature;
42
- const cryptoMod = require("crypto"); // eslint-disable-line no-unused-vars
43
42
  const json = JSON.stringify(canonicalizeForTarball(clone), null, 2);
44
43
  return normalizeSkillBytes(Buffer.from(json, "utf8"));
45
44
  }