@blamejs/exceptd-skills 0.19.33 → 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 (119) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/exceptd.js +895 -2828
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +170 -76
  8. package/lib/collectors/cicd-pipeline-compromise.js +113 -136
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +198 -211
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +130 -118
  20. package/lib/collectors/scan-excludes.js +33 -139
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -155
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +39 -113
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +88 -236
  37. package/lib/playbook-runner.js +759 -2107
  38. package/lib/prefetch.js +101 -376
  39. package/lib/refresh-external.js +199 -633
  40. package/lib/refresh-network.js +78 -311
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +85 -146
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +28 -27
  48. package/lib/upstream-check-cli.js +36 -29
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +52 -121
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +78 -286
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -413
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +242 -242
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +29 -28
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +21 -31
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +26 -57
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +63 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +62 -81
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +83 -198
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +7 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +7 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +7 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +63 -148
  114. package/scripts/release.js +69 -234
  115. package/scripts/run-e2e-scenarios.js +26 -73
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -141
@@ -1,39 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  /**
4
- * check-codebase-patterns.js — grep-gate enforcement for code-shape bug
5
- * classes that have recurred across exceptd releases. One run surfaces every
6
- * class as a single numbered report instead of dying on the first hit.
4
+ * Grep gate for code-shape bug classes that recur across releases; CLASSES
5
+ * below is the registry.
7
6
  *
8
- * Shipped v1 classes:
9
- * - process-exit-after-stdout-write : a library-callable function writes to
10
- * the result channel (process.stdout.write / console.log) and then calls
11
- * process.exit(), which truncates the buffered write when stdout is
12
- * piped. Route through `safeExit(EXIT_CODES.X); return;` (lib/exit-codes).
13
- * This is the stdout-flush-truncation class the validate-cves fix closed by hand.
14
- * - dynamic-regex : `new RegExp(<non-literal>)` — a ReDoS sink when the
15
- * pattern derives from operator input. Use a static literal, or anchor +
16
- * length-cap the input, or mark the site `// allow:dynamic-regex —
17
- * <reason>` when the source is a trusted bundled schema.
18
- * - orphan-allow-class : an `// allow:<class>` marker whose class is not in
19
- * VALID_ALLOW_CLASSES, or is missing the `— <reason>` tail. A typo'd
20
- * marker suppresses nothing, so the underlying violation would ship
21
- * unflagged — this meta-guard keeps the marker mechanism trustworthy.
22
- * - unsorted-marked-array : a flat string array tagged `// keep-sorted` that
23
- * drifted out of alphabetical order. Opt-in — only marked arrays are
24
- * checked, so a one-time allowlist sort becomes a standing guarantee.
25
- * - misaligned-marked-run : a `// keep-aligned` const/weight table whose
26
- * `=`/`:` assignment columns are not all equal. Opt-in, same shape.
27
- *
28
- * Exceptions live at the violation site, not in this file:
7
+ * Exceptions live at the violation site:
29
8
  * - file-level, in the first 50 lines: // codebase-patterns:allow-file <class> — <reason>
30
9
  * - per-line, on the same line or up to 2 lines above: // allow:<class> — <reason>
31
10
  *
32
- * NOT covered here (owned elsewhere — do not duplicate):
33
- * - internal phase/version vocabulary in comments -> scripts/check-version-tags.js
34
- * - process.exit on the top-level CLI dispatch -> tests/safe-exit-grep.test.js
35
- * - anti-coincidence test assertions -> scripts/check-test-coverage.js
36
- * - internal-path leaks in operator output -> tests/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.
37
15
  */
38
16
 
39
17
  const fs = require("node:fs");
@@ -41,8 +19,8 @@ const path = require("node:path");
41
19
 
42
20
  const ROOT = path.resolve(__dirname, "..");
43
21
 
44
- // The classes that accept an `// allow:<class>` marker. orphan-allow-class is
45
- // the meta-guard itself and is intentionally NOT a markable class.
22
+ // Classes that accept an `// allow:<class>` marker. orphan-allow-class is the
23
+ // meta-guard itself, so it is not markable.
46
24
  const VALID_ALLOW_CLASSES = Object.freeze({
47
25
  "process-exit-after-stdout-write": true,
48
26
  "dynamic-regex": true,
@@ -57,8 +35,6 @@ const EXCLUDE_DIRS = new Set([
57
35
  "data", ".test-output", ".keys", "keys", "coverage",
58
36
  ]);
59
37
 
60
- // ---- file walk -----------------------------------------------------------
61
-
62
38
  function relPath(abs) {
63
39
  return path.relative(ROOT, abs).split(path.sep).join("/");
64
40
  }
@@ -104,18 +80,15 @@ function readLines(rel) {
104
80
  return lines;
105
81
  }
106
82
 
107
- // Strip a trailing `//` line comment for code-shape detection (so a class
108
- // name mentioned in a comment doesn't arm a detector). String-aware: a `//`
109
- // inside a quoted string (e.g. a `http://` URL) is NOT a comment, so the
110
- // scanner skips string contents — otherwise the rest of the line, including a
111
- // real `process.exit(...)` / `new RegExp(...)`, was silently truncated away and
112
- // the detector never fired.
83
+ // Strip a trailing `//` line comment so a class name mentioned in a comment
84
+ // can't arm a detector. String-aware: a `//` inside a quoted string (a `http://`
85
+ // URL) is not a comment — truncating there hides a real hit later on the line.
113
86
  function stripLineComment(line) {
114
87
  let inStr = null; // active quote char, or null
115
88
  for (let i = 0; i < line.length; i++) {
116
89
  const ch = line[i];
117
90
  if (inStr) {
118
- if (ch === "\\") { i++; continue; } // skip the escaped char
91
+ if (ch === "\\") { i++; continue; }
119
92
  if (ch === inStr) inStr = null;
120
93
  } else if (ch === "'" || ch === '"' || ch === "`") {
121
94
  inStr = ch;
@@ -126,8 +99,6 @@ function stripLineComment(line) {
126
99
  return line;
127
100
  }
128
101
 
129
- // ---- allow-marker engine -------------------------------------------------
130
-
131
102
  function hasFileAllow(rel, cls) {
132
103
  const head = readLines(rel).slice(0, 50);
133
104
  const re = new RegExp("codebase-patterns:allow-file\\s+" + cls + "\\b");
@@ -147,23 +118,10 @@ function filterMarkers(hits, cls) {
147
118
  return hits.filter((h) => !hasFileAllow(h.file, cls) && !hasLineAllow(h.file, h.line, cls));
148
119
  }
149
120
 
150
- // ---- require.main block ranges -------------------------------------------
151
-
152
- // Count `{` / `}` in `line` that are in REAL CODE context, advancing a
153
- // stateful tokenizer that tracks string / template / comment regions across
154
- // lines. Braces inside a single/double/template string, a `//` line comment,
155
- // or a `/* */` block comment do NOT affect depth — otherwise a `{` or `}`
156
- // typed inside a string literal in the require.main block miscounts the brace
157
- // balance and the computed block range slides onto an unrelated later function
158
- // (whose process.exit() is then wrongly treated as a CLI-entry exit and not
159
- // flagged). `inTemplate` and `inBlockComment` are the cross-line states a
160
- // per-line stripper cannot model, so the tokenizer state object is threaded
161
- // line-to-line by the caller.
162
- //
163
- // `state` is mutated in place: { inSingle, inDouble, inTemplate, inBlock,
164
- // templateDepth } — `templateDepth` tracks `${ … }` interpolation nesting so
165
- // the closing `}` of an interpolation is treated as template punctuation, not
166
- // a code brace, while braces INSIDE the interpolation expression still count.
121
+ // Counts `{` / `}` in REAL CODE context only: a brace inside a string, template
122
+ // or comment must not move the depth, or the computed require.main range slides
123
+ // onto a later function. `state` is mutated in place and threaded line to line,
124
+ // since `inTemplate` / `inBlock` are cross-line states.
167
125
  function countCodeBraces(line, state) {
168
126
  let delta = 0;
169
127
  for (let i = 0; i < line.length; i++) {
@@ -190,13 +148,12 @@ function countCodeBraces(line, state) {
190
148
  // Enter an interpolation expression: braces inside ARE code.
191
149
  state.templateExpr.push(0);
192
150
  state.inTemplate = false;
193
- i++; // skip the `{`; the `${` opener is template punctuation
151
+ i++;
194
152
  continue;
195
153
  }
196
154
  continue;
197
155
  }
198
- // Code context (possibly inside a template interpolation expression).
199
- if (ch === "/" && next === "/") break; // `//` — rest of the line is a comment
156
+ if (ch === "/" && next === "/") break;
200
157
  if (ch === "/" && next === "*") { state.inBlock = true; i++; continue; }
201
158
  if (ch === "'") { state.inSingle = true; continue; }
202
159
  if (ch === '"') { state.inDouble = true; continue; }
@@ -222,17 +179,12 @@ function newBraceState() {
222
179
  return { inSingle: false, inDouble: false, inTemplate: false, inBlock: false, templateExpr: [] };
223
180
  }
224
181
 
225
- // Line ranges (1-based, inclusive) of `if (require.main === module) { ... }`
226
- // blocks — the dual-mode CLI-entry section where synchronous-print-then-exit
227
- // is correct. process.exit there is owned by tests/safe-exit-grep.test.js and
228
- // is not a library-surface concern.
182
+ // Line ranges (1-based, inclusive) of `if (require.main === module) { ... }` blocks,
183
+ // where print-then-exit is correct — owned by tests/safe-exit-grep.test.js.
229
184
  function requireMainRanges(lines) {
230
185
  const ranges = [];
231
186
  for (let i = 0; i < lines.length; i++) {
232
187
  if (/\brequire\.main\s*===\s*module\b/.test(lines[i])) {
233
- // Find the opening brace (same line or next few), then balance —
234
- // string/comment/template-aware so braces inside literals don't skew the
235
- // depth (see countCodeBraces).
236
188
  let depth = 0;
237
189
  let started = false;
238
190
  let j = i;
@@ -252,19 +204,24 @@ function inRanges(ranges, lineNo) {
252
204
  return ranges.some(([a, b]) => lineNo >= a && lineNo <= b);
253
205
  }
254
206
 
255
- // ---- detectors -----------------------------------------------------------
256
-
257
- // A line that opens a new function body (so a backward stdout-write scan stops
258
- // at the enclosing function and doesn't arm an exit from an unrelated earlier
259
- // function). The bare-identifier (third) alternative matches a declaration /
260
- // method-shorthand opener (`foo() {`, `async bar() {`), but it must REFUSE
261
- // control-flow openers (`for (…) {`, `if (…) {`, `while/switch/catch (…) {`):
262
- // a control-flow block sitting between a stdout write and a process.exit() is
263
- // inside the SAME function, so stopping the backward scan there would wrongly
264
- // leave the exit unflagged. The negative lookahead excludes the control-flow
265
- // keywords; `function` and arrow alternatives are unchanged.
207
+ // Opens a new function body, so the backward stdout-write scan stops at the
208
+ // enclosing function. The bare-identifier alternative must REFUSE control-flow
209
+ // openers (`if (…) {`, `for (…) {`): those sit inside the SAME function, and
210
+ // stopping there leaves a real exit-after-write unflagged.
266
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*\{/;
267
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.
268
225
  function detectProcessExitAfterStdout(files) {
269
226
  const hits = [];
270
227
  for (const rel of (files || filesUnder(["bin/exceptd.js", "lib", "orchestrator", "scripts"]))) {
@@ -275,8 +232,7 @@ function detectProcessExitAfterStdout(files) {
275
232
  if (!/\bprocess\.exit\s*\(/.test(code)) continue;
276
233
  const lineNo = i + 1;
277
234
  if (inRanges(mainRanges, lineNo)) continue; // CLI-entry block: legitimate
278
- // Scan backward within the enclosing function for a result-channel
279
- // write (console.log / process.stdout.write). Stop at a function start.
235
+ // Scan backward within the enclosing function for a result-channel write.
280
236
  let sawStdout = false;
281
237
  for (let k = i - 1; k >= 0 && k >= i - 60; k--) {
282
238
  const prev = stripLineComment(lines[k]);
@@ -291,11 +247,8 @@ function detectProcessExitAfterStdout(files) {
291
247
  return filterMarkers(hits, "process-exit-after-stdout-write");
292
248
  }
293
249
 
294
- // The first non-whitespace char of the first arg is `"`, `'`, or `/` => a
295
- // string/regex literal => static, safe. Anything else (an identifier, a `(`,
296
- // a backtick template) is operator-derivable and flagged. Backtick is NOT
297
- // exempt — a template literal can interpolate operator input, so it must be
298
- // flagged the same as a bare identifier.
250
+ // A first arg opening with `"`, `'` or `/` is a literal, so static and safe.
251
+ // Backtick is NOT exempt — a template literal can interpolate operator input.
299
252
  function isStaticRegexFirstChar(ch) {
300
253
  return ch === '"' || ch === "'" || ch === "/";
301
254
  }
@@ -312,23 +265,17 @@ function detectDynamicRegex(files) {
312
265
  hits.push({ file: rel, line: i + 1, content: lines[i].trim() });
313
266
  continue;
314
267
  }
315
- // Multi-line form: `new RegExp(` ends the (comment-stripped) line with the
316
- // open paren as the last token, and the pattern arg is on a following
317
- // line. The single-line match above can't see the first-arg char, so it
318
- // would silently pass a dynamic RegExp whose argument starts next line.
319
- // Look ahead, skipping blank and comment-only lines (capped at 5), and
320
- // inspect the first code line's first non-whitespace char.
268
+ // Multi-line form: the pattern arg starts on a later line, so look ahead
269
+ // past blank and comment-only lines, capped at 5.
321
270
  if (!/\bnew RegExp\s*\(\s*$/.test(code)) continue;
322
271
  let firstChar = null;
323
272
  for (let k = i + 1; k <= i + 5 && k < lines.length; k++) {
324
273
  const ahead = stripLineComment(lines[k]).replace(/^\s+/, "");
325
- if (ahead === "") continue; // blank or comment-only — skip
274
+ if (ahead === "") continue;
326
275
  firstChar = ahead[0];
327
276
  break;
328
277
  }
329
- // A `new RegExp(` with nothing parseable after it within the cap is
330
- // suspicious — flag conservatively. Otherwise apply the SAME literal
331
- // exemption as the single-line path.
278
+ // Nothing parseable within the cap is suspicious — flag it.
332
279
  if (firstChar !== null && isStaticRegexFirstChar(firstChar)) continue;
333
280
  hits.push({ file: rel, line: i + 1, content: lines[i].trim() });
334
281
  }
@@ -336,13 +283,9 @@ function detectDynamicRegex(files) {
336
283
  return filterMarkers(hits, "dynamic-regex");
337
284
  }
338
285
 
339
- // Raw bidi-override / zero-width / invisible / null codepoints embedded as
340
- // literals in source — the Trojan-Source class (CVE-2021-42574). A literal
341
- // such codepoint is invisible in review and can reorder or hide code. Source
342
- // should emit them programmatically (via vendor/blamejs/codepoint-class) or
343
- // escape them (\uXXXX), never type them literally. The range table holds only
344
- // numeric codepoints + the regex is built from escapes, so this detector's own
345
- // source is clean (and the file self-skips below regardless).
286
+ // Raw bidi-override / zero-width / invisible / null codepoints typed as literals
287
+ // — the Trojan-Source class (CVE-2021-42574). Source emits them via
288
+ // vendor/blamejs/codepoint-class or a \uXXXX escape instead.
346
289
  const _BIDI_LITERAL_RANGES = [
347
290
  [0x202A, 0x202E], [0x2066, 0x2069], 0x200E, 0x200F, 0x061C, // bidi overrides + isolates
348
291
  0x200B, 0x200C, 0x200D, 0x00AD, 0x2060, 0xFEFF, // zero-width / invisible
@@ -378,13 +321,8 @@ function detectOrphanAllowClass(files) {
378
321
  const cmt = lines[i].indexOf("//");
379
322
  if (cmt === -1) continue;
380
323
  const comment = lines[i].slice(cmt);
381
- // Validate BOTH marker forms with the same class + reason rules:
382
- // per-line: allow:<class> — <reason>
383
- // file-level: codebase-patterns:allow-file <class> — <reason>
384
- // The file-level form is the broadest exemption (it suppresses every hit
385
- // of its class in the file), so a reason-less or unknown-class file-level
386
- // marker must be caught here too — otherwise it would suppress silently
387
- // and never reach the per-line orphan check.
324
+ // Both marker forms carry the same class + reason rules. The file-level
325
+ // form suppresses every hit of its class, so it must be caught here.
388
326
  const fileLevel = comment.match(/\bcodebase-patterns:allow-file\s+([a-z0-9-]+)\b(.*)$/);
389
327
  const perLine = comment.match(/\ballow:([a-z0-9-]+)\b(.*)$/);
390
328
  const m = fileLevel || perLine;
@@ -402,16 +340,11 @@ function detectOrphanAllowClass(files) {
402
340
  return hits;
403
341
  }
404
342
 
405
- // ---- opt-in readability detectors (preventative) -------------------------
406
- // These fire ONLY on sites that explicitly opt in via a marker, so unmarked
407
- // code is never flagged. They turn a one-time cleanup (sorting an allowlist,
408
- // aligning a const table) into a standing guarantee: mark the cleaned site and
409
- // the gate keeps it clean.
343
+ // The two detectors below fire only on sites that opt in via a marker, so
344
+ // unmarked code is never flagged.
410
345
 
411
- // `// keep-sorted` marks a flat string-literal array that must stay
412
- // alphabetically sorted (e.g. an allowlist). Only arrays whose opening line
413
- // carries the marker are checked; arrays containing object/nested elements are
414
- // skipped (not a flat string list).
346
+ // `// keep-sorted` marks a flat string-literal array that must stay alphabetically
347
+ // sorted; an array with object or nested elements is skipped.
415
348
  function scanUnsortedMarkedArray(rel, lines) {
416
349
  const hits = [];
417
350
  for (let i = 0; i < lines.length; i++) {
@@ -428,7 +361,7 @@ function scanUnsortedMarkedArray(rel, lines) {
428
361
  body += " " + seg;
429
362
  if (started && depth <= 0) break;
430
363
  }
431
- if (/[{]/.test(body)) continue; // object/nested elements — not a flat string array
364
+ if (/[{]/.test(body)) continue;
432
365
  const strs = [];
433
366
  const re = /(['"])((?:\\.|(?!\1).)*)\1/g;
434
367
  let m;
@@ -452,9 +385,8 @@ function detectUnsortedMarkedArray(files) {
452
385
  }
453
386
 
454
387
  // `// keep-aligned` marks a contiguous run of `IDENT = value` / `IDENT: value`
455
- // lines (a const/weight table) whose assignment columns must all line up. The
456
- // run is the lines immediately after the marker, until a blank or non-assignment
457
- // line. Opt-in, so only deliberately-aligned tables are enforced.
388
+ // lines whose assignment columns must all line up. The run starts at the line
389
+ // after the marker and ends at the first blank or non-assignment line.
458
390
  function scanMisalignedMarkedRun(rel, lines) {
459
391
  const hits = [];
460
392
  for (let i = 0; i < lines.length; i++) {
@@ -487,22 +419,13 @@ function detectMisalignedMarkedRun(files) {
487
419
  return hits;
488
420
  }
489
421
 
490
- // ---- hand-rolled SQL in a file that talks to a database ------------------
491
- //
492
- // exceptd ships no database today, so this is a FORWARD guard: the moment a
493
- // file imports a SQL driver, any SQL STATEMENT (`"SELECT …"`) or CLAUSE built
494
- // by string concatenation (`… + " WHERE " + …`) is a parameterization/injection
495
- // sink and must use the driver's bound-parameter API instead. The SQL-driver
496
- // import is the gate — a SQL-looking string in a file with no driver executes
497
- // nothing, so prose that merely begins with "Update …" / "Delete …" in a
498
- // non-DB file is never scanned (no false positives). A trusted static DDL
499
- // string can opt out with `// allow:hand-rolled-sql — <reason>`.
422
+ // Forward guard: the driver import is the gate, so prose merely beginning
423
+ // "Update …" in a non-DB file is never scanned. In a file that does import one,
424
+ // a statement or a concatenated clause is an injection sink.
500
425
  const SQL_DRIVER_IMPORT = /require\(\s*["'](?:node:sqlite|better-sqlite3|sqlite3|sqlite|pg|mysql2?|knex|sequelize|drizzle-orm|postgres|@libsql\/[\w.-]+)(?:\/[^"']*)?["']\s*\)|\bfrom\s+["'](?:node:sqlite|better-sqlite3|pg|mysql2?|knex|sequelize|drizzle-orm)(?:\/[^"']*)?["']/;
501
426
  const SQL_STMT_START = /(["'`])\s*(?:SELECT\b|INSERT\s+(?:INTO|OR)\b|REPLACE\s+INTO\b|UPDATE\s+["'`]?[A-Za-z_]|DELETE\s+FROM\b|CREATE\s+(?:TABLE|UNIQUE\s+INDEX|INDEX|TRIGGER|VIRTUAL\s+TABLE)\b|ALTER\s+TABLE\b|DROP\s+(?:TABLE|TRIGGER|INDEX)\b|MERGE\s+INTO\b)/i;
502
- // Trailing-concat form tolerates an embedded SQL string-quote inside the
503
- // clause (e.g. `" WHERE name = 'x' " + id`) by scanning to the first `+`
504
- // concatenation operator rather than requiring the clause string to close on
505
- // the same quote with no interior quotes.
427
+ // The trailing-concat form tolerates a quote inside the clause
428
+ // (`" WHERE name = 'x' " + id`) by scanning to the first `+`.
506
429
  const SQL_CLAUSE_FRAG = /(?:\+\s*["'`]\s*(?:SET|FROM|WHERE|VALUES|ORDER\s+BY|GROUP\s+BY|HAVING|RETURNING|LIMIT|OFFSET|ON\s+CONFLICT|(?:INNER\s+|LEFT\s+|RIGHT\s+|CROSS\s+)?JOIN)\b|["'`]\s*(?:SET|FROM|WHERE|VALUES\s*\(|ORDER\s+BY|GROUP\s+BY|HAVING|RETURNING|ON\s+CONFLICT|(?:INNER\s+|LEFT\s+|RIGHT\s+|CROSS\s+)?JOIN)\b[^+]*\+)/i;
507
430
  function detectHandRolledSql(files) {
508
431
  const hits = [];
@@ -564,11 +487,8 @@ const CLASSES = [
564
487
  ];
565
488
 
566
489
  function main() {
567
- // Fail closed if the scan universe is empty. Each detector scopes to a subset
568
- // of these roots and silently finds no hits when its root list is
569
- // unreadable/empty — so a wholesale missing source tree would make every
570
- // class report "clean" and the gate pass without scanning anything (the
571
- // absent-input false-pass class). Refuse to call zero-files-scanned "clean".
490
+ // Fail closed on an empty scan universe: each detector silently finds no hits
491
+ // when its roots are unreadable. Zero files scanned is not "clean".
572
492
  const universe = filesUnder(["bin/exceptd.js", "lib", "orchestrator", "scripts"]);
573
493
  if (universe.length === 0) {
574
494
  console.error("[check-codebase-patterns] FAIL — zero source files found under bin/lib/orchestrator/scripts; refusing to report clean without scanning anything.");
@@ -2,40 +2,10 @@
2
2
  "use strict";
3
3
 
4
4
  /**
5
- * EPSS score/percentile consistency gate.
5
+ * EPSS score/percentile consistency gate. Exit 0 consistent, 1 inconsistent.
6
6
  *
7
- * EPSS publishes a score and a percentile for every CVE, refreshed daily. The
8
- * percentile is the score's RANK within that day's publication: if A scores
9
- * higher than B on a given day, A's percentile is at least B's. That makes the
10
- * pair self-checking with no network access — sort a day's entries by score and
11
- * the percentiles must come out sorted too.
12
- *
13
- * The failure this catches is updating one field without the other. A refresh
14
- * that writes a new score but keeps yesterday's percentile leaves a plausible
15
- * entry: both numbers are in range, both look like EPSS values, and nothing
16
- * downstream can tell they describe different days. The result is a CVE ranked
17
- * as top-decile urgency on a score that no longer supports it, which is exactly
18
- * the input the prioritisation model trusts most.
19
- *
20
- * What the ordering check does and does not establish. Monotonicity is a
21
- * necessary condition, not a sufficient one: it catches a stale percentile that
22
- * contradicts the new score's rank, which is what a partial refresh usually
23
- * produces, but a stale value that happens to preserve the ordering passes.
24
- * Proving two fields share a publication would require the fetched row, and
25
- * that is not recoverable from the catalog offline. So this is a corruption
26
- * detector, not a provenance proof — the provenance guarantee has to come from
27
- * the write side, by fetching and writing score, percentile and date together.
28
- *
29
- * Three checks run:
30
- * - within each epss_date, sorting by score must sort by percentile;
31
- * - an entry carries both numeric fields or neither;
32
- * - epss_note, which is derived text, must restate the current fields.
33
- *
34
- * Entries are grouped by epss_date so each publication is checked against
35
- * itself; comparing across days is meaningless because the whole distribution
36
- * shifts.
37
- *
38
- * Exit codes: 0 consistent, 1 inconsistent.
7
+ * The percentile is the score's rank within one publication, so a cohort must
8
+ * sort the same way by both. Monotonicity is necessary, not sufficient.
39
9
  */
40
10
 
41
11
  const fs = require("node:fs");
@@ -44,13 +14,8 @@ const path = require("node:path");
44
14
  const ROOT = path.resolve(__dirname, "..");
45
15
 
46
16
  /**
47
- * Percentiles are published rounded to five decimals, so two scores that are
48
- * adjacent in the ranking can round to percentiles that invert by a hair. That
49
- * is arithmetic, not drift. Real mismatches are pairs pulled from different
50
- * days, which land orders of magnitude above this: the widest rounding artifact
51
- * observed in the catalog is 8e-5, while a stale-field mismatch runs 0.05-0.9.
52
- * The threshold sits between the two, far enough from each that neither
53
- * classification is a close call.
17
+ * Percentiles publish rounded to five decimals, so adjacent scores can invert by
18
+ * a hair. Rounding artifacts reach 8e-5; a stale-field mismatch runs 0.05-0.9.
54
19
  */
55
20
  const ROUNDING_TOLERANCE = 1e-3;
56
21
 
@@ -58,24 +23,10 @@ function loadCatalog(file) {
58
23
  return JSON.parse(fs.readFileSync(file, "utf8"));
59
24
  }
60
25
 
61
- /**
62
- * The operator-readable restatement of an entry's EPSS fields. Entries carry it
63
- * as `epss_note`, and it is derived, not authored — so it must be rebuilt
64
- * whenever the numbers move.
65
- *
66
- * The renderer lives in lib/cve-enrich.js because the refresh writer rebuilds
67
- * the note from the same definition. A second copy here would let the gate and
68
- * the writer disagree about the exact wording, which shows up as this gate
69
- * failing on data that was refreshed correctly.
70
- */
26
+ /** Shared with the refresh writer so gate and writer cannot disagree on wording. */
71
27
  const { renderEpssNote: renderNote } = require("../lib/cve-enrich.js");
72
28
 
73
- /**
74
- * Group entries by publication date. Entries missing either field are reported
75
- * separately rather than skipped — a half-populated pair is its own defect, and
76
- * silently ignoring it would let the gate pass on the very shape it exists to
77
- * catch.
78
- */
29
+ /** Group entries by publication date; a half-populated pair is reported, not skipped. */
79
30
  function cohorts(catalog) {
80
31
  const groups = new Map();
81
32
  const incomplete = [];
@@ -101,10 +52,7 @@ function cohorts(catalog) {
101
52
  return { groups, incomplete };
102
53
  }
103
54
 
104
- /**
105
- * Sort a cohort by score and report every place the percentile goes backwards
106
- * by more than rounding can explain.
107
- */
55
+ /** Sort by score; report every percentile step backwards that rounding cannot explain. */
108
56
  function inversions(rows) {
109
57
  const sorted = [...rows].sort((a, b) => a.score - b.score);
110
58
  const found = [];
@@ -141,10 +89,7 @@ function check(catalogFile) {
141
89
  failures.push(`${row.id}: has one EPSS field but not ${row.missing} — write both together or neither`);
142
90
  }
143
91
 
144
- // The prose restatement drifts the same way the percentile does: a refresh
145
- // moves the numbers and leaves the sentence describing the previous
146
- // publication, so the entry states two different scores as of two different
147
- // dates. It is derived text, so it can be checked exactly.
92
+ // epss_note is derived text, so it can be checked exactly against the fields.
148
93
  for (const [id, entry] of Object.entries(catalog)) {
149
94
  if (typeof entry.epss_note !== "string") continue;
150
95
  if (typeof entry.epss_score !== "number" || typeof entry.epss_percentile !== "number") continue;
@@ -1,31 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
- * scripts/check-framework-gap-coverage.js
3
+ * Enforces global-first coverage (AGENTS.md Hard Rule #5): every curated CVE in
4
+ * data/cve-catalog.json must declare a framework_control_gaps statement for all
5
+ * five jurisdiction buckets, so a multi-jurisdiction operator reading the
6
+ * offline catalog gets more than a US-centric subset.
4
7
  *
5
- * AGENTS.md Hard Rule #5 (global-first) enforcement gate. Every curated CVE
6
- * in data/cve-catalog.json must declare a framework_control_gaps statement
7
- * for all five jurisdiction buckets:
8
+ * Draft entries (_auto_imported) are exempt — they carry raw NVD data, and the
9
+ * curation bar requires promotion before shipping, at which point this applies.
8
10
  *
9
- * NIST — NIST-800-53-* / NIST-800-63*
10
- * EU — NIS2-* / DORA-* / EU-AI-* / GDPR-*
11
- * UK — UK-CAF-*
12
- * AU — AU-Essential-8-* / AU-ISM-*
13
- * ISO — ISO-27001-2022-*
14
- *
15
- * A multi-jurisdiction operator reading the offline catalog's framework-gap
16
- * output must get coverage for every required jurisdiction on every CVE, not
17
- * a US-centric subset. Before this gate, two-thirds of the corpus mapped only
18
- * a subset; a codex review surfaced it, the corpus was completed, and this
19
- * gate keeps it from regressing when new CVEs are curated.
20
- *
21
- * Draft entries (_auto_imported === true) are exempt — they carry raw NVD
22
- * data that has not been through curation yet. The curation bar (elsewhere)
23
- * requires drafts to be promoted before shipping, at which point this gate
24
- * applies.
25
- *
26
- * Exit code: 0 when every curated entry is complete, 1 when any entry omits a
27
- * required bucket (the list is printed). Uses process.exitCode (not
28
- * process.exit) so buffered stdout drains before the process ends.
11
+ * Exits 0 when every curated entry is complete, 1 when any omits a bucket.
12
+ * `process.exitCode`, never `process.exit()`: the failure list is a buffered
13
+ * stdout write that the exit can truncate.
29
14
  */
30
15
 
31
16
  'use strict';
@@ -34,19 +19,16 @@ const fs = require('node:fs');
34
19
  const path = require('node:path');
35
20
 
36
21
  const ROOT = path.resolve(__dirname, '..');
37
- // Catalog path is overridable (argv[2]) so the gate's own test can point it at
38
- // a fixture; defaults to the shipped catalog for the real predeploy run.
22
+ // argv[2] overrides the catalog so the gate's own test can point at a fixture.
39
23
  const CATALOG = process.argv[2] || path.join(ROOT, 'data', 'cve-catalog.json');
40
24
 
41
25
  const REQUIRED = ['NIST', 'EU', 'UK', 'AU', 'ISO'];
42
26
 
43
27
  function bucketOf(key) {
44
28
  if (/^NIST/i.test(key)) return 'NIST';
45
- // EU bucket is the security-regulation family AGENTS.md Hard Rule #5 names:
46
- // NIS2, DORA, the EU AI Act, and the EU Cyber Resilience Act. GDPR / EU-GDPR
47
- // (data-protection) is NOT sufficient on its own — an entry mapped only to
48
- // GDPR still owes NIS2/DORA/EU-AI coverage, so GDPR does not satisfy this
49
- // bucket. Note EU-AI / EU-CRA match here; EU-GDPR deliberately does not.
29
+ // The EU bucket is the security-regulation family Hard Rule #5 names. GDPR is
30
+ // data protection and does NOT satisfy it: an entry mapped only to GDPR still
31
+ // owes NIS2/DORA/EU-AI coverage, so EU-GDPR must not match here.
50
32
  if (/^(NIS2|DORA|EU-AI|EU-CRA)/i.test(key)) return 'EU';
51
33
  if (/^UK-CAF/i.test(key)) return 'UK';
52
34
  if (/^(AU-Essential-?8|AU-ISM|Essential-?8)/i.test(key)) return 'AU';