@blamejs/exceptd-skills 0.18.8 → 0.18.11

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 (46) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/exceptd.js +197 -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/cross-ref-api.js +29 -7
  11. package/lib/cve-regression-watcher.js +47 -15
  12. package/lib/framework-gap.js +27 -5
  13. package/lib/gap-detectors.js +8 -3
  14. package/lib/lint-skills.js +3 -2
  15. package/lib/playbook-runner.js +60 -5
  16. package/lib/refresh-external.js +58 -7
  17. package/lib/refresh-network.js +24 -8
  18. package/lib/rfc-cli.js +108 -18
  19. package/lib/schemas/playbook.schema.json +1 -1
  20. package/lib/scoring.js +31 -1
  21. package/lib/source-advisories.js +58 -9
  22. package/lib/ttp-mapper.js +31 -3
  23. package/lib/upstream-check-cli.js +13 -1
  24. package/lib/validate-catalog-meta.js +51 -7
  25. package/lib/validate-cve-catalog.js +10 -0
  26. package/lib/validate-playbooks.js +19 -1
  27. package/lib/xml-tokenizer.js +187 -25
  28. package/manifest.json +53 -53
  29. package/orchestrator/dispatcher.js +45 -9
  30. package/orchestrator/index.js +9 -7
  31. package/orchestrator/pipeline.js +62 -14
  32. package/orchestrator/scanner.js +40 -9
  33. package/package.json +1 -1
  34. package/sbom.cdx.json +105 -90
  35. package/scripts/build-indexes.js +21 -3
  36. package/scripts/builders/section-offsets.js +17 -8
  37. package/scripts/check-catalog-gap-budget.js +3 -3
  38. package/scripts/check-codebase-patterns.js +124 -11
  39. package/scripts/check-sbom-currency.js +69 -3
  40. package/scripts/check-test-count.js +28 -16
  41. package/scripts/check-test-subjects.js +127 -0
  42. package/scripts/check-version-tags.js +24 -5
  43. package/scripts/predeploy.js +13 -0
  44. package/scripts/refresh-upstream-catalogs.js +150 -42
  45. package/scripts/release.js +28 -11
  46. package/scripts/validate-vendor-online.js +12 -9
@@ -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
@@ -293,16 +342,25 @@ async function refreshRfc({ dry = false } = {}) {
293
342
  existing.add(id);
294
343
  added++;
295
344
  }
296
- if (cat._meta) {
297
- cat._meta.last_updated = TODAY;
298
- cat._meta.last_threat_review = TODAY;
299
- }
345
+ // Only restamp _meta + write when something actually changed. A genuine
346
+ // no-op leaves the file byte-identical so the daily refresh doesn't emit a
347
+ // spurious _meta-only diff (and so the freshness gates stay honest — a
348
+ // wall-clock restamp on an unchanged catalog masks real staleness).
349
+ const changed = added > 0 || backfilledCount > 0 || statusBumped > 0;
300
350
  if (dry) {
301
351
  console.log(`[refresh-upstream:rfc] DRY-RUN: +${added} new, ${backfilledCount} backfilled, ${statusBumped} status bumps.`);
302
352
  return { added, statusBumped, backfilled: backfilledCount };
303
353
  }
304
- writeCatalog("rfc-references.json", cat);
305
- console.log(`[ok] rfc-references.json: +${added} entries, ${backfilledCount} backfilled, ${statusBumped} status bumps (now ${existing.size} total)`);
354
+ if (changed) {
355
+ if (cat._meta) {
356
+ cat._meta.last_updated = TODAY;
357
+ cat._meta.last_threat_review = TODAY;
358
+ }
359
+ _writeCatalog("rfc-references.json", cat);
360
+ console.log(`[ok] rfc-references.json: +${added} entries, ${backfilledCount} backfilled, ${statusBumped} status bumps (now ${existing.size} total)`);
361
+ } else {
362
+ console.log("[ok] rfc-references.json: no upstream changes — file unchanged");
363
+ }
306
364
  return { added, statusBumped, backfilled: backfilledCount };
307
365
  }
308
366
 
@@ -392,9 +450,12 @@ function backfillAttack(cur, fresh) {
392
450
  return touched;
393
451
  }
394
452
 
395
- async function refreshAttack({ dry = false, cap = Infinity } = {}) {
453
+ async function refreshAttack({ dry = false, cap = Infinity, _deps = {} } = {}) {
454
+ const _fetchUrl = _deps.fetchUrl || fetchUrl;
455
+ const _loadCatalog = _deps.loadCatalog || loadCatalog;
456
+ const _writeCatalog = _deps.writeCatalog || writeCatalog;
396
457
  console.log("[refresh-upstream:attack] fetching MITRE ATT&CK STIX...");
397
- const body = await fetchUrl(ATTACK_SRC);
458
+ const body = await _fetchUrl(ATTACK_SRC);
398
459
  const stix = JSON.parse(body);
399
460
  // For NEW adds: live techniques only (skip revoked / deprecated).
400
461
  // For BACKFILL on existing rows: include revoked too — an operator-
@@ -410,7 +471,7 @@ async function refreshAttack({ dry = false, cap = Infinity } = {}) {
410
471
  );
411
472
  console.log(`[refresh-upstream:attack] STIX live techniques: ${liveTechs.length} (+ ${backfillTechs.length - liveTechs.length} revoked/deprecated available for backfill on existing rows)`);
412
473
  const techs = liveTechs;
413
- const local = loadCatalog("attack-techniques.json");
474
+ const local = _loadCatalog("attack-techniques.json");
414
475
  const existing = new Set(Object.keys(local).filter((k) => k !== "_meta"));
415
476
  techs.sort((a, b) => {
416
477
  const aSub = a.x_mitre_is_subtechnique ? 1 : 0;
@@ -448,9 +509,14 @@ async function refreshAttack({ dry = false, cap = Infinity } = {}) {
448
509
  added++;
449
510
  }
450
511
  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)`);
512
+ const changed = added > 0 || backfilled > 0;
513
+ if (changed) {
514
+ if (local._meta) { local._meta.last_updated = TODAY; local._meta.last_threat_review = TODAY; }
515
+ _writeCatalog("attack-techniques.json", local);
516
+ console.log(`[ok] attack-techniques.json: +${added} entries, ${backfilled} context backfills (now ${existing.size} total)`);
517
+ } else {
518
+ console.log("[ok] attack-techniques.json: no upstream changes — file unchanged");
519
+ }
454
520
  return { added, backfilled };
455
521
  }
456
522
 
@@ -472,15 +538,18 @@ const ICS_TACTIC_NAME = {
472
538
  "impact": "Impact (ICS)"
473
539
  };
474
540
 
475
- async function refreshIcsAttack({ dry = false, cap = Infinity } = {}) {
541
+ async function refreshIcsAttack({ dry = false, cap = Infinity, _deps = {} } = {}) {
542
+ const _fetchUrl = _deps.fetchUrl || fetchUrl;
543
+ const _loadCatalog = _deps.loadCatalog || loadCatalog;
544
+ const _writeCatalog = _deps.writeCatalog || writeCatalog;
476
545
  console.log("[refresh-upstream:ics-attack] fetching MITRE ICS-attack STIX...");
477
- const body = await fetchUrl(ICS_ATTACK_SRC);
546
+ const body = await _fetchUrl(ICS_ATTACK_SRC);
478
547
  const stix = JSON.parse(body);
479
548
  const techs = (stix.objects || []).filter(
480
549
  (o) => o.type === "attack-pattern" && !o.revoked && !o.x_mitre_deprecated
481
550
  );
482
551
  console.log(`[refresh-upstream:ics-attack] STIX live ICS techniques: ${techs.length}`);
483
- const local = loadCatalog("attack-techniques.json");
552
+ const local = _loadCatalog("attack-techniques.json");
484
553
  const existing = new Set(Object.keys(local).filter((k) => k !== "_meta"));
485
554
  let added = 0, backfilled = 0;
486
555
  for (const t of techs) {
@@ -519,9 +588,14 @@ async function refreshIcsAttack({ dry = false, cap = Infinity } = {}) {
519
588
  added++;
520
589
  }
521
590
  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)`);
591
+ const changed = added > 0 || backfilled > 0;
592
+ if (changed) {
593
+ if (local._meta) { local._meta.last_updated = TODAY; local._meta.last_threat_review = TODAY; }
594
+ _writeCatalog("attack-techniques.json", local);
595
+ console.log(`[ok] attack-techniques.json: +${added} ICS entries, ${backfilled} backfills (now ${existing.size} total)`);
596
+ } else {
597
+ console.log("[ok] attack-techniques.json: no upstream ICS changes — file unchanged");
598
+ }
525
599
  return { added, backfilled };
526
600
  }
527
601
 
@@ -591,9 +665,12 @@ function backfillAtlas(cur, fresh) {
591
665
  return touched;
592
666
  }
593
667
 
594
- async function refreshAtlas({ dry = false } = {}) {
668
+ async function refreshAtlas({ dry = false, _deps = {} } = {}) {
669
+ const _fetchUrl = _deps.fetchUrl || fetchUrl;
670
+ const _loadCatalog = _deps.loadCatalog || loadCatalog;
671
+ const _writeCatalog = _deps.writeCatalog || writeCatalog;
595
672
  console.log("[refresh-upstream:atlas] fetching MITRE ATLAS STIX...");
596
- const body = await fetchUrl(ATLAS_SRC);
673
+ const body = await _fetchUrl(ATLAS_SRC);
597
674
  const stix = JSON.parse(body);
598
675
  const techs = (stix.objects || []).filter(
599
676
  (o) => o.type === "attack-pattern" && !o.revoked && !o.x_mitre_deprecated
@@ -609,7 +686,7 @@ async function refreshAtlas({ dry = false } = {}) {
609
686
  atlasVersion = o.x_mitre_version; break;
610
687
  }
611
688
  }
612
- const local = loadCatalog("atlas-ttps.json");
689
+ const local = _loadCatalog("atlas-ttps.json");
613
690
  const existing = new Set(Object.keys(local).filter((k) => k !== "_meta"));
614
691
  aml.sort((a, b) => {
615
692
  const aSub = a.x_mitre_is_subtechnique ? 1 : 0;
@@ -635,13 +712,22 @@ async function refreshAtlas({ dry = false } = {}) {
635
712
  added++;
636
713
  }
637
714
  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;
715
+ // A newly-detected ATLAS matrix version that differs from the recorded one
716
+ // is itself a change (the catalog should bump atlas_version + last_updated
717
+ // together), independent of any added/backfilled rows.
718
+ const versionChanged = !!(atlasVersion && local._meta && local._meta.atlas_version !== atlasVersion);
719
+ const changed = added > 0 || backfilled > 0 || versionChanged;
720
+ if (changed) {
721
+ if (local._meta) {
722
+ if (atlasVersion) local._meta.atlas_version = atlasVersion;
723
+ local._meta.last_updated = TODAY;
724
+ local._meta.last_threat_review = TODAY;
725
+ }
726
+ _writeCatalog("atlas-ttps.json", local);
727
+ console.log(`[ok] atlas-ttps.json: +${added} entries, ${backfilled} backfills (now ${existing.size} total${atlasVersion ? `, ATLAS v${atlasVersion}` : ""})`);
728
+ } else {
729
+ console.log("[ok] atlas-ttps.json: no upstream changes — file unchanged");
642
730
  }
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
731
  return { added, backfilled, atlasVersion };
646
732
  }
647
733
 
@@ -672,8 +758,18 @@ function d3fendIdList(t, field) {
672
758
  return arr.map((x) => (x && x["@id"]) ? String(x["@id"]).replace(/^d3f:/, "") : null).filter(Boolean);
673
759
  }
674
760
 
761
+ // Strip a trailing period from an OWL d3fend-id: a few upstream artifact ids
762
+ // (e.g. "D3A-C4.") carry a spurious terminal dot that no id token regex can
763
+ // round-trip, leaving the entry unmatchable by the orphan/cross-ref scanners.
764
+ // No legitimate d3fend technique id ends in a period. The SAME normalization
765
+ // must key the catalog (existing.has / local[id]) as well as the entry payload,
766
+ // or a refresh re-adds the period-keyed row every run (duplicate / churn).
767
+ function normD3fendId(rawId) {
768
+ return typeof rawId === "string" ? rawId.replace(/\.$/, "") : rawId;
769
+ }
770
+
675
771
  function d3fendEntryFromOwl(t) {
676
- const id = t["d3f:d3fend-id"];
772
+ const id = normD3fendId(t["d3f:d3fend-id"]);
677
773
  const labelRaw = t["rdfs:label"];
678
774
  const name = Array.isArray(labelRaw)
679
775
  ? (typeof labelRaw[0] === "object" ? labelRaw[0]["@value"] : labelRaw[0])
@@ -736,19 +832,22 @@ function backfillD3fend(cur, fresh) {
736
832
  return touched;
737
833
  }
738
834
 
739
- async function refreshD3fend({ dry = false, cap = Infinity } = {}) {
835
+ async function refreshD3fend({ dry = false, cap = Infinity, _deps = {} } = {}) {
836
+ const _fetchUrl = _deps.fetchUrl || fetchUrl;
837
+ const _loadCatalog = _deps.loadCatalog || loadCatalog;
838
+ const _writeCatalog = _deps.writeCatalog || writeCatalog;
740
839
  console.log("[refresh-upstream:d3fend] fetching MITRE D3FEND ontology...");
741
- const body = await fetchUrl(D3FEND_SRC);
840
+ const body = await _fetchUrl(D3FEND_SRC);
742
841
  const j = JSON.parse(body);
743
842
  const graph = j["@graph"] || [];
744
843
  const techs = graph.filter((o) => o["@id"] && o["d3f:d3fend-id"] && o["rdfs:label"]);
745
844
  console.log(`[refresh-upstream:d3fend] ontology techniques: ${techs.length}`);
746
- const local = loadCatalog("d3fend-catalog.json");
845
+ const local = _loadCatalog("d3fend-catalog.json");
747
846
  const existing = new Set(Object.keys(local).filter((k) => k !== "_meta"));
748
847
  techs.sort((a, b) => String(a["d3f:d3fend-id"]).localeCompare(String(b["d3f:d3fend-id"])));
749
848
  let added = 0, backfilled = 0;
750
849
  for (const t of techs) {
751
- const id = t["d3f:d3fend-id"];
850
+ const id = normD3fendId(t["d3f:d3fend-id"]);
752
851
  if (existing.has(id)) {
753
852
  const fresh = d3fendEntryFromOwl(t);
754
853
  const cur = local[id];
@@ -761,9 +860,14 @@ async function refreshD3fend({ dry = false, cap = Infinity } = {}) {
761
860
  added++;
762
861
  }
763
862
  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)`);
863
+ const changed = added > 0 || backfilled > 0;
864
+ if (changed) {
865
+ if (local._meta) { local._meta.last_updated = TODAY; local._meta.last_threat_review = TODAY; }
866
+ _writeCatalog("d3fend-catalog.json", local);
867
+ console.log(`[ok] d3fend-catalog.json: +${added} entries, ${backfilled} backfills (now ${existing.size} total)`);
868
+ } else {
869
+ console.log("[ok] d3fend-catalog.json: no upstream changes — file unchanged");
870
+ }
767
871
  return { added, backfilled };
768
872
  }
769
873
 
@@ -820,5 +924,9 @@ module.exports = {
820
924
  refreshAtlas,
821
925
  refreshD3fend,
822
926
  SOURCES,
823
- runCli
927
+ runCli,
928
+ // Exported for regression tests: fetchUrl's status/redirect handling and
929
+ // writeCatalog's atomicity are load-bearing fail-closed properties.
930
+ fetchUrl,
931
+ writeCatalog
824
932
  };
@@ -264,14 +264,14 @@ function cmdPrepare(opts) {
264
264
  // bootstrap-mode test enforces would otherwise fail at gates time.
265
265
  var top = _changelogTopVersion();
266
266
  if (top !== next) {
267
- console.error("");
268
- console.error("release: CHANGELOG.md top heading is '## " + top + "', expected '## " + next + "'.");
269
- console.error("Write the " + next + " entry first (terse, behavior-change framed, no internal");
270
- console.error("narrative), then re-run prepare. Example heading:");
271
- console.error("");
272
- console.error(" ## " + next + " — <YYYY-MM-DD>");
273
- console.error("");
274
- process.exit(2);
267
+ // Throw (not process.exit) so a stdout write earlier in this phase can't be
268
+ // truncated when piped, and so `release all` aborts the whole sequence here
269
+ // rather than continuing past a failed prepare. Matches the throw-style
270
+ // guards above (clean-tree / on-main); the dispatcher maps it to exit 1.
271
+ throw new Error(
272
+ "CHANGELOG.md top heading is '## " + top + "', expected '## " + next + "'. " +
273
+ "Write the " + next + " entry first (terse, behavior-change framed, no internal " +
274
+ "narrative), then re-run prepare. Example heading: ## " + next + " — <YYYY-MM-DD>");
275
275
  }
276
276
 
277
277
  // The `## <next>` heading exists; confirm the section extracts cleanly and
@@ -539,8 +539,25 @@ function cmdRelease() {
539
539
  var next = _readJsonVersion("package.json");
540
540
 
541
541
  _section("release workflow");
542
- var runId = _capture("gh", ["run", "list", "--workflow=release.yml", "--limit", "1",
543
- "--json", "databaseId", "--jq", ".[0].databaseId"]).stdout;
542
+ // Select the release.yml run created by THIS tag push — not merely the
543
+ // newest run. gh exposes the tag ref as headBranch for tag-triggered runs;
544
+ // event=="push" excludes workflow_dispatch runs. Filtering by headBranch==tag
545
+ // uniquely identifies this run even when a prior tag points at the same
546
+ // commit (so it's preferable to headSha matching, and needs no extra git
547
+ // call). Bounded retries cover the few-seconds window GitHub needs to
548
+ // register the run after the tag push.
549
+ var tag = "v" + next;
550
+ var runId = "";
551
+ for (var _i = 0; _i < 30 && !runId; _i++) {
552
+ runId = _capture("gh", ["run", "list", "--workflow=release.yml",
553
+ "--event=push", "--json", "databaseId,headBranch,event",
554
+ "--jq", '[.[] | select(.headBranch=="' + tag + '")] | sort_by(.databaseId) | last | .databaseId']).stdout;
555
+ if (!runId && _i < 29) {
556
+ // Short bounded wait between polls — the run usually appears within
557
+ // a few seconds of the tag push.
558
+ _spawn(process.execPath, ["-e", "setTimeout(function(){},2000)"], { stdio: "ignore" });
559
+ }
560
+ }
544
561
  if (runId) {
545
562
  _run("gh", ["run", "watch", runId, "--exit-status"], { allowFail: true });
546
563
  var concl = _capture("gh", ["run", "view", runId, "--json", "conclusion", "--jq", ".conclusion"]).stdout;
@@ -553,7 +570,7 @@ function cmdRelease() {
553
570
  }
554
571
  _ok("release.yml: success");
555
572
  } else {
556
- throw new Error("release: no release.yml run found for the tag — the publish workflow has not started; " +
573
+ throw new Error("release: no release.yml run found for " + tag + " — the publish workflow has not started; " +
557
574
  "confirm the tag was pushed and the workflow fired before treating the release as done");
558
575
  }
559
576
 
@@ -64,17 +64,20 @@ function rawUrlForPin(sourceRepo, commit, upstreamPath) {
64
64
  /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/
65
65
  );
66
66
  if (!m) return null;
67
- const [, owner, repo] = m;
67
+ // Validate every file-derived component, then build the URL from the SAME
68
+ // validated bindings (cleanOwner/cleanRepo/cleanCommit/cleanPath) so a tampered
69
+ // _PROVENANCE.json cannot steer the request and no unguarded metadata value
70
+ // reaches the fetch: owner/repo are GitHub-name-shaped, the commit is a hex git
71
+ // object id, and the path is a relative, traversal-free repo path. With the host
72
+ // a string literal, the fetch destination is fully constrained.
73
+ const cleanOwner = m[1];
74
+ const cleanRepo = m[2];
75
+ const cleanCommit = String(commit || "");
68
76
  const cleanPath = String(upstreamPath || "").replace(/^\/+/, "");
69
- // Validate every file-derived component before embedding it in the fetch URL,
70
- // so a tampered _PROVENANCE.json cannot steer the request: owner/repo are
71
- // GitHub-name-shaped, the commit is a hex git object id, and the path is a
72
- // relative, traversal-free repo path. With the host a string literal, the
73
- // fetch destination is fully constrained — not metadata-controlled.
74
- if (!/^[A-Za-z0-9._-]+$/.test(owner) || !/^[A-Za-z0-9._-]+$/.test(repo)) return null;
75
- if (!/^[0-9a-fA-F]{7,64}$/.test(String(commit || ""))) return null;
77
+ if (!/^[A-Za-z0-9._-]+$/.test(cleanOwner) || !/^[A-Za-z0-9._-]+$/.test(cleanRepo)) return null;
78
+ if (!/^[0-9a-fA-F]{7,64}$/.test(cleanCommit)) return null;
76
79
  if (!/^[A-Za-z0-9._/-]+$/.test(cleanPath) || cleanPath.includes("..")) return null;
77
- return `https://raw.githubusercontent.com/${owner}/${repo}/${commit}/${cleanPath}`;
80
+ return `https://raw.githubusercontent.com/${cleanOwner}/${cleanRepo}/${cleanCommit}/${cleanPath}`;
78
81
  }
79
82
 
80
83
  const MAX_REDIRECTS = 5;