@blamejs/exceptd-skills 0.19.33 → 0.19.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/bin/exceptd.js +895 -2828
- package/data/_indexes/_meta.json +2 -2
- package/lib/auto-discovery.js +56 -286
- package/lib/canonical-eq.js +7 -40
- package/lib/citation-resolve.js +22 -70
- package/lib/collectors/ai-api.js +170 -76
- package/lib/collectors/cicd-pipeline-compromise.js +113 -136
- package/lib/collectors/citation-hygiene.js +72 -210
- package/lib/collectors/containers.js +41 -130
- package/lib/collectors/cred-stores.js +31 -115
- package/lib/collectors/crypto-codebase.js +55 -138
- package/lib/collectors/crypto.js +24 -54
- package/lib/collectors/hardening.js +20 -78
- package/lib/collectors/kernel.js +16 -46
- package/lib/collectors/library-author.js +198 -211
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +130 -118
- package/lib/collectors/scan-excludes.js +33 -139
- package/lib/collectors/secrets.js +62 -178
- package/lib/cross-ref-api.js +39 -123
- package/lib/currency-severity.js +8 -27
- package/lib/cve-batch.js +13 -21
- package/lib/cve-cli.js +13 -20
- package/lib/cve-curation.js +72 -239
- package/lib/cve-regression-watcher.js +29 -155
- package/lib/cvss.js +13 -54
- package/lib/doctor-bucketing.js +3 -19
- package/lib/exit-codes.js +10 -42
- package/lib/flag-suggest.js +7 -25
- package/lib/framework-gap.js +39 -113
- package/lib/gap-detectors.js +37 -159
- package/lib/id-validation.js +9 -30
- package/lib/job-queue.js +13 -36
- package/lib/lint-skills.js +88 -236
- package/lib/playbook-runner.js +759 -2107
- package/lib/prefetch.js +101 -376
- package/lib/refresh-external.js +199 -633
- package/lib/refresh-network.js +78 -311
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +85 -146
- package/lib/sign.js +43 -229
- package/lib/source-advisories.js +43 -194
- package/lib/source-ghsa.js +37 -120
- package/lib/source-osv.js +94 -266
- package/lib/ttp-mapper.js +28 -27
- package/lib/upstream-check-cli.js +36 -29
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +52 -121
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +78 -286
- package/lib/validate-vendor.js +16 -49
- package/lib/verify.js +56 -286
- package/lib/version-pins.js +5 -34
- package/lib/worker-pool.js +11 -30
- package/lib/xml-tokenizer.js +47 -152
- package/manifest.json +53 -53
- package/orchestrator/dispatcher.js +17 -68
- package/orchestrator/event-bus.js +11 -74
- package/orchestrator/index.js +138 -413
- package/orchestrator/pipeline.js +28 -85
- package/orchestrator/scanner.js +34 -138
- package/orchestrator/scheduler.js +20 -84
- package/package.json +1 -1
- package/sbom.cdx.json +242 -242
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +29 -28
- package/scripts/backfill-theater-test.js +7 -64
- package/scripts/bootstrap.js +12 -44
- package/scripts/build-indexes.js +40 -154
- package/scripts/builders/activity-feed.js +4 -14
- package/scripts/builders/catalog-summaries.js +3 -10
- package/scripts/builders/currency.js +7 -20
- package/scripts/builders/cwe-chains.js +7 -30
- package/scripts/builders/did-ladders.js +6 -13
- package/scripts/builders/frequency.js +5 -19
- package/scripts/builders/jurisdiction-clocks.js +6 -25
- package/scripts/builders/recipes.js +6 -14
- package/scripts/builders/section-offsets.js +13 -51
- package/scripts/builders/stale-content.js +7 -28
- package/scripts/builders/summary-cards.js +8 -29
- package/scripts/builders/theater-fingerprints.js +21 -31
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +26 -57
- package/scripts/check-catalog-gap-budget.js +15 -32
- package/scripts/check-changelog-extract.js +18 -48
- package/scripts/check-codebase-patterns-currency.js +6 -22
- package/scripts/check-codebase-patterns.js +63 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +62 -81
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +83 -198
- package/scripts/check-test-subjects.js +21 -62
- package/scripts/check-ttp-references.js +14 -38
- package/scripts/check-ttp-upstream.js +8 -40
- package/scripts/check-version-bump.js +9 -61
- package/scripts/check-version-tags.js +20 -121
- package/scripts/predeploy.js +38 -184
- package/scripts/refresh-manifest-snapshot.js +16 -38
- package/scripts/refresh-mitre-atlas.js +7 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +7 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +7 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +63 -148
- package/scripts/release.js +69 -234
- package/scripts/run-e2e-scenarios.js +26 -73
- package/scripts/sync-manifest-metadata.js +10 -34
- package/scripts/sync-package-description.js +8 -17
- package/scripts/validate-vendor-online.js +13 -44
- package/scripts/verify-shipped-tarball.js +35 -141
|
@@ -1,27 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Bidirectional test↔subject gate. Every tests/<x>.test.js must name a real
|
|
5
|
+
* subject and every subject must have a test. Subjects are derived from the
|
|
6
|
+
* codebase, not a hand-maintained list.
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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).
|
|
8
|
+
* A FORWARD violation is a test file naming no subject; a REVERSE violation is
|
|
9
|
+
* a subject with no test. --worklist writes the machine-readable list to
|
|
10
|
+
* stdout; either violation exits non-zero.
|
|
25
11
|
*/
|
|
26
12
|
const fs = require("node:fs");
|
|
27
13
|
const path = require("node:path");
|
|
@@ -42,22 +28,16 @@ function deriveSubjects() {
|
|
|
42
28
|
if (e.isDirectory()) { walkSrc(rel); continue; }
|
|
43
29
|
if (!e.name.endsWith(".js")) continue;
|
|
44
30
|
const base = e.name.replace(/\.js$/, "");
|
|
45
|
-
// index
|
|
46
|
-
// covers it, so treat the bare "index" basename as an alias, not a
|
|
47
|
-
// separately reverse-required module.
|
|
31
|
+
// A bare "index" is an alias, not a reverse-required module.
|
|
48
32
|
add(base, (base === "index" ? "alias:" : "module:") + rel);
|
|
49
33
|
const parent = path.basename(path.dirname(rel));
|
|
50
|
-
// parent-prefixed name (collectors-x
|
|
51
|
-
//
|
|
52
|
-
// canonical <base>.test.js already satisfies coverage, so don't double-
|
|
53
|
-
// count the alias as its own reverse gap.
|
|
34
|
+
// A parent-prefixed name (collectors-x) is an alias of the canonical
|
|
35
|
+
// basename: a valid test target, never its own reverse gap.
|
|
54
36
|
if (!["lib", "scripts", "orchestrator", "bin"].includes(parent)) add(parent + "-" + base, "alias:" + rel);
|
|
55
37
|
const txt = read(rel);
|
|
56
38
|
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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// every export name after it (the brace-truncation class), under-deriving
|
|
60
|
-
// subjects so a real export silently has no required test.
|
|
39
|
+
// Brace-balanced scan, not a non-greedy /\{([\s\S]*?)\}/ — that stops at
|
|
40
|
+
// the first nested `}` and drops every export after it.
|
|
61
41
|
const expAt = txt.search(/module\.exports\s*=\s*\{/);
|
|
62
42
|
if (expAt >= 0) {
|
|
63
43
|
const open = txt.indexOf("{", expAt);
|
|
@@ -70,43 +50,28 @@ function deriveSubjects() {
|
|
|
70
50
|
["lib", "orchestrator", "scripts", "bin", "sources/validators"].forEach(walkSrc);
|
|
71
51
|
add("orchestrator", "module:orchestrator/index.js");
|
|
72
52
|
add("cli", "module:bin/exceptd.js");
|
|
73
|
-
// Vendored
|
|
74
|
-
// reverse-required — we don't force a dedicated test per vendored file.
|
|
53
|
+
// Vendored modules are valid test subjects but not reverse-required.
|
|
75
54
|
(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
55
|
|
|
77
|
-
//
|
|
78
|
-
// (verb: () => path.join(...)) that bin/exceptd.js uses for most subcommands.
|
|
56
|
+
// Both dispatch forms bin/exceptd.js uses: switch-case and the `verb: () =>` table.
|
|
79
57
|
const cliSrc = read("bin/exceptd.js");
|
|
80
58
|
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
59
|
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
60
|
|
|
83
|
-
// data catalog files
|
|
84
61
|
for (const e of ls("data")) if (e.isFile() && e.name.endsWith(".json")) add(e.name.replace(/\.json$/, ""), "data");
|
|
85
|
-
//
|
|
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.
|
|
62
|
+
// A zero-subject derivation throws rather than passing reverse coverage empty.
|
|
90
63
|
let cveDerived = 0;
|
|
91
64
|
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
65
|
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
66
|
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
|
-
//
|
|
95
|
-
// unreadable / empty data/playbooks must NOT silently derive zero playbook
|
|
96
|
-
// subjects — that lets the reverse-coverage gate pass with no playbook
|
|
97
|
-
// coverage (the same absent-input false-pass class). Fail loud instead.
|
|
67
|
+
// Same floor for playbooks.
|
|
98
68
|
let pbDerived = 0;
|
|
99
69
|
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"); pbDerived++; }
|
|
100
70
|
if (pbDerived === 0) throw new Error("check-test-subjects: data/playbooks/ yielded zero playbooks — refusing to let reverse coverage pass with no playbook coverage.");
|
|
101
|
-
// workflows
|
|
102
71
|
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"); }
|
|
103
72
|
|
|
104
|
-
// Repo
|
|
105
|
-
//
|
|
106
|
-
// that pins one of these artifacts (its content, counts, or cross-references)
|
|
107
|
-
// is named after a durable subject, not a release — so these are valid test
|
|
108
|
-
// targets. Kind is not module/cve/playbook, so they are NOT reverse-required
|
|
109
|
-
// (we don't force a dedicated test per doc file).
|
|
73
|
+
// Repo artifacts — valid test targets, but their kind is not
|
|
74
|
+
// module/cve/playbook, so none of them is reverse-required.
|
|
110
75
|
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"]) {
|
|
111
76
|
add(f.replace(/\.[^.]*$/, "").toLowerCase().replace(/_/g, "-"), "repo:" + f);
|
|
112
77
|
}
|
|
@@ -116,12 +81,8 @@ function deriveSubjects() {
|
|
|
116
81
|
add("playbooks", "aggregate:data/playbooks");
|
|
117
82
|
add("workflows", "aggregate:.github/workflows");
|
|
118
83
|
add("governance", "repo:governance-files"); // LICENSE/NOTICE/FUNDING/CoC/gitignore/gitleaks presence + integrity
|
|
119
|
-
// Module
|
|
120
|
-
//
|
|
121
|
-
// tree would derive zero reverse-required module subjects and let the gate
|
|
122
|
-
// pass with no module coverage (the absent-input false-pass class, same as
|
|
123
|
-
// the CVE/playbook guards). The repo always has dozens of modules; zero is an
|
|
124
|
-
// anomaly. Fail loud.
|
|
84
|
+
// Module floor: ls() returns [] on a read failure, so an unreadable source
|
|
85
|
+
// tree would otherwise pass the gate with no module coverage.
|
|
125
86
|
let moduleCount = 0;
|
|
126
87
|
for (const kind of subjects.values()) if (typeof kind === "string" && kind.startsWith("module:")) moduleCount++;
|
|
127
88
|
if (moduleCount === 0) throw new Error("check-test-subjects: zero source-module subjects derived (lib/orchestrator/scripts/bin unreadable?) — refusing to let reverse coverage pass with no module coverage.");
|
|
@@ -142,10 +103,8 @@ function run() {
|
|
|
142
103
|
for (const t of testFiles) if (!subjects.has(t.toLowerCase())) forward.push({ file: "tests/" + t + ".test.js", suggested: suggest(t) });
|
|
143
104
|
const reverse = [];
|
|
144
105
|
for (const [s, kind] of subjects) if (!testSet.has(s)) reverse.push({ subject: s, kind });
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// not reverse-required). The exit-code logic gates on THIS, consistently
|
|
148
|
-
// across --worklist and the human/predeploy paths.
|
|
106
|
+
// Only module, cve and playbook subjects must have a test; the rest are valid
|
|
107
|
+
// targets, not requirements. Both output paths take their exit code from this.
|
|
149
108
|
const reverseRequired = reverse.filter((x) => x.kind.startsWith("module:") || x.kind.startsWith("cve-primitive") || x.kind.startsWith("playbook-primitive"));
|
|
150
109
|
return { subjects: subjects.size, forward, reverse, reverseRequired };
|
|
151
110
|
}
|
|
@@ -2,30 +2,14 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* TTP reference-integrity gate.
|
|
5
|
+
* TTP reference-integrity gate. data/attack-techniques.json and
|
|
6
|
+
* data/atlas-ttps.json are the pinned copies of ATT&CK and ATLAS; every other file
|
|
7
|
+
* naming a technique refers into them, and this proves those references resolve.
|
|
8
|
+
* Resolution runs against the pins, not against MITRE:
|
|
9
|
+
* scripts/check-ttp-upstream.js is the network check on whether the pins
|
|
10
|
+
* themselves are current, and it cannot block a release. This one can.
|
|
6
11
|
*
|
|
7
|
-
*
|
|
8
|
-
* ATT&CK and ATLAS. Every other file that names a technique — countermeasure
|
|
9
|
-
* maps, DLP controls, playbooks, skill bodies, CVE entries — is referring INTO
|
|
10
|
-
* those two catalogs. This gate proves those references resolve.
|
|
11
|
-
*
|
|
12
|
-
* The failure it exists for: MITRE retires and renumbers techniques between
|
|
13
|
-
* releases. When a pin is bumped, the two source catalogs get remapped, but
|
|
14
|
-
* references living in other files are easy to miss — nothing dereferences them
|
|
15
|
-
* at runtime, so a stale id keeps rendering in operator output as if it were
|
|
16
|
-
* current. It points at a MITRE page that no longer resolves, and any control
|
|
17
|
-
* claiming to counter it is now mapped to nothing (AGENTS.md Hard Rule #4: no
|
|
18
|
-
* orphaned controls). A bumped pin left exactly this residue in the D3FEND and
|
|
19
|
-
* DLP maps, invisible to every other gate.
|
|
20
|
-
*
|
|
21
|
-
* The check is offline: it resolves references against the pinned catalogs, not
|
|
22
|
-
* against MITRE. That is deliberate — scripts/check-ttp-upstream.js is the
|
|
23
|
-
* network check that asks whether the PINS are current, and it cannot block a
|
|
24
|
-
* release because it needs connectivity. This one can block, because a
|
|
25
|
-
* reference that does not resolve against the pin we ship is broken no matter
|
|
26
|
-
* what upstream says.
|
|
27
|
-
*
|
|
28
|
-
* Exit codes: 0 all references resolve, 1 unresolved references found.
|
|
12
|
+
* Exit 0 when every reference resolves, 1 when any does not.
|
|
29
13
|
*/
|
|
30
14
|
|
|
31
15
|
const fs = require("node:fs");
|
|
@@ -34,11 +18,10 @@ const path = require("node:path");
|
|
|
34
18
|
const ROOT = path.resolve(__dirname, "..");
|
|
35
19
|
|
|
36
20
|
/**
|
|
37
|
-
* ATT&CK ids are TNNNN[.NNN]; ATLAS ids are AML.TNNNN[.NNN].
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* filenames, where the dot is not a legal separator.
|
|
21
|
+
* ATT&CK ids are TNNNN[.NNN]; ATLAS ids are AML.TNNNN[.NNN]. Without the
|
|
22
|
+
* lookbehind the ATT&CK alternative matches the "T0017" inside "AML.T0017" and
|
|
23
|
+
* reports a phantom bare-ATT&CK reference for every ATLAS id. It covers the
|
|
24
|
+
* hyphen too, since ATLAS ids also appear in filenames.
|
|
42
25
|
*/
|
|
43
26
|
const TTP_PATTERN = /\bAML\.T\d{4}(?:\.\d{3})?\b|(?<!AML[.-])\bT\d{4}(?:\.\d{3})?\b/g;
|
|
44
27
|
|
|
@@ -50,17 +33,10 @@ const SEARCH_EXTS = new Set([".json", ".md", ".js"]);
|
|
|
50
33
|
const SKIP_DIRS = new Set(["node_modules", "_indexes", "vendor", ".git"]);
|
|
51
34
|
|
|
52
35
|
/**
|
|
53
|
-
* Tokens that match the pattern without
|
|
54
|
-
*
|
|
55
|
-
* eventually gets parked here to make the gate green.
|
|
36
|
+
* Tokens that match the pattern without referencing a technique. Every entry needs
|
|
37
|
+
* a reason: an unexplained allowlist is how a real stale id gets parked here.
|
|
56
38
|
*/
|
|
57
|
-
const NOT_REFERENCES = [
|
|
58
|
-
{
|
|
59
|
-
id: "T1234",
|
|
60
|
-
file: "lib/gap-detectors.js",
|
|
61
|
-
why: "placeholder in a comment describing the reference-extraction pattern itself, not a claim about a technique",
|
|
62
|
-
},
|
|
63
|
-
];
|
|
39
|
+
const NOT_REFERENCES = [];
|
|
64
40
|
|
|
65
41
|
function loadKnownIds() {
|
|
66
42
|
const known = new Set();
|
|
@@ -2,31 +2,11 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
* pinned upstream release.
|
|
5
|
+
* Validates every shipped TTP id in data/attack-techniques.json and
|
|
6
|
+
* data/atlas-ttps.json against the pinned upstream MITRE release. A network
|
|
7
|
+
* check, so it runs in the refresh workflow rather than in offline predeploy.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
* data/atlas-ttps.json are curated mirrors of MITRE releases, and nothing
|
|
10
|
-
* checked that the ids in them are ids MITRE actually publishes. The ATLAS
|
|
11
|
-
* 2026.07 / ATT&CK 19.2 pin bump found eleven that were not: five ATT&CK
|
|
12
|
-
* techniques revoked upstream — already revoked in 19.1, the version pinned at
|
|
13
|
-
* the time, so they had been shipping revoked for a full release cycle — and
|
|
14
|
-
* six that appear in no upstream domain at any version. Every one of them was
|
|
15
|
-
* reachable by a skill telling an operator to map a finding to it, which is
|
|
16
|
-
* exactly what Hard Rule #4 (no orphaned controls — every control maps to a
|
|
17
|
-
* real TTP) exists to prevent.
|
|
18
|
-
*
|
|
19
|
-
* This is a NETWORK check, so it runs in the refresh workflow beside the pin
|
|
20
|
-
* checker rather than in predeploy, which is offline. Report-only: it prints
|
|
21
|
-
* findings and exits non-zero, leaving the decision to a maintainer, because a
|
|
22
|
-
* revocation needs a judgement call about the successor rather than an
|
|
23
|
-
* automatic rewrite.
|
|
24
|
-
*
|
|
25
|
-
* node scripts/check-ttp-upstream.js validate against the pins
|
|
26
|
-
* node scripts/check-ttp-upstream.js --json machine-readable output
|
|
27
|
-
*
|
|
28
|
-
* Exit: 0 clean, 1 findings, 2 upstream unreachable (never a silent pass —
|
|
29
|
-
* an unreachable upstream must not read as "all ids valid").
|
|
9
|
+
* Exit 0 clean, 1 findings, 2 upstream unreachable — never a silent pass.
|
|
30
10
|
*/
|
|
31
11
|
|
|
32
12
|
const fs = require("node:fs");
|
|
@@ -41,14 +21,8 @@ function readJson(p) {
|
|
|
41
21
|
}
|
|
42
22
|
|
|
43
23
|
/**
|
|
44
|
-
* A
|
|
45
|
-
*
|
|
46
|
-
* project publishes before it can reach a request, so a malformed or tampered
|
|
47
|
-
* `_meta` cannot steer the fetch — and so a typo'd pin fails here with a clear
|
|
48
|
-
* message instead of as a puzzling 404 later.
|
|
49
|
-
*
|
|
50
|
-
* ATT&CK semver-ish major.minor 19.2
|
|
51
|
-
* ATLAS CalVer YYYY.MM with optional .N 2026.07, 2025.11.2
|
|
24
|
+
* A pin is catalog data interpolated into an upstream URL, so its shape is
|
|
25
|
+
* bounded here: ATT&CK is major.minor, ATLAS is CalVer YYYY.MM with optional .N.
|
|
52
26
|
*/
|
|
53
27
|
const VERSION_SHAPES = {
|
|
54
28
|
attack: /^\d{1,3}\.\d{1,3}$/,
|
|
@@ -66,13 +40,8 @@ function assertPinShape(kind, value) {
|
|
|
66
40
|
}
|
|
67
41
|
|
|
68
42
|
/**
|
|
69
|
-
* The only hosts this checker may
|
|
70
|
-
* the
|
|
71
|
-
* but that guard sits a long way from the request — a later edit could add a
|
|
72
|
-
* caller that skips it, and static analysis reasonably flags file data reaching
|
|
73
|
-
* an outbound request when the barrier is that distant. Re-check the resolved
|
|
74
|
-
* origin at the sink so the property is enforced where it matters and is
|
|
75
|
-
* verifiable by reading ten lines rather than tracing the whole module.
|
|
43
|
+
* The only hosts this checker may contact. Re-checked at the sink even though
|
|
44
|
+
* `assertPinShape` bounds the version, so a later caller cannot route past it.
|
|
76
45
|
*/
|
|
77
46
|
const ALLOWED_ORIGINS = new Set(["https://raw.githubusercontent.com"]);
|
|
78
47
|
|
|
@@ -89,7 +58,6 @@ function assertAllowedUrl(rawUrl) {
|
|
|
89
58
|
`${[...ALLOWED_ORIGINS].join(", ")}`
|
|
90
59
|
);
|
|
91
60
|
}
|
|
92
|
-
// Reject anything that could smuggle credentials or redirect the path.
|
|
93
61
|
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
94
62
|
throw new Error(`refusing to fetch a URL carrying credentials or a query/fragment: ${parsed.origin}${parsed.pathname}`);
|
|
95
63
|
}
|
|
@@ -2,45 +2,9 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* minor or major requires explicit human authorization." That rule lived in
|
|
9
|
-
* the contributor guide and in maintainer memory — and was violated anyway
|
|
10
|
-
* (two releases shipped as minors that should have been patches). A written
|
|
11
|
-
* rule the tooling does not enforce is one a tired/automated contributor will
|
|
12
|
-
* eventually skip. This gate makes the rule mechanical: an unauthorized
|
|
13
|
-
* minor/major version bump fails predeploy and cannot ship.
|
|
14
|
-
*
|
|
15
|
-
* Hermetic by design. Authorization is a COMMITTED artifact
|
|
16
|
-
* (tests/.version-bump-ack.json), not an environment variable, because
|
|
17
|
-
* `npm run predeploy` runs in the release.yml validate job as well as locally.
|
|
18
|
-
* An env-var scheme would either false-fail a legitimately-authorized minor in
|
|
19
|
-
* CI or require per-release CI config. A committed ack file travels with the
|
|
20
|
-
* checkout, so the gate enforces identically everywhere — and a minor bump
|
|
21
|
-
* becomes a loud, reviewable line in the PR diff instead of a silent change to
|
|
22
|
-
* a version string.
|
|
23
|
-
*
|
|
24
|
-
* Mechanism:
|
|
25
|
-
* prev = the most recent OTHER `## X.Y.Z` heading in CHANGELOG.md
|
|
26
|
-
* cur = package.json version (== the top CHANGELOG heading; the version-sync
|
|
27
|
-
* gate enforces that match separately)
|
|
28
|
-
* classify prev -> cur as patch | minor | major | none | downgrade
|
|
29
|
-
* - patch / none -> pass (the default; zero ceremony)
|
|
30
|
-
* - downgrade / bad -> fail (versions only move forward)
|
|
31
|
-
* - minor / major -> pass ONLY if tests/.version-bump-ack.json names
|
|
32
|
-
* the exact `cur` version with the matching type;
|
|
33
|
-
* otherwise fail with remediation.
|
|
34
|
-
*
|
|
35
|
-
* To authorize a minor (only after the user explicitly asks for one):
|
|
36
|
-
* echo '{"version":"0.19.0","type":"minor"}' > tests/.version-bump-ack.json
|
|
37
|
-
* and commit it. The ack is version-specific, so a stale ack cannot authorize
|
|
38
|
-
* a different future bump.
|
|
39
|
-
*
|
|
40
|
-
* Output:
|
|
41
|
-
* stdout: structured JSON when --json, else a one-line summary
|
|
42
|
-
* exit 0: patch/none, or an authorized minor/major
|
|
43
|
-
* exit 1: unauthorized minor/major, downgrade, or unparseable version
|
|
5
|
+
* Patch-only-cadence gate. A minor or major fails unless the committed
|
|
6
|
+
* tests/.version-bump-ack.json names that exact version, so an ack travels with
|
|
7
|
+
* the checkout and a stale one cannot authorize a later bump.
|
|
44
8
|
*/
|
|
45
9
|
|
|
46
10
|
const fs = require('fs');
|
|
@@ -57,7 +21,6 @@ function parseSemver(v) {
|
|
|
57
21
|
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
|
|
58
22
|
}
|
|
59
23
|
|
|
60
|
-
// Classify the transition prev -> cur. Pure; exported for unit tests.
|
|
61
24
|
function classifyBump(prev, cur) {
|
|
62
25
|
const a = parseSemver(prev);
|
|
63
26
|
const b = parseSemver(cur);
|
|
@@ -68,8 +31,7 @@ function classifyBump(prev, cur) {
|
|
|
68
31
|
return 'none';
|
|
69
32
|
}
|
|
70
33
|
|
|
71
|
-
//
|
|
72
|
-
// exported for unit tests. ack = { version, type } | null.
|
|
34
|
+
// `ack` is { version, type } or null.
|
|
73
35
|
function checkBump(prev, cur, ack) {
|
|
74
36
|
if (!prev) return { ok: true, bump: 'initial', reason: 'no previous version recorded' };
|
|
75
37
|
const bump = classifyBump(prev, cur);
|
|
@@ -82,7 +44,6 @@ function checkBump(prev, cur, ack) {
|
|
|
82
44
|
if (bump === 'patch' || bump === 'none') {
|
|
83
45
|
return { ok: true, bump, reason: `${bump} bump (${prev} -> ${cur})` };
|
|
84
46
|
}
|
|
85
|
-
// minor or major — requires explicit committed authorization for this exact version.
|
|
86
47
|
if (ack && ack.version === cur && ack.type === bump) {
|
|
87
48
|
return { ok: true, bump, reason: `${bump} bump authorized for ${cur} via tests/.version-bump-ack.json` };
|
|
88
49
|
}
|
|
@@ -93,7 +54,7 @@ function checkBump(prev, cur, ack) {
|
|
|
93
54
|
};
|
|
94
55
|
}
|
|
95
56
|
|
|
96
|
-
//
|
|
57
|
+
// Document order — newest first, which determinePrevious relies on.
|
|
97
58
|
function changelogVersions(text) {
|
|
98
59
|
const out = [];
|
|
99
60
|
const re = /^##\s+(\d+\.\d+\.\d+)\b/gm;
|
|
@@ -107,14 +68,8 @@ function suggestPatch(prev) {
|
|
|
107
68
|
return a ? `${a.major}.${a.minor}.${a.patch + 1}` : null;
|
|
108
69
|
}
|
|
109
70
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// heading-less CHANGELOG as a "first release" — doing so silently authorized
|
|
113
|
-
// ANY bump (including an unapproved major), because checkBump(null, ...) takes
|
|
114
|
-
// the initial-release pass. `text` is the CHANGELOG contents, or null when the
|
|
115
|
-
// read failed. Returns { ok, prev?, reason? }: ok:false fails the gate; ok:true
|
|
116
|
-
// yields prev, which is null ONLY for a genuine first release whose sole
|
|
117
|
-
// `## X.Y.Z` heading equals cur (versions === [cur]).
|
|
71
|
+
// Fails closed: an unreadable or heading-less CHANGELOG must not look like a
|
|
72
|
+
// first release, because checkBump(null, ...) then authorizes any bump.
|
|
118
73
|
function determinePrevious(text, cur) {
|
|
119
74
|
if (text == null) {
|
|
120
75
|
return { ok: false, reason: 'CHANGELOG.md could not be read' };
|
|
@@ -149,12 +104,7 @@ function main() {
|
|
|
149
104
|
}
|
|
150
105
|
const cur = pkg.version;
|
|
151
106
|
|
|
152
|
-
//
|
|
153
|
-
// Swallowing a read error to '' previously made versions=[] -> prev=null ->
|
|
154
|
-
// checkBump's initial-release pass, so a missing/malformed CHANGELOG silently
|
|
155
|
-
// authorized any bump. A genuine first release still passes: its sole
|
|
156
|
-
// `## X.Y.Z` heading equals cur, so determinePrevious returns prev=null with
|
|
157
|
-
// ok:true and checkBump takes the legitimate initial path.
|
|
107
|
+
// null on a read failure, never '' — determinePrevious fails closed on null.
|
|
158
108
|
let changelogText = null;
|
|
159
109
|
try { changelogText = fs.readFileSync(CHANGELOG_PATH, 'utf8'); } catch (_e) { changelogText = null; }
|
|
160
110
|
const prevRes = determinePrevious(changelogText, cur);
|
|
@@ -163,7 +113,6 @@ function main() {
|
|
|
163
113
|
process.exitCode = 1;
|
|
164
114
|
return;
|
|
165
115
|
}
|
|
166
|
-
// prev = the most recent heading that differs from the current version.
|
|
167
116
|
const prev = prevRes.prev;
|
|
168
117
|
|
|
169
118
|
const ack = readAck();
|
|
@@ -191,8 +140,7 @@ function main() {
|
|
|
191
140
|
if (patch) process.stderr.write(`[check-version-bump] If this should be a patch, set the version to ${patch}.\n`);
|
|
192
141
|
process.stderr.write(`[check-version-bump] If the user explicitly authorized a ${res.bump}, commit tests/.version-bump-ack.json = {"version":"${cur}","type":"${res.bump}"}.\n`);
|
|
193
142
|
}
|
|
194
|
-
// process.exitCode
|
|
195
|
-
// is not truncated when stdout is piped (the stdout-flush-truncation class).
|
|
143
|
+
// `process.exitCode`, not process.exit(): exit truncates the buffered stdout write.
|
|
196
144
|
process.exitCode = 1;
|
|
197
145
|
return;
|
|
198
146
|
}
|