@blamejs/exceptd-skills 0.18.9 → 0.18.12

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 (51) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bin/exceptd.js +204 -118
  3. package/data/_indexes/_meta.json +3 -3
  4. package/data/_indexes/frequency.json +2 -2
  5. package/data/d3fend-catalog.json +6 -6
  6. package/data/playbooks/identity-sso-compromise.json +2 -2
  7. package/data/playbooks/sbom.json +1 -1
  8. package/lib/citation-resolve.js +11 -0
  9. package/lib/collectors/containers.js +13 -0
  10. package/lib/collectors/cred-stores.js +18 -9
  11. package/lib/collectors/secrets.js +4 -2
  12. package/lib/cross-ref-api.js +29 -7
  13. package/lib/cve-regression-watcher.js +47 -15
  14. package/lib/framework-gap.js +52 -19
  15. package/lib/gap-detectors.js +8 -3
  16. package/lib/lint-skills.js +3 -2
  17. package/lib/playbook-runner.js +125 -7
  18. package/lib/refresh-external.js +58 -7
  19. package/lib/refresh-network.js +18 -5
  20. package/lib/rfc-cli.js +113 -18
  21. package/lib/schemas/playbook.schema.json +1 -1
  22. package/lib/scoring.js +71 -8
  23. package/lib/source-advisories.js +58 -9
  24. package/lib/ttp-mapper.js +31 -3
  25. package/lib/upstream-check-cli.js +13 -1
  26. package/lib/validate-catalog-meta.js +51 -7
  27. package/lib/validate-cve-catalog.js +10 -0
  28. package/lib/validate-playbooks.js +19 -1
  29. package/lib/verify.js +35 -34
  30. package/lib/xml-tokenizer.js +187 -25
  31. package/manifest.json +53 -53
  32. package/orchestrator/dispatcher.js +53 -9
  33. package/orchestrator/index.js +9 -7
  34. package/orchestrator/pipeline.js +62 -14
  35. package/orchestrator/scanner.js +60 -9
  36. package/package.json +1 -1
  37. package/sbom.cdx.json +115 -100
  38. package/scripts/build-indexes.js +21 -3
  39. package/scripts/builders/cwe-chains.js +5 -2
  40. package/scripts/builders/section-offsets.js +17 -8
  41. package/scripts/builders/summary-cards.js +12 -4
  42. package/scripts/check-catalog-gap-budget.js +3 -3
  43. package/scripts/check-codebase-patterns-currency.js +1 -0
  44. package/scripts/check-codebase-patterns.js +166 -11
  45. package/scripts/check-sbom-currency.js +69 -3
  46. package/scripts/check-test-count.js +28 -16
  47. package/scripts/check-test-subjects.js +148 -0
  48. package/scripts/check-version-tags.js +24 -5
  49. package/scripts/predeploy.js +32 -8
  50. package/scripts/refresh-upstream-catalogs.js +169 -44
  51. package/scripts/release.js +28 -11
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * check-test-subjects.js — bidirectional test↔subject gate (and reorg driver).
5
+ *
6
+ * Every test file must be named after a real SUBJECT the codebase actually has,
7
+ * and every subject must have a test. A "subject" is derived dynamically from
8
+ * the codebase so this list is never hand-maintained:
9
+ * - a source MODULE basename (lib/x.js -> x; lib/collectors/x.js -> x and
10
+ * collectors-x; orchestrator/index.js -> orchestrator; bin/exceptd.js -> cli)
11
+ * - an exported FUNCTION / CLASS name (kebab-cased) — per-function granularity
12
+ * - a data PRIMITIVE: a data/*.json catalog FILE, plus each catalog ENTRY that
13
+ * is itself a primitive — every CVE/MAL/GHSA id in data/cve-catalog.json and
14
+ * every playbook in data/playbooks/ — so one CVE == one test file
15
+ * - a .github/workflows/*.yml WORKFLOW (release -> release-workflow, etc.)
16
+ * - a CLI verb dispatched by bin/exceptd.js
17
+ *
18
+ * FORWARD violation : a tests/<x>.test.js where <x> is not a valid subject.
19
+ * REVERSE violation : a subject (module / CVE / playbook / workflow) with no
20
+ * tests/<subject>.test.js.
21
+ *
22
+ * Run with --worklist for the machine-readable reorg work list (JSON on stdout).
23
+ * Run with no flag for a human summary; exits non-zero while any violation
24
+ * remains (so once the suite conforms this becomes a standing predeploy gate).
25
+ */
26
+ const fs = require("node:fs");
27
+ const path = require("node:path");
28
+ const ROOT = path.resolve(__dirname, "..");
29
+
30
+ function camelKebab(s) { return s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase(); }
31
+ function read(p) { try { return fs.readFileSync(path.join(ROOT, p), "utf8"); } catch { return ""; } }
32
+ function ls(d) { try { return fs.readdirSync(path.join(ROOT, d), { withFileTypes: true }); } catch { return []; } }
33
+
34
+ function deriveSubjects() {
35
+ const subjects = new Map(); // name -> kind
36
+ const add = (s, kind) => { if (s && !subjects.has(s.toLowerCase())) subjects.set(s.toLowerCase(), kind); };
37
+
38
+ function walkSrc(d) {
39
+ for (const e of ls(d)) {
40
+ if (e.name === "node_modules") continue;
41
+ const rel = d + "/" + e.name;
42
+ if (e.isDirectory()) { walkSrc(rel); continue; }
43
+ if (!e.name.endsWith(".js")) continue;
44
+ const base = e.name.replace(/\.js$/, "");
45
+ // index.js is a directory entry point; the directory/canonical subject
46
+ // covers it, so treat the bare "index" basename as an alias, not a
47
+ // separately reverse-required module.
48
+ add(base, (base === "index" ? "alias:" : "module:") + rel);
49
+ const parent = path.basename(path.dirname(rel));
50
+ // parent-prefixed name (collectors-x, builders-x, validators-x) is an
51
+ // ALIAS of the canonical basename subject — a valid test target, but the
52
+ // canonical <base>.test.js already satisfies coverage, so don't double-
53
+ // count the alias as its own reverse gap.
54
+ if (!["lib", "scripts", "orchestrator", "bin"].includes(parent)) add(parent + "-" + base, "alias:" + rel);
55
+ const txt = read(rel);
56
+ for (const m of txt.matchAll(/(?:^|\n)\s*(?:async\s+)?(?:function|class)\s+([A-Za-z_$][\w$]*)/g)) add(camelKebab(m[1]), "fn:" + rel);
57
+ // Capture the FULL module.exports object via a brace-balanced scan. A
58
+ // non-greedy /\{([\s\S]*?)\}/ stops at the first nested `}` and drops
59
+ // every export name after it (the brace-truncation class), under-deriving
60
+ // subjects so a real export silently has no required test.
61
+ const expAt = txt.search(/module\.exports\s*=\s*\{/);
62
+ if (expAt >= 0) {
63
+ const open = txt.indexOf("{", expAt);
64
+ let depth = 0, end = -1;
65
+ for (let k = open; k < txt.length; k++) { const ch = txt[k]; if (ch === "{") depth++; else if (ch === "}" && --depth === 0) { end = k; break; } }
66
+ if (end > open) for (const m of txt.slice(open + 1, end).matchAll(/([A-Za-z_$][\w$]*)\s*[,:}\n]/g)) add(camelKebab(m[1]), "fn:" + rel);
67
+ }
68
+ }
69
+ }
70
+ ["lib", "orchestrator", "scripts", "bin", "sources/validators"].forEach(walkSrc);
71
+ add("orchestrator", "module:orchestrator/index.js");
72
+ add("cli", "module:bin/exceptd.js");
73
+ // Vendored (pinned third-party) modules are valid test SUBJECTS but are not
74
+ // reverse-required — we don't force a dedicated test per vendored file.
75
+ (function walkVendor(d) { for (const e of ls(d)) { const rel = d + "/" + e.name; if (e.isDirectory()) walkVendor(rel); else if (e.name.endsWith(".js")) add(e.name.replace(/\.js$/, ""), "vendor:" + rel); } })("vendor");
76
+
77
+ // CLI verbs — both the switch-case form and the dispatch-table form
78
+ // (verb: () => path.join(...)) that bin/exceptd.js uses for most subcommands.
79
+ const cliSrc = read("bin/exceptd.js");
80
+ for (const m of cliSrc.matchAll(/case\s+['"]([a-z][a-z0-9-]+)['"]/g)) { add("cli-" + m[1], "cli-verb"); add(m[1], "cli-verb"); }
81
+ for (const m of cliSrc.matchAll(/^\s*["']?([a-z][a-z0-9-]+)["']?:\s*\(\)\s*=>/gm)) { add("cli-" + m[1], "cli-verb"); add(m[1], "cli-verb"); }
82
+
83
+ // data catalog files
84
+ for (const e of ls("data")) if (e.isFile() && e.name.endsWith(".json")) add(e.name.replace(/\.json$/, ""), "data");
85
+ // data ENTRY primitives: every CVE id + every playbook
86
+ // CVE-primitive subjects. An unreadable / malformed / empty catalog must NOT
87
+ // silently derive zero CVE subjects — that would let the reverse-coverage gate
88
+ // PASS with no CVE coverage at all (the absent-input false-pass class). Fail
89
+ // loud instead.
90
+ let cveDerived = 0;
91
+ try { const cat = JSON.parse(read("data/cve-catalog.json")); for (const k of Object.keys(cat)) if (k !== "_meta") { add(k.toLowerCase(), "cve-primitive"); cveDerived++; } }
92
+ catch (e) { throw new Error("check-test-subjects: cannot read/parse data/cve-catalog.json — refusing to derive subjects (reverse coverage would falsely pass with no CVE coverage): " + e.message); }
93
+ if (cveDerived === 0) throw new Error("check-test-subjects: data/cve-catalog.json yielded zero CVE entries — refusing to let reverse coverage pass with no CVE coverage.");
94
+ for (const e of ls("data/playbooks")) if (e.isFile() && e.name.endsWith(".json")) { const b = e.name.replace(/\.json$/, ""); add(b, "playbook-primitive"); add("playbook-" + b, "alias:playbook"); }
95
+ // workflows
96
+ for (const e of ls(".github/workflows")) if (/\.ya?ml$/.test(e.name)) { const b = e.name.replace(/\.ya?ml$/, ""); add(b, "workflow"); add(b + "-workflow", "workflow"); }
97
+
98
+ // Repo-artifact subjects: shipped root config/doc files, the docker build
99
+ // context, the agents/ directory, and aggregate catalog directories. A test
100
+ // that pins one of these artifacts (its content, counts, or cross-references)
101
+ // is named after a durable subject, not a release — so these are valid test
102
+ // targets. Kind is not module/cve/playbook, so they are NOT reverse-required
103
+ // (we don't force a dedicated test per doc file).
104
+ for (const f of ["package.json", "manifest.json", "manifest-snapshot.json", "README.md", "AGENTS.md", "SECURITY.md", "ARCHITECTURE.md", "CONTEXT.md", "CHANGELOG.md", "CONTRIBUTING.md", "CODE_OF_CONDUCT.md", "LICENSE", "NOTICE"]) {
105
+ add(f.replace(/\.[^.]*$/, "").toLowerCase().replace(/_/g, "-"), "repo:" + f);
106
+ }
107
+ add("agents-md", "repo:AGENTS.md");
108
+ add("docker", "repo:docker/test.Dockerfile");
109
+ add("agents", "repo:agents/");
110
+ add("playbooks", "aggregate:data/playbooks");
111
+ add("workflows", "aggregate:.github/workflows");
112
+ add("governance", "repo:governance-files"); // LICENSE/NOTICE/FUNDING/CoC/gitignore/gitleaks presence + integrity
113
+ return subjects;
114
+ }
115
+
116
+ function run() {
117
+ const subjects = deriveSubjects();
118
+ const testFiles = ls("tests").filter((e) => e.isFile() && e.name.endsWith(".test.js")).map((e) => e.name.replace(/\.test\.js$/, ""));
119
+ const testSet = new Set(testFiles.map((t) => t.toLowerCase()));
120
+
121
+ const suggest = (name) => {
122
+ const toks = name.toLowerCase().split("-");
123
+ for (let n = toks.length; n >= 1; n--) { const c = toks.slice(0, n).join("-"); if (subjects.has(c)) return c; }
124
+ return null;
125
+ };
126
+ const forward = [];
127
+ for (const t of testFiles) if (!subjects.has(t.toLowerCase())) forward.push({ file: "tests/" + t + ".test.js", suggested: suggest(t) });
128
+ const reverse = [];
129
+ for (const [s, kind] of subjects) if (!testSet.has(s)) reverse.push({ subject: s, kind });
130
+ // Reverse-REQUIRED subset: only module / cve / playbook subjects must have a
131
+ // test (aliases, data files, cli verbs, repo artifacts are valid targets but
132
+ // not reverse-required). The exit-code logic gates on THIS, consistently
133
+ // across --worklist and the human/predeploy paths.
134
+ const reverseRequired = reverse.filter((x) => x.kind.startsWith("module:") || x.kind.startsWith("cve-primitive") || x.kind.startsWith("playbook-primitive"));
135
+ return { subjects: subjects.size, forward, reverse, reverseRequired };
136
+ }
137
+
138
+ if (require.main === module) {
139
+ const r = run();
140
+ const revMods = r.reverseRequired;
141
+ if (process.argv.includes("--worklist")) { process.stdout.write(JSON.stringify(r) + "\n"); process.exitCode = (r.forward.length || revMods.length) ? 1 : 0; }
142
+ else {
143
+ console.log(`[check-test-subjects] valid subjects=${r.subjects} | FORWARD violations=${r.forward.length} | REVERSE (module/cve/playbook) gaps=${revMods.length}`);
144
+ if (r.forward.length || revMods.length) { console.log("[check-test-subjects] FAIL — run with --worklist for the full list."); process.exitCode = 1; }
145
+ else console.log("[check-test-subjects] ok — every test maps to a subject and every subject has a test.");
146
+ }
147
+ }
148
+ module.exports = { deriveSubjects, run };
@@ -90,6 +90,13 @@ const COMMENT_EXEMPT = new Set([
90
90
  // literals are load-bearing data, not sprinkled release tags.
91
91
  "scripts/check-version-bump.js",
92
92
  "tests/version-bump-cadence.test.js",
93
+ // The version-tag gate's own regression test asserts the trailing-period /
94
+ // IPv4 / longer-run boundaries and the PHASE_RESIDUE_RES / FILENAME_VERSION_RE
95
+ // / countLineViolations exports, so it MUST embed literal stamps like
96
+ // `0.18.9.`, `0.18.99`, `Pre-0.13.22`, and `foo-v0_13_2.test.js` as the inputs
97
+ // under test — load-bearing data for the detector's boundary cases, not
98
+ // sprinkled release tags.
99
+ "tests/check-version-tags.test.js",
93
100
  ]);
94
101
 
95
102
  // Git-ignored files (a contributor's local-only working docs, scratch) are
@@ -115,11 +122,15 @@ function gitIgnoredSet(relPaths) {
115
122
  // Pattern: project version like `v0.13.22` or bare `0.13.22`. Matches
116
123
  // our pre-1.0 release range. External package versions like ATLAS
117
124
  // `v5.6.0` or CycloneDX `1.6` don't match because the major is 0.
118
- // The dot/digit lookarounds keep an IPv4 octet run (e.g. `127.0.0.1`, whose
119
- // `0.0.1` tail would otherwise count as a version tag) and any longer
120
- // dotted-numeric sequence from registering — a release version is never
121
- // embedded inside a larger digit.digit run.
122
- const VERSION_TAG_RE = /(?<![\d.])v?0\.\d+\.\d+(?![\d.])/;
125
+ // The trailing lookahead rejects a longer minor/patch digit (so `0.18.99`
126
+ // still matches, but the stamp can't be part of a wider number) and a
127
+ // dot-followed-by-digit (an IPv4 next octet / longer dotted-numeric run, e.g.
128
+ // `127.0.0.1`, whose `0.0.1` tail would otherwise register). A sentence-ending
129
+ // period after the patch (dot followed by non-digit / end-of-line, e.g.
130
+ // `// fixed in 0.18.9.`) is NOT excluded — that is exactly the version residue
131
+ // the gate must catch. The leading `(?<![\d.])` lookbehind keeps the IPv4
132
+ // suppression on the other side.
133
+ const VERSION_TAG_RE = /(?<![\d.])v?0\.\d+\.\d+(?!\d)(?!\.\d)/;
123
134
 
124
135
  // Phase residue patterns — broader than just version tags.
125
136
  const PHASE_RESIDUE_RES = [
@@ -314,5 +325,13 @@ function main() {
314
325
  process.exitCode = 1;
315
326
  }
316
327
 
328
+ module.exports = {
329
+ VERSION_TAG_RE,
330
+ PHASE_RESIDUE_RES,
331
+ FILENAME_VERSION_RE,
332
+ countLineViolations,
333
+ scanCurrent,
334
+ };
335
+
317
336
  if (require.main === module) main();
318
337
 
@@ -249,6 +249,19 @@ const GATES = [
249
249
  args: [path.join(ROOT, "scripts", "check-codebase-patterns.js")],
250
250
  ciJobName: "Data integrity (catalog + manifest snapshot)",
251
251
  },
252
+ {
253
+ // Test-subject coverage gate. Bidirectional: every tests/<x>.test.js must
254
+ // be named after a real SUBJECT the codebase has (a module / CLI verb /
255
+ // CVE id / playbook / data primitive / repo artifact), and every such
256
+ // subject must have a test. Blocks the naming drift that lets a test be
257
+ // filed under a version/finding label (where downstream readers can't find
258
+ // it) and surfaces any module/playbook that ships without a test. Derived
259
+ // dynamically from the source tree, so the list is never hand-maintained.
260
+ name: "Test-subject coverage (every test maps to a subject; every subject has a test)",
261
+ command: process.execPath,
262
+ args: [path.join(ROOT, "scripts", "check-test-subjects.js")],
263
+ ciJobName: "Data integrity (catalog + manifest snapshot)",
264
+ },
252
265
  {
253
266
  // Release-notes extract + quality gate. Runs the same `## <version>`
254
267
  // CHANGELOG extraction the release workflow publishes as the GitHub
@@ -322,16 +335,27 @@ function runGate(gate) {
322
335
  const ceil = typeof gate.informationalMaxExitCode === "number"
323
336
  ? gate.informationalMaxExitCode
324
337
  : Infinity;
325
- // A signal kill (spawnSync returns status:null with r.signal set — e.g. a
326
- // 137 OOM kill) is a crash, not an informational soft-signal. Without this,
327
- // an OOM-killed informational gate fell through to "informational" and the
328
- // release proceeded as if the gate had merely produced advisory output.
329
- if (r.signal || (r.status !== null && r.status > ceil)) {
338
+ // A spawn failure (spawnSync returns r.error set, status:null, signal:null —
339
+ // e.g. the gate command is missing / ENOENT / EACCES) is a crash, not an
340
+ // informational soft-signal. So is a signal kill (status:null with r.signal
341
+ // set — e.g. a 137 OOM kill) and a status that exceeds the soft-signal
342
+ // ceiling. Without surfacing the spawn-error case, an informational gate
343
+ // that never even ran fell through to "informational" and the release
344
+ // proceeded as if the gate had merely produced advisory output. The
345
+ // status===null && !signal case (no error object, but the process never
346
+ // produced an exit code) is treated the same way — a gate that did not
347
+ // exit cleanly cannot be classified as a soft signal.
348
+ const spawnFailed = !!r.error || (r.status === null && !r.signal);
349
+ if (r.error || r.signal || spawnFailed || (r.status !== null && r.status > ceil)) {
330
350
  return {
331
351
  status: "failed",
332
- exitCode: r.status,
333
- message: r.signal
352
+ exitCode: r.status ?? null,
353
+ message: r.error
354
+ ? `informational gate failed to spawn (treated as a crash): ${r.error.message}`
355
+ : r.signal
334
356
  ? `informational gate killed by signal ${r.signal} (treated as a crash)`
357
+ : r.status === null
358
+ ? `informational gate did not exit cleanly (no exit code, no signal) — treated as a crash`
335
359
  : `informational gate crashed (exit ${r.status} > informationalMaxExitCode=${ceil})`,
336
360
  durationMs,
337
361
  warnCount,
@@ -425,7 +449,7 @@ function main() {
425
449
  process.exit(failures.length > 0 ? 1 : 0); // allow:process-exit-after-stdout-write — local-only gate runner; output is the human/CI summary written synchronously above, never a piped --json result channel
426
450
  }
427
451
 
428
- module.exports = { GATES };
452
+ module.exports = { GATES, runGate };
429
453
 
430
454
  if (require.main === module) {
431
455
  try {
@@ -55,10 +55,39 @@ function specRequiredFields(catalogKey) {
55
55
  return spec.required_context.map((r) => r.field);
56
56
  }
57
57
 
58
- function fetchUrl(url) {
58
+ const MAX_REDIRECTS = 5;
59
+
60
+ // Hardened fetch helper. Three properties the hand-rolled follower lacked:
61
+ // 1. Redirect-depth cap + base-URL resolution + response drain, so a
62
+ // redirect loop rejects within the cap (rather than recursing/hanging
63
+ // unbounded) and a relative Location resolves against the current URL.
64
+ // 2. 4xx/5xx (and a missing statusCode edge) reject instead of resolving an
65
+ // error body as a "successful" empty result — every consumer fails
66
+ // closed on an HTTP error rather than stamping _meta on a non-fetch.
67
+ // 3. A 3xx with no Location header rejects with a clear message rather than
68
+ // throwing an opaque ERR_INVALID_URL on `new URL(undefined, url)`.
69
+ function fetchUrl(url, depth = 0) {
59
70
  return new Promise((resolve, reject) => {
60
71
  https.get(url, { headers: { "User-Agent": "exceptd-refresh-upstream-catalogs" } }, (r) => {
61
- if (r.statusCode >= 300 && r.statusCode < 400) return fetchUrl(r.headers.location).then(resolve, reject);
72
+ const code = r.statusCode;
73
+ if (code == null) {
74
+ r.resume();
75
+ return reject(new Error(`no HTTP status code for ${url}`));
76
+ }
77
+ if (code >= 300 && code < 400) {
78
+ r.resume(); // drain the redirect response so the socket is freed/reused
79
+ const loc = r.headers.location;
80
+ if (!loc) return reject(new Error(`redirect ${code} from ${url} with no Location header`));
81
+ if (depth >= MAX_REDIRECTS) return reject(new Error(`too many redirects (>${MAX_REDIRECTS}) fetching ${url}`));
82
+ let next;
83
+ try { next = new URL(loc, url).toString(); } // resolves relative AND absolute Location
84
+ catch (e) { return reject(new Error(`invalid redirect target "${loc}" from ${url}: ${e.message}`)); }
85
+ return fetchUrl(next, depth + 1).then(resolve, reject);
86
+ }
87
+ if (code >= 400) {
88
+ r.resume(); // drain so the socket is freed
89
+ return reject(new Error("HTTP " + code + " for " + url));
90
+ }
62
91
  let b = "";
63
92
  r.on("data", (c) => (b += c));
64
93
  r.on("end", () => resolve(b));
@@ -70,8 +99,16 @@ function loadCatalog(rel) {
70
99
  return JSON.parse(fs.readFileSync(path.join(ROOT, "data", rel), "utf8"));
71
100
  }
72
101
 
102
+ // Atomic write: a crash / disk-full / SIGKILL mid-write would otherwise leave a
103
+ // truncated JSON catalog on disk. Write to a temp sibling and rename — rename is
104
+ // atomic on POSIX and on same-volume Windows renames (the .tmp sibling is
105
+ // adjacent to the target, same volume), so a reader / the next run only ever
106
+ // sees the complete old or complete new file. Mirrors build-indexes#writeJson.
73
107
  function writeCatalog(rel, obj) {
74
- fs.writeFileSync(path.join(ROOT, "data", rel), JSON.stringify(obj, null, 2) + "\n");
108
+ const abs = path.join(ROOT, "data", rel);
109
+ const tmp = `${abs}.tmp-${process.pid}`;
110
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
111
+ fs.renameSync(tmp, abs);
75
112
  }
76
113
 
77
114
  function getTag(blk, tag) {
@@ -198,9 +235,12 @@ function parseRfcEntry(blk) {
198
235
  };
199
236
  }
200
237
 
201
- async function refreshRfc({ dry = false } = {}) {
238
+ async function refreshRfc({ dry = false, _deps = {} } = {}) {
239
+ const _fetchUrl = _deps.fetchUrl || fetchUrl;
240
+ const _loadCatalog = _deps.loadCatalog || loadCatalog;
241
+ const _writeCatalog = _deps.writeCatalog || writeCatalog;
202
242
  console.log("[refresh-upstream:rfc] fetching IETF RFC index...");
203
- const body = await fetchUrl(RFC_SRC);
243
+ const body = await _fetchUrl(RFC_SRC);
204
244
  console.log(`[refresh-upstream:rfc] index size: ${(body.length / 1e6).toFixed(2)} MB`);
205
245
  const re = /<rfc-entry>([\s\S]*?)<\/rfc-entry>/g;
206
246
  const entries = []; // current — eligible for new-add
@@ -213,8 +253,17 @@ async function refreshRfc({ dry = false } = {}) {
213
253
  if (e.obsoleted || e.status === "HISTORIC" || e.status === "UNKNOWN") continue;
214
254
  entries.push(e);
215
255
  }
256
+ // Sanity floor: the IETF index has ~9000+ RFCs, so a successful fetch can
257
+ // never parse to zero entries. A zero count means the fetch returned an
258
+ // error/empty/soft-error body (a 200 with a CDN error page, a captive portal,
259
+ // or a truncated body the HTTP-status guard can't see) — refuse to stamp
260
+ // _meta or write rfc-references.json, matching the JSON-parse failures the
261
+ // STIX sources surface for free (an empty index is never a legitimate result).
262
+ if (backfillable.length === 0) {
263
+ throw new Error("RFC index parsed 0 entries (fetch likely returned an error/empty body) — refusing to stamp _meta or write rfc-references.json");
264
+ }
216
265
  console.log(`[refresh-upstream:rfc] current entries: ${entries.length} (+ ${backfillable.length - entries.length} obsoleted/historic available for backfill on existing rows)`);
217
- const cat = loadCatalog("rfc-references.json");
266
+ const cat = _loadCatalog("rfc-references.json");
218
267
  const existing = new Set(Object.keys(cat).filter((k) => k !== "_meta"));
219
268
  let added = 0, statusBumped = 0, backfilledCount = 0;
220
269
  // First pass: backfill ALL existing rows from the broader entry set
@@ -226,8 +275,13 @@ async function refreshRfc({ dry = false } = {}) {
226
275
  const cur = cat[id];
227
276
  if (!cur) continue;
228
277
  let touched = false;
229
- if (cur._auto_imported && cur.status !== RFC_STATUS_MAP[e.status]) {
230
- cur.status = RFC_STATUS_MAP[e.status];
278
+ // Mirror the new-add path's `|| e.status` fallback: an upstream status
279
+ // outside RFC_STATUS_MAP must NOT write `undefined` (which would drop the
280
+ // status field on an existing curated row). Fall back to the raw upstream
281
+ // status, and only bump when the mapped value actually differs.
282
+ const mapped = RFC_STATUS_MAP[e.status] || e.status;
283
+ if (cur._auto_imported && mapped && cur.status !== mapped) {
284
+ cur.status = mapped;
231
285
  touched = true;
232
286
  statusBumped++;
233
287
  }
@@ -293,16 +347,25 @@ async function refreshRfc({ dry = false } = {}) {
293
347
  existing.add(id);
294
348
  added++;
295
349
  }
296
- if (cat._meta) {
297
- cat._meta.last_updated = TODAY;
298
- cat._meta.last_threat_review = TODAY;
299
- }
350
+ // Only restamp _meta + write when something actually changed. A genuine
351
+ // no-op leaves the file byte-identical so the daily refresh doesn't emit a
352
+ // spurious _meta-only diff (and so the freshness gates stay honest — a
353
+ // wall-clock restamp on an unchanged catalog masks real staleness).
354
+ const changed = added > 0 || backfilledCount > 0 || statusBumped > 0;
300
355
  if (dry) {
301
356
  console.log(`[refresh-upstream:rfc] DRY-RUN: +${added} new, ${backfilledCount} backfilled, ${statusBumped} status bumps.`);
302
357
  return { added, statusBumped, backfilled: backfilledCount };
303
358
  }
304
- writeCatalog("rfc-references.json", cat);
305
- console.log(`[ok] rfc-references.json: +${added} entries, ${backfilledCount} backfilled, ${statusBumped} status bumps (now ${existing.size} total)`);
359
+ if (changed) {
360
+ if (cat._meta) {
361
+ cat._meta.last_updated = TODAY;
362
+ cat._meta.last_threat_review = TODAY;
363
+ }
364
+ _writeCatalog("rfc-references.json", cat);
365
+ console.log(`[ok] rfc-references.json: +${added} entries, ${backfilledCount} backfilled, ${statusBumped} status bumps (now ${existing.size} total)`);
366
+ } else {
367
+ console.log("[ok] rfc-references.json: no upstream changes — file unchanged");
368
+ }
306
369
  return { added, statusBumped, backfilled: backfilledCount };
307
370
  }
308
371
 
@@ -392,9 +455,12 @@ function backfillAttack(cur, fresh) {
392
455
  return touched;
393
456
  }
394
457
 
395
- async function refreshAttack({ dry = false, cap = Infinity } = {}) {
458
+ async function refreshAttack({ dry = false, cap = Infinity, _deps = {} } = {}) {
459
+ const _fetchUrl = _deps.fetchUrl || fetchUrl;
460
+ const _loadCatalog = _deps.loadCatalog || loadCatalog;
461
+ const _writeCatalog = _deps.writeCatalog || writeCatalog;
396
462
  console.log("[refresh-upstream:attack] fetching MITRE ATT&CK STIX...");
397
- const body = await fetchUrl(ATTACK_SRC);
463
+ const body = await _fetchUrl(ATTACK_SRC);
398
464
  const stix = JSON.parse(body);
399
465
  // For NEW adds: live techniques only (skip revoked / deprecated).
400
466
  // For BACKFILL on existing rows: include revoked too — an operator-
@@ -410,7 +476,7 @@ async function refreshAttack({ dry = false, cap = Infinity } = {}) {
410
476
  );
411
477
  console.log(`[refresh-upstream:attack] STIX live techniques: ${liveTechs.length} (+ ${backfillTechs.length - liveTechs.length} revoked/deprecated available for backfill on existing rows)`);
412
478
  const techs = liveTechs;
413
- const local = loadCatalog("attack-techniques.json");
479
+ const local = _loadCatalog("attack-techniques.json");
414
480
  const existing = new Set(Object.keys(local).filter((k) => k !== "_meta"));
415
481
  techs.sort((a, b) => {
416
482
  const aSub = a.x_mitre_is_subtechnique ? 1 : 0;
@@ -448,9 +514,14 @@ async function refreshAttack({ dry = false, cap = Infinity } = {}) {
448
514
  added++;
449
515
  }
450
516
  if (dry) { console.log(`[refresh-upstream:attack] DRY-RUN: +${added} new, ${backfilled} context backfills`); return { added, backfilled }; }
451
- if (local._meta) { local._meta.last_updated = TODAY; local._meta.last_threat_review = TODAY; }
452
- writeCatalog("attack-techniques.json", local);
453
- console.log(`[ok] attack-techniques.json: +${added} entries, ${backfilled} context backfills (now ${existing.size} total)`);
517
+ const changed = added > 0 || backfilled > 0;
518
+ if (changed) {
519
+ if (local._meta) { local._meta.last_updated = TODAY; local._meta.last_threat_review = TODAY; }
520
+ _writeCatalog("attack-techniques.json", local);
521
+ console.log(`[ok] attack-techniques.json: +${added} entries, ${backfilled} context backfills (now ${existing.size} total)`);
522
+ } else {
523
+ console.log("[ok] attack-techniques.json: no upstream changes — file unchanged");
524
+ }
454
525
  return { added, backfilled };
455
526
  }
456
527
 
@@ -472,15 +543,18 @@ const ICS_TACTIC_NAME = {
472
543
  "impact": "Impact (ICS)"
473
544
  };
474
545
 
475
- async function refreshIcsAttack({ dry = false, cap = Infinity } = {}) {
546
+ async function refreshIcsAttack({ dry = false, cap = Infinity, _deps = {} } = {}) {
547
+ const _fetchUrl = _deps.fetchUrl || fetchUrl;
548
+ const _loadCatalog = _deps.loadCatalog || loadCatalog;
549
+ const _writeCatalog = _deps.writeCatalog || writeCatalog;
476
550
  console.log("[refresh-upstream:ics-attack] fetching MITRE ICS-attack STIX...");
477
- const body = await fetchUrl(ICS_ATTACK_SRC);
551
+ const body = await _fetchUrl(ICS_ATTACK_SRC);
478
552
  const stix = JSON.parse(body);
479
553
  const techs = (stix.objects || []).filter(
480
554
  (o) => o.type === "attack-pattern" && !o.revoked && !o.x_mitre_deprecated
481
555
  );
482
556
  console.log(`[refresh-upstream:ics-attack] STIX live ICS techniques: ${techs.length}`);
483
- const local = loadCatalog("attack-techniques.json");
557
+ const local = _loadCatalog("attack-techniques.json");
484
558
  const existing = new Set(Object.keys(local).filter((k) => k !== "_meta"));
485
559
  let added = 0, backfilled = 0;
486
560
  for (const t of techs) {
@@ -519,9 +593,14 @@ async function refreshIcsAttack({ dry = false, cap = Infinity } = {}) {
519
593
  added++;
520
594
  }
521
595
  if (dry) { console.log(`[refresh-upstream:ics-attack] DRY-RUN: +${added} new, ${backfilled} backfills`); return { added, backfilled }; }
522
- if (local._meta) { local._meta.last_updated = TODAY; local._meta.last_threat_review = TODAY; }
523
- writeCatalog("attack-techniques.json", local);
524
- console.log(`[ok] attack-techniques.json: +${added} ICS entries, ${backfilled} backfills (now ${existing.size} total)`);
596
+ const changed = added > 0 || backfilled > 0;
597
+ if (changed) {
598
+ if (local._meta) { local._meta.last_updated = TODAY; local._meta.last_threat_review = TODAY; }
599
+ _writeCatalog("attack-techniques.json", local);
600
+ console.log(`[ok] attack-techniques.json: +${added} ICS entries, ${backfilled} backfills (now ${existing.size} total)`);
601
+ } else {
602
+ console.log("[ok] attack-techniques.json: no upstream ICS changes — file unchanged");
603
+ }
525
604
  return { added, backfilled };
526
605
  }
527
606
 
@@ -580,6 +659,15 @@ function backfillAtlas(cur, fresh) {
580
659
  if (!cur[key] && val) { cur[key] = val; touched = true; }
581
660
  }
582
661
  };
662
+ // Parity with backfillAttack: include the short description + tactic in the
663
+ // backfill set. Existing curated ATLAS rows often carry only {name} and need
664
+ // the short description + tactic too, not just description_full/platforms/etc.
665
+ fillIfEmpty("description", fresh.description);
666
+ // tactic: atlasEntryFromStix() emits a STRING for a single-tactic technique
667
+ // and an array for multi-tactic, so backfill both forms. fillIfEmpty only
668
+ // writes when cur is empty, so an existing (string OR array) tactic is never
669
+ // overwritten — the common single-tactic case is no longer left unhydrated.
670
+ fillIfEmpty("tactic", fresh.tactic);
583
671
  fillIfEmpty("description_full", fresh.description_full);
584
672
  fillIfEmpty("platforms", fresh.platforms);
585
673
  fillIfEmpty("detection", fresh.detection);
@@ -591,9 +679,12 @@ function backfillAtlas(cur, fresh) {
591
679
  return touched;
592
680
  }
593
681
 
594
- async function refreshAtlas({ dry = false } = {}) {
682
+ async function refreshAtlas({ dry = false, _deps = {} } = {}) {
683
+ const _fetchUrl = _deps.fetchUrl || fetchUrl;
684
+ const _loadCatalog = _deps.loadCatalog || loadCatalog;
685
+ const _writeCatalog = _deps.writeCatalog || writeCatalog;
595
686
  console.log("[refresh-upstream:atlas] fetching MITRE ATLAS STIX...");
596
- const body = await fetchUrl(ATLAS_SRC);
687
+ const body = await _fetchUrl(ATLAS_SRC);
597
688
  const stix = JSON.parse(body);
598
689
  const techs = (stix.objects || []).filter(
599
690
  (o) => o.type === "attack-pattern" && !o.revoked && !o.x_mitre_deprecated
@@ -609,7 +700,7 @@ async function refreshAtlas({ dry = false } = {}) {
609
700
  atlasVersion = o.x_mitre_version; break;
610
701
  }
611
702
  }
612
- const local = loadCatalog("atlas-ttps.json");
703
+ const local = _loadCatalog("atlas-ttps.json");
613
704
  const existing = new Set(Object.keys(local).filter((k) => k !== "_meta"));
614
705
  aml.sort((a, b) => {
615
706
  const aSub = a.x_mitre_is_subtechnique ? 1 : 0;
@@ -635,13 +726,22 @@ async function refreshAtlas({ dry = false } = {}) {
635
726
  added++;
636
727
  }
637
728
  if (dry) { console.log(`[refresh-upstream:atlas] DRY-RUN: +${added} new, ${backfilled} backfills${atlasVersion ? `, v${atlasVersion}` : ""}`); return { added, backfilled, atlasVersion }; }
638
- if (local._meta) {
639
- if (atlasVersion) local._meta.atlas_version = atlasVersion;
640
- local._meta.last_updated = TODAY;
641
- local._meta.last_threat_review = TODAY;
729
+ // A newly-detected ATLAS matrix version that differs from the recorded one
730
+ // is itself a change (the catalog should bump atlas_version + last_updated
731
+ // together), independent of any added/backfilled rows.
732
+ const versionChanged = !!(atlasVersion && local._meta && local._meta.atlas_version !== atlasVersion);
733
+ const changed = added > 0 || backfilled > 0 || versionChanged;
734
+ if (changed) {
735
+ if (local._meta) {
736
+ if (atlasVersion) local._meta.atlas_version = atlasVersion;
737
+ local._meta.last_updated = TODAY;
738
+ local._meta.last_threat_review = TODAY;
739
+ }
740
+ _writeCatalog("atlas-ttps.json", local);
741
+ console.log(`[ok] atlas-ttps.json: +${added} entries, ${backfilled} backfills (now ${existing.size} total${atlasVersion ? `, ATLAS v${atlasVersion}` : ""})`);
742
+ } else {
743
+ console.log("[ok] atlas-ttps.json: no upstream changes — file unchanged");
642
744
  }
643
- writeCatalog("atlas-ttps.json", local);
644
- console.log(`[ok] atlas-ttps.json: +${added} entries, ${backfilled} backfills (now ${existing.size} total${atlasVersion ? `, ATLAS v${atlasVersion}` : ""})`);
645
745
  return { added, backfilled, atlasVersion };
646
746
  }
647
747
 
@@ -672,8 +772,18 @@ function d3fendIdList(t, field) {
672
772
  return arr.map((x) => (x && x["@id"]) ? String(x["@id"]).replace(/^d3f:/, "") : null).filter(Boolean);
673
773
  }
674
774
 
775
+ // Strip a trailing period from an OWL d3fend-id: a few upstream artifact ids
776
+ // (e.g. "D3A-C4.") carry a spurious terminal dot that no id token regex can
777
+ // round-trip, leaving the entry unmatchable by the orphan/cross-ref scanners.
778
+ // No legitimate d3fend technique id ends in a period. The SAME normalization
779
+ // must key the catalog (existing.has / local[id]) as well as the entry payload,
780
+ // or a refresh re-adds the period-keyed row every run (duplicate / churn).
781
+ function normD3fendId(rawId) {
782
+ return typeof rawId === "string" ? rawId.replace(/\.$/, "") : rawId;
783
+ }
784
+
675
785
  function d3fendEntryFromOwl(t) {
676
- const id = t["d3f:d3fend-id"];
786
+ const id = normD3fendId(t["d3f:d3fend-id"]);
677
787
  const labelRaw = t["rdfs:label"];
678
788
  const name = Array.isArray(labelRaw)
679
789
  ? (typeof labelRaw[0] === "object" ? labelRaw[0]["@value"] : labelRaw[0])
@@ -736,19 +846,22 @@ function backfillD3fend(cur, fresh) {
736
846
  return touched;
737
847
  }
738
848
 
739
- async function refreshD3fend({ dry = false, cap = Infinity } = {}) {
849
+ async function refreshD3fend({ dry = false, cap = Infinity, _deps = {} } = {}) {
850
+ const _fetchUrl = _deps.fetchUrl || fetchUrl;
851
+ const _loadCatalog = _deps.loadCatalog || loadCatalog;
852
+ const _writeCatalog = _deps.writeCatalog || writeCatalog;
740
853
  console.log("[refresh-upstream:d3fend] fetching MITRE D3FEND ontology...");
741
- const body = await fetchUrl(D3FEND_SRC);
854
+ const body = await _fetchUrl(D3FEND_SRC);
742
855
  const j = JSON.parse(body);
743
856
  const graph = j["@graph"] || [];
744
857
  const techs = graph.filter((o) => o["@id"] && o["d3f:d3fend-id"] && o["rdfs:label"]);
745
858
  console.log(`[refresh-upstream:d3fend] ontology techniques: ${techs.length}`);
746
- const local = loadCatalog("d3fend-catalog.json");
859
+ const local = _loadCatalog("d3fend-catalog.json");
747
860
  const existing = new Set(Object.keys(local).filter((k) => k !== "_meta"));
748
861
  techs.sort((a, b) => String(a["d3f:d3fend-id"]).localeCompare(String(b["d3f:d3fend-id"])));
749
862
  let added = 0, backfilled = 0;
750
863
  for (const t of techs) {
751
- const id = t["d3f:d3fend-id"];
864
+ const id = normD3fendId(t["d3f:d3fend-id"]);
752
865
  if (existing.has(id)) {
753
866
  const fresh = d3fendEntryFromOwl(t);
754
867
  const cur = local[id];
@@ -761,9 +874,14 @@ async function refreshD3fend({ dry = false, cap = Infinity } = {}) {
761
874
  added++;
762
875
  }
763
876
  if (dry) { console.log(`[refresh-upstream:d3fend] DRY-RUN: +${added} new, ${backfilled} backfills`); return { added, backfilled }; }
764
- if (local._meta) { local._meta.last_updated = TODAY; local._meta.last_threat_review = TODAY; }
765
- writeCatalog("d3fend-catalog.json", local);
766
- console.log(`[ok] d3fend-catalog.json: +${added} entries, ${backfilled} backfills (now ${existing.size} total)`);
877
+ const changed = added > 0 || backfilled > 0;
878
+ if (changed) {
879
+ if (local._meta) { local._meta.last_updated = TODAY; local._meta.last_threat_review = TODAY; }
880
+ _writeCatalog("d3fend-catalog.json", local);
881
+ console.log(`[ok] d3fend-catalog.json: +${added} entries, ${backfilled} backfills (now ${existing.size} total)`);
882
+ } else {
883
+ console.log("[ok] d3fend-catalog.json: no upstream changes — file unchanged");
884
+ }
767
885
  return { added, backfilled };
768
886
  }
769
887
 
@@ -820,5 +938,12 @@ module.exports = {
820
938
  refreshAtlas,
821
939
  refreshD3fend,
822
940
  SOURCES,
823
- runCli
941
+ runCli,
942
+ // Exported for regression tests: fetchUrl's status/redirect handling and
943
+ // writeCatalog's atomicity are load-bearing fail-closed properties.
944
+ fetchUrl,
945
+ writeCatalog,
946
+ // Exported for regression tests: backfillAtlas must mirror backfillAttack's
947
+ // description + array-tactic backfill on curated rows.
948
+ backfillAtlas
824
949
  };