@blamejs/exceptd-skills 0.18.9 → 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.
- package/CHANGELOG.md +24 -0
- package/bin/exceptd.js +197 -118
- package/data/_indexes/_meta.json +3 -3
- package/data/_indexes/frequency.json +2 -2
- package/data/d3fend-catalog.json +6 -6
- package/data/playbooks/identity-sso-compromise.json +2 -2
- package/data/playbooks/sbom.json +1 -1
- package/lib/citation-resolve.js +11 -0
- package/lib/collectors/containers.js +13 -0
- package/lib/cross-ref-api.js +29 -7
- package/lib/cve-regression-watcher.js +47 -15
- package/lib/framework-gap.js +27 -5
- package/lib/gap-detectors.js +8 -3
- package/lib/lint-skills.js +3 -2
- package/lib/playbook-runner.js +60 -5
- package/lib/refresh-external.js +58 -7
- package/lib/refresh-network.js +18 -5
- package/lib/rfc-cli.js +108 -18
- package/lib/schemas/playbook.schema.json +1 -1
- package/lib/scoring.js +31 -1
- package/lib/source-advisories.js +58 -9
- package/lib/ttp-mapper.js +31 -3
- package/lib/upstream-check-cli.js +13 -1
- package/lib/validate-catalog-meta.js +51 -7
- package/lib/validate-cve-catalog.js +10 -0
- package/lib/validate-playbooks.js +19 -1
- package/lib/xml-tokenizer.js +187 -25
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +45 -9
- package/orchestrator/index.js +9 -7
- package/orchestrator/pipeline.js +62 -14
- package/orchestrator/scanner.js +40 -9
- package/package.json +1 -1
- package/sbom.cdx.json +103 -88
- package/scripts/build-indexes.js +21 -3
- package/scripts/builders/section-offsets.js +17 -8
- package/scripts/check-catalog-gap-budget.js +3 -3
- package/scripts/check-codebase-patterns.js +124 -11
- package/scripts/check-sbom-currency.js +69 -3
- package/scripts/check-test-count.js +28 -16
- package/scripts/check-test-subjects.js +127 -0
- package/scripts/check-version-tags.js +24 -5
- package/scripts/predeploy.js +13 -0
- package/scripts/refresh-upstream-catalogs.js +150 -42
- package/scripts/release.js +28 -11
|
@@ -55,10 +55,39 @@ function specRequiredFields(catalogKey) {
|
|
|
55
55
|
return spec.required_context.map((r) => r.field);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
};
|
package/scripts/release.js
CHANGED
|
@@ -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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
543
|
-
|
|
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
|
|
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
|
|