@blamejs/exceptd-skills 0.18.11 → 0.18.13

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 (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/bin/exceptd.js +12 -5
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +10 -1
  5. package/lib/collectors/cred-stores.js +18 -9
  6. package/lib/collectors/secrets.js +4 -2
  7. package/lib/cve-regression-watcher.js +5 -1
  8. package/lib/framework-gap.js +25 -14
  9. package/lib/job-queue.js +9 -2
  10. package/lib/lint-skills.js +34 -5
  11. package/lib/playbook-runner.js +65 -2
  12. package/lib/prefetch.js +15 -7
  13. package/lib/rfc-cli.js +7 -2
  14. package/lib/schemas/skill-frontmatter.schema.json +2 -2
  15. package/lib/scoring.js +40 -7
  16. package/lib/source-ghsa.js +8 -2
  17. package/lib/source-osv.js +19 -3
  18. package/lib/ttp-mapper.js +15 -0
  19. package/lib/upstream-check.js +17 -3
  20. package/lib/validate-cve-catalog.js +16 -4
  21. package/lib/validate-package.js +9 -2
  22. package/lib/validate-playbooks.js +35 -10
  23. package/lib/validate-vendor.js +11 -5
  24. package/lib/verify.js +35 -34
  25. package/lib/xml-tokenizer.js +36 -8
  26. package/manifest.json +53 -53
  27. package/orchestrator/dispatcher.js +8 -0
  28. package/orchestrator/scanner.js +20 -0
  29. package/package.json +1 -1
  30. package/sbom.cdx.json +87 -87
  31. package/scripts/builders/cwe-chains.js +5 -2
  32. package/scripts/builders/summary-cards.js +12 -4
  33. package/scripts/check-codebase-patterns-currency.js +1 -0
  34. package/scripts/check-codebase-patterns.js +42 -0
  35. package/scripts/check-test-subjects.js +27 -6
  36. package/scripts/predeploy.js +19 -8
  37. package/scripts/refresh-upstream-catalogs.js +20 -3
  38. package/sources/validators/cve-validator.js +7 -1
  39. package/sources/validators/rfc-validator.js +21 -3
  40. package/sources/validators/version-pin-validator.js +39 -2
  41. package/vendor/blamejs/_PROVENANCE.json +3 -2
  42. package/vendor/blamejs/worker-pool.js +6 -0
@@ -49,6 +49,7 @@ const VALID_ALLOW_CLASSES = Object.freeze({
49
49
  "bidi-codepoint-literal": true,
50
50
  "unsorted-marked-array": true,
51
51
  "misaligned-marked-run": true,
52
+ "hand-rolled-sql": true,
52
53
  });
53
54
 
54
55
  const EXCLUDE_DIRS = new Set([
@@ -486,6 +487,37 @@ function detectMisalignedMarkedRun(files) {
486
487
  return hits;
487
488
  }
488
489
 
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>`.
500
+ 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
+ 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.
506
+ 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
+ function detectHandRolledSql(files) {
508
+ const hits = [];
509
+ for (const rel of (files || filesUnder(["lib", "orchestrator", "bin/exceptd.js", "scripts"]))) {
510
+ const lines = readLines(rel);
511
+ if (!SQL_DRIVER_IMPORT.test(lines.join("\n"))) continue; // not a DB file — never scan prose
512
+ for (let i = 0; i < lines.length; i++) {
513
+ const code = stripLineComment(lines[i]);
514
+ if (SQL_STMT_START.test(code)) { hits.push({ file: rel, line: i + 1, content: lines[i].trim().slice(0, 110), why: "SQL statement in a string literal" }); continue; }
515
+ if (SQL_CLAUSE_FRAG.test(code)) { hits.push({ file: rel, line: i + 1, content: lines[i].trim().slice(0, 110), why: "SQL clause built by concatenation" }); }
516
+ }
517
+ }
518
+ return filterMarkers(hits, "hand-rolled-sql");
519
+ }
520
+
489
521
  const CLASSES = [
490
522
  {
491
523
  id: "process-exit-after-stdout-write",
@@ -523,6 +555,12 @@ const CLASSES = [
523
555
  warnOnly: false,
524
556
  hint: "a typo'd or reason-less `// allow:<class>` suppresses nothing — fix the class id or add `— <reason>`",
525
557
  },
558
+ {
559
+ id: "hand-rolled-sql",
560
+ run: detectHandRolledSql,
561
+ warnOnly: false,
562
+ hint: "a SQL statement/clause assembled as a string in a file that imports a SQL driver is an injection sink — use bound parameters, or `// allow:hand-rolled-sql — <reason>` for a trusted static DDL string",
563
+ },
526
564
  ];
527
565
 
528
566
  function main() {
@@ -559,6 +597,10 @@ module.exports = {
559
597
  detectOrphanAllowClass,
560
598
  detectUnsortedMarkedArray,
561
599
  detectMisalignedMarkedRun,
600
+ detectHandRolledSql,
601
+ SQL_DRIVER_IMPORT,
602
+ SQL_STMT_START,
603
+ SQL_CLAUSE_FRAG,
562
604
  scanUnsortedMarkedArray,
563
605
  scanMisalignedMarkedRun,
564
606
  requireMainRanges,
@@ -54,8 +54,17 @@ function deriveSubjects() {
54
54
  if (!["lib", "scripts", "orchestrator", "bin"].includes(parent)) add(parent + "-" + base, "alias:" + rel);
55
55
  const txt = read(rel);
56
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
- const exp = txt.match(/module\.exports\s*=\s*\{([\s\S]*?)\}/);
58
- if (exp) for (const k of exp[1].matchAll(/([A-Za-z_$][\w$]*)\s*[,:}\n]/g)) add(camelKebab(k[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
+ }
59
68
  }
60
69
  }
61
70
  ["lib", "orchestrator", "scripts", "bin", "sources/validators"].forEach(walkSrc);
@@ -74,7 +83,14 @@ function deriveSubjects() {
74
83
  // data catalog files
75
84
  for (const e of ls("data")) if (e.isFile() && e.name.endsWith(".json")) add(e.name.replace(/\.json$/, ""), "data");
76
85
  // data ENTRY primitives: every CVE id + every playbook
77
- 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"); } catch {}
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.");
78
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"); }
79
95
  // workflows
80
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"); }
@@ -111,14 +127,19 @@ function run() {
111
127
  for (const t of testFiles) if (!subjects.has(t.toLowerCase())) forward.push({ file: "tests/" + t + ".test.js", suggested: suggest(t) });
112
128
  const reverse = [];
113
129
  for (const [s, kind] of subjects) if (!testSet.has(s)) reverse.push({ subject: s, kind });
114
- return { subjects: subjects.size, forward, reverse };
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 };
115
136
  }
116
137
 
117
138
  if (require.main === module) {
118
139
  const r = run();
119
- if (process.argv.includes("--worklist")) { process.stdout.write(JSON.stringify(r) + "\n"); process.exitCode = (r.forward.length || r.reverse.length) ? 1 : 0; }
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; }
120
142
  else {
121
- const revMods = r.reverse.filter((x) => x.kind.startsWith("module:") || x.kind.startsWith("cve-primitive") || x.kind.startsWith("playbook-primitive"));
122
143
  console.log(`[check-test-subjects] valid subjects=${r.subjects} | FORWARD violations=${r.forward.length} | REVERSE (module/cve/playbook) gaps=${revMods.length}`);
123
144
  if (r.forward.length || revMods.length) { console.log("[check-test-subjects] FAIL — run with --worklist for the full list."); process.exitCode = 1; }
124
145
  else console.log("[check-test-subjects] ok — every test maps to a subject and every subject has a test.");
@@ -335,16 +335,27 @@ function runGate(gate) {
335
335
  const ceil = typeof gate.informationalMaxExitCode === "number"
336
336
  ? gate.informationalMaxExitCode
337
337
  : Infinity;
338
- // A signal kill (spawnSync returns status:null with r.signal set — e.g. a
339
- // 137 OOM kill) is a crash, not an informational soft-signal. Without this,
340
- // an OOM-killed informational gate fell through to "informational" and the
341
- // release proceeded as if the gate had merely produced advisory output.
342
- 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)) {
343
350
  return {
344
351
  status: "failed",
345
- exitCode: r.status,
346
- 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
347
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`
348
359
  : `informational gate crashed (exit ${r.status} > informationalMaxExitCode=${ceil})`,
349
360
  durationMs,
350
361
  warnCount,
@@ -438,7 +449,7 @@ function main() {
438
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
439
450
  }
440
451
 
441
- module.exports = { GATES };
452
+ module.exports = { GATES, runGate };
442
453
 
443
454
  if (require.main === module) {
444
455
  try {
@@ -275,8 +275,13 @@ async function refreshRfc({ dry = false, _deps = {} } = {}) {
275
275
  const cur = cat[id];
276
276
  if (!cur) continue;
277
277
  let touched = false;
278
- if (cur._auto_imported && cur.status !== RFC_STATUS_MAP[e.status]) {
279
- 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;
280
285
  touched = true;
281
286
  statusBumped++;
282
287
  }
@@ -654,6 +659,15 @@ function backfillAtlas(cur, fresh) {
654
659
  if (!cur[key] && val) { cur[key] = val; touched = true; }
655
660
  }
656
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);
657
671
  fillIfEmpty("description_full", fresh.description_full);
658
672
  fillIfEmpty("platforms", fresh.platforms);
659
673
  fillIfEmpty("detection", fresh.detection);
@@ -928,5 +942,8 @@ module.exports = {
928
942
  // Exported for regression tests: fetchUrl's status/redirect handling and
929
943
  // writeCatalog's atomicity are load-bearing fail-closed properties.
930
944
  fetchUrl,
931
- writeCatalog
945
+ writeCatalog,
946
+ // Exported for regression tests: backfillAtlas must mirror backfillAttack's
947
+ // description + array-tactic backfill on curated rows.
948
+ backfillAtlas
932
949
  };
@@ -272,7 +272,13 @@ async function validateCve(cveId, localEntry) {
272
272
  const disputed = (fetched.cve_tags || []).some(t => /disputed/i.test(t)) ||
273
273
  /disputed/i.test(fetched.nvd_vuln_status || '');
274
274
  if (cveFoundInNvd && disputed) {
275
- pushDiscrepancy(discrepancies, 'cve_status', local?.status ?? null, 'disputed', 'high');
275
+ // `local` is the projected shape (cvss_*, cisa_kev*, epss_*) and never
276
+ // carries a status field — reading local?.status here always yielded null,
277
+ // so the local side of a disputed discrepancy reported nothing. Read the
278
+ // catalog's own status/cve_status off the RAW input so an entry that
279
+ // already records the dispute (or a stale "active" status) is surfaced.
280
+ const localCveStatus = localEntry?.cve_status ?? localEntry?.status ?? null;
281
+ pushDiscrepancy(discrepancies, 'cve_status', localCveStatus, 'disputed', 'high');
276
282
  }
277
283
 
278
284
  // --- Compare CVSS (only if NVD reachable & has data) ---
@@ -13,6 +13,10 @@
13
13
  * drift — at least one of those fields disagrees.
14
14
  * missing — Datatracker does not have this RFC / draft.
15
15
  * unreachable — network failed or the request timed out.
16
+ * skipped — the catalog key is not an IETF RFC/DRAFT (e.g. CSAF-2.0,
17
+ * ISO-29147, ISO-30111). Datatracker only tracks IETF
18
+ * documents, so these have no upstream to compare against —
19
+ * they are intentionally not validated here, NOT drift.
16
20
  *
17
21
  * Zero external dependencies. Node 24 stdlib (fetch + AbortController).
18
22
  *
@@ -107,15 +111,26 @@ function compareEntry(local, upstream) {
107
111
  return discrepancies;
108
112
  }
109
113
 
114
+ // Datatracker only tracks IETF RFCs and Internet-Drafts. A catalog key that is
115
+ // neither (CSAF-2.0, ISO-29147, ISO-30111, …) has no IETF upstream to compare
116
+ // against, so it is out of scope for this validator rather than a discrepancy.
117
+ function isIetfKey(id) {
118
+ return typeof id === 'string' && (id.startsWith('RFC-') || id.startsWith('DRAFT-'));
119
+ }
120
+
110
121
  async function validateRfc(id, entry) {
111
122
  let docName;
112
123
  if (id.startsWith('RFC-')) docName = rfcNumberFromKey(id);
113
124
  else if (id.startsWith('DRAFT-')) docName = draftSlugFromKey(id);
114
125
  else {
126
+ // Not an IETF document — there is nothing on Datatracker to diff against.
127
+ // Report a distinct, non-drift status with no discrepancies so callers
128
+ // don't surface a permanent false-positive "drift" for every ISO/CSAF
129
+ // reference the catalog carries.
115
130
  return {
116
131
  id,
117
- status: 'drift',
118
- discrepancies: [`unrecognized catalog key shape: ${id}`],
132
+ status: 'skipped',
133
+ discrepancies: [],
119
134
  local: entry,
120
135
  fetched: null,
121
136
  };
@@ -150,7 +165,10 @@ async function validateRfc(id, entry) {
150
165
  }
151
166
 
152
167
  async function validateAllRfcs(refs, { concurrency = 4 } = {}) {
153
- const ids = Object.keys(refs).filter(k => !k.startsWith('_'));
168
+ // Skip "_"-prefixed meta keys AND any non-IETF reference (ISO/CSAF/etc.):
169
+ // Datatracker has no record of those, so validating them only ever produced
170
+ // permanent false-positive "drift". They are simply out of scope here.
171
+ const ids = Object.keys(refs).filter(k => !k.startsWith('_') && isIetfKey(k));
154
172
  const results = [];
155
173
  for (let i = 0; i < ids.length; i += concurrency) {
156
174
  const batch = ids.slice(i, i + concurrency);
@@ -7,8 +7,17 @@
7
7
  *
8
8
  * - MITRE ATLAS: GitHub releases (mitre-atlas/atlas-data)
9
9
  * - MITRE ATT&CK: GitHub releases (mitre-attack/attack-stix-data)
10
- * - MITRE D3FEND: GitHub releases (mitre-d3fend/d3fend-data)
11
- * - MITRE CWE: GitHub releases (mitre/cwe)
10
+ *
11
+ * MITRE D3FEND and MITRE CWE do NOT publish tagged GitHub releases — D3FEND
12
+ * ships its ontology from d3fend/d3fend-ontology untagged, and CWE distributes
13
+ * XML from cwe.mitre.org. Their pin currency is tracked on the upstream-check
14
+ * path (lib/upstream-check.js) against those canonical mitre.org endpoints, not
15
+ * via the GitHub releases API. They remain enumerated here for completeness but
16
+ * are flagged `github_releases: false`, so the GitHub-release check is skipped
17
+ * for them and they are reported as `tracked_elsewhere` rather than emitting a
18
+ * spurious `unreachable` (a release API call for a repo with no releases always
19
+ * fails). This keeps the live path checking the same two GitHub-release repos
20
+ * the cache path (pinsDiffFromCache) checks.
12
21
  *
13
22
  * Each check returns:
14
23
  * { pin_name, local_version, latest_version, drift: bool, source_url, error? }
@@ -29,24 +38,35 @@ const PINS = [
29
38
  repo: "mitre-atlas/atlas-data",
30
39
  local_path_hint: "manifest.json — atlas_version",
31
40
  strip_v_prefix: true,
41
+ github_releases: true,
32
42
  },
33
43
  {
34
44
  pin_name: "attack_version",
35
45
  repo: "mitre-attack/attack-stix-data",
36
46
  local_path_hint: "manifest.json — attack_version",
37
47
  strip_v_prefix: true,
48
+ github_releases: true,
38
49
  },
39
50
  {
51
+ // D3FEND does not tag GitHub releases — currency is tracked via
52
+ // lib/upstream-check.js against the canonical mitre.org endpoint. Listed
53
+ // here for completeness; the GitHub-release check is skipped.
40
54
  pin_name: "d3fend_version",
41
55
  repo: "d3fend/d3fend-data",
42
56
  local_path_hint: "data/d3fend-catalog.json _meta.version",
43
57
  strip_v_prefix: true,
58
+ github_releases: false,
59
+ tracked_via: "lib/upstream-check.js (d3fend.mitre.org)",
44
60
  },
45
61
  {
62
+ // CWE distributes XML from cwe.mitre.org with no tagged GitHub releases —
63
+ // currency is tracked via lib/upstream-check.js. GitHub-release check skipped.
46
64
  pin_name: "cwe_version",
47
65
  repo: "mitre/cwe",
48
66
  local_path_hint: "data/cwe-catalog.json _meta.version",
49
67
  strip_v_prefix: true,
68
+ github_releases: false,
69
+ tracked_via: "lib/upstream-check.js (cwe.mitre.org)",
50
70
  },
51
71
  ];
52
72
 
@@ -113,6 +133,23 @@ async function checkAllPins(ctx) {
113
133
  const out = [];
114
134
  for (const pin of PINS) {
115
135
  const local = normalize(resolveLocalVersion(pin.pin_name, ctx), pin.strip_v_prefix);
136
+ // Pins whose upstream does not publish GitHub releases (D3FEND, CWE) have
137
+ // no release tag to compare against — calling the releases API would always
138
+ // fail and surface a spurious `unreachable`. Report them as tracked
139
+ // elsewhere (drift:null, no error) so the live path checks exactly the same
140
+ // GitHub-release repos as the cache path.
141
+ if (pin.github_releases === false) {
142
+ out.push({
143
+ pin_name: pin.pin_name,
144
+ local_version: local,
145
+ latest_version: null,
146
+ drift: null,
147
+ tracked_elsewhere: true,
148
+ tracked_via: pin.tracked_via || null,
149
+ local_path_hint: pin.local_path_hint,
150
+ });
151
+ continue;
152
+ }
116
153
  const release = await latestGithubRelease(pin.repo);
117
154
  if (release.error) {
118
155
  out.push({
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "worker-pool.js": {
36
36
  "vendored_path": "vendor/blamejs/worker-pool.js",
37
- "vendored_sha256": "fa9814c2d18db221a2dc552cf2a5047467d87a2590cbc9d64b8a0a340e545b55",
37
+ "vendored_sha256": "948549d82e481f241e5babb6bdddd27bf7148fb8ed1c389b9f2f7ad035e4e56b",
38
38
  "upstream_path": "lib/worker-pool.js",
39
39
  "upstream_sha256_at_pin": "262f99e9cc3d4a8f4eba9ad3e28401e8a1f47f78040afa63c9b17eb998437171",
40
40
  "stripped": [
@@ -50,7 +50,8 @@
50
50
  "worker recycle on uncaught error / timeout / exit"
51
51
  ],
52
52
  "exceptd_deltas": [
53
- "scriptPath validator rejects Windows UNC + extended-path prefixes (\\\\?\\, \\\\.\\, \\\\<server>\\) — defense-in-depth against worker-spawn from network shares on win32 platforms"
53
+ "scriptPath validator rejects Windows UNC + extended-path prefixes (\\\\?\\, \\\\.\\, \\\\<server>\\) — defense-in-depth against worker-spawn from network shares on win32 platforms",
54
+ "_onWorkerExit respawn branch: call _maybeResolveDrain() after _drainQueue() so drain() settles when a worker recycles with an empty queue (the no-respawn branch already did; the respawn branch could hang pending drain waiters)."
54
55
  ]
55
56
  },
56
57
  "codepoint-class.js": {
@@ -322,6 +322,12 @@ function create(scriptPath, opts) {
322
322
  if (!terminated && workerSlots.length < size) {
323
323
  try { _spawnWorker(); } catch (_e) { /* already audited */ }
324
324
  _drainQueue();
325
+ // exceptd delta: also settle pending drain() waiters after a respawn.
326
+ // _drainQueue() only schedules queued tasks; if the queue is already
327
+ // empty (a worker recycled with nothing left to do) the pool is now idle
328
+ // and a caller awaiting drain() must be resolved, exactly as the
329
+ // no-respawn branch below does — otherwise drain() hangs forever.
330
+ _maybeResolveDrain();
325
331
  } else {
326
332
  _maybeResolveDrain();
327
333
  }