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