@blamejs/exceptd-skills 0.19.33 → 0.19.34
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 +10 -0
- package/bin/exceptd.js +896 -2824
- 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 +20 -54
- package/lib/collectors/cicd-pipeline-compromise.js +40 -108
- 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 +57 -206
- package/lib/collectors/mcp.js +24 -70
- package/lib/collectors/runtime.js +24 -86
- package/lib/collectors/sbom.js +34 -106
- package/lib/collectors/scan-excludes.js +31 -138
- 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 -152
- 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 +35 -114
- 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 +64 -232
- package/lib/playbook-runner.js +693 -2095
- package/lib/prefetch.js +100 -376
- package/lib/refresh-external.js +199 -627
- package/lib/refresh-network.js +75 -307
- package/lib/rfc-cli.js +23 -68
- package/lib/scoring.js +77 -145
- 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 +14 -24
- package/lib/upstream-check-cli.js +10 -28
- package/lib/upstream-check.js +19 -44
- package/lib/validate-catalog-meta.js +17 -61
- package/lib/validate-cve-catalog.js +43 -119
- package/lib/validate-indexes.js +25 -76
- package/lib/validate-package.js +16 -62
- package/lib/validate-playbooks.js +69 -275
- 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 -412
- 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 +241 -241
- package/scripts/audit-catalog-gaps.js +9 -62
- package/scripts/audit-cross-skill.js +5 -31
- package/scripts/audit-perf.js +6 -16
- 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 +12 -27
- package/scripts/builders/token-budget.js +4 -31
- package/scripts/check-agents-md-collectors.js +11 -54
- 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 +50 -143
- package/scripts/check-epss-consistency.js +9 -64
- package/scripts/check-framework-gap-coverage.js +13 -31
- package/scripts/check-manifest-snapshot.js +13 -73
- package/scripts/check-sbom-currency.js +44 -142
- package/scripts/check-test-count.js +15 -52
- package/scripts/check-test-coverage.js +66 -197
- 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 +3 -8
- package/scripts/refresh-mitre-attack.js +1 -8
- package/scripts/refresh-mitre-d3fend.js +3 -9
- package/scripts/refresh-mitre-ics-attack.js +3 -8
- package/scripts/refresh-reverse-refs.js +27 -94
- package/scripts/refresh-rfc-index.js +2 -10
- package/scripts/refresh-sbom.js +31 -161
- package/scripts/refresh-upstream-catalogs.js +40 -137
- package/scripts/release.js +69 -232
- package/scripts/run-e2e-scenarios.js +24 -71
- 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 -140
|
@@ -2,30 +2,12 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* Drives the scenario harness under tests/e2e-scenarios/. A scenario directory
|
|
6
|
+
* stages a synthetic file tree plus an evidence.json and an expect.json; each
|
|
7
|
+
* one runs in its own temp copy against the real CLI.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* playbooks check for) + an evidence.json + an expect.json. The runner:
|
|
10
|
-
*
|
|
11
|
-
* 1. mkdtemp a working dir
|
|
12
|
-
* 2. recursive-copy fixtures/ into it
|
|
13
|
-
* 3. recursive-copy any evidence.json next to scenario.json into it
|
|
14
|
-
* 4. cd into the working dir
|
|
15
|
-
* 5. spawnSync the CLI with the scenario's verb + args
|
|
16
|
-
* 6. parse stdout as JSON
|
|
17
|
-
* 7. diff against expect.json (path-based assertions)
|
|
18
|
-
*
|
|
19
|
-
* Container parity: this script is invoked unchanged inside the Docker
|
|
20
|
-
* `e2e` target (npm run test:docker:e2e). The container only adds Linux
|
|
21
|
-
* file-permission realism and Node version pinning; the script itself
|
|
22
|
-
* runs identically on host + container.
|
|
23
|
-
*
|
|
24
|
-
* Release gate: .github/workflows/release.yml runs this BEFORE
|
|
25
|
-
* `npm publish` so a regression that breaks any playbook detection
|
|
26
|
-
* blocks the release.
|
|
27
|
-
*
|
|
28
|
-
* Zero npm deps. Node 24 stdlib only.
|
|
9
|
+
* The Docker `e2e` target and release.yml run this script unchanged, so host and
|
|
10
|
+
* container behaviour must not diverge. Node stdlib only, zero npm deps.
|
|
29
11
|
*/
|
|
30
12
|
|
|
31
13
|
const fs = require("fs");
|
|
@@ -55,11 +37,8 @@ function getJsonPath(obj, dotted) {
|
|
|
55
37
|
return dotted.split(".").reduce((acc, key) => acc?.[key], obj);
|
|
56
38
|
}
|
|
57
39
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// parsed as JSON — a scenario whose stdout is a human banner (no JSON body,
|
|
61
|
-
// only an expect_exit assertion) must still enforce a forbidden-token ban on
|
|
62
|
-
// stderr. evaluateScenario calls this unconditionally.
|
|
40
|
+
// Separate from diffExpect because the ban has to hold whether or not stdout
|
|
41
|
+
// parsed as JSON; evaluateScenario calls it unconditionally.
|
|
63
42
|
function stderrBanFailures(expect, stderr) {
|
|
64
43
|
const failures = [];
|
|
65
44
|
if (expect.stderr_must_not_match) {
|
|
@@ -72,10 +51,7 @@ function stderrBanFailures(expect, stderr) {
|
|
|
72
51
|
return failures;
|
|
73
52
|
}
|
|
74
53
|
|
|
75
|
-
//
|
|
76
|
-
// stderr guard is NOT evaluated here (see stderrBanFailures); this function
|
|
77
|
-
// only inspects the JSON body so it cannot be silently skipped when stdout
|
|
78
|
-
// fails to parse.
|
|
54
|
+
// Positive matchers against the parsed JSON body only; the stderr ban is separate.
|
|
79
55
|
function diffExpect(jsonBody, expect, ctx) {
|
|
80
56
|
const failures = [];
|
|
81
57
|
if (expect.json_path_equals) {
|
|
@@ -119,11 +95,8 @@ function tryParseJson(s) {
|
|
|
119
95
|
const v = JSON.parse(s.trim());
|
|
120
96
|
if (v && typeof v === "object") return v;
|
|
121
97
|
} catch { /* ignore */ }
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// scalars (a trailing JSON-parseable "done"/42/true log line) are skipped —
|
|
125
|
-
// binding assertions against a trailing scalar would silently test the
|
|
126
|
-
// wrong value.
|
|
98
|
+
// Trailing logs are possible, so take the LAST complete object or array. A bare
|
|
99
|
+
// scalar is never a verb envelope; accepting one would bind to a log line.
|
|
127
100
|
const lines = s.trim().split("\n");
|
|
128
101
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
129
102
|
try {
|
|
@@ -134,11 +107,8 @@ function tryParseJson(s) {
|
|
|
134
107
|
return null;
|
|
135
108
|
}
|
|
136
109
|
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
// spawning a process. Surfaces spawn-level failures (timeout/launch error)
|
|
140
|
-
// that res.status alone hides, and refuses to pass a scenario that binds no
|
|
141
|
-
// assertion.
|
|
110
|
+
// Pure over a raw spawnSync result, so the failure logic is testable without
|
|
111
|
+
// spawning anything. Returns the list of failure strings; empty means pass.
|
|
142
112
|
function evaluateScenario(scenario, expect, res) {
|
|
143
113
|
const stdout = res.stdout || "";
|
|
144
114
|
const stderr = res.stderr || "";
|
|
@@ -146,19 +116,14 @@ function evaluateScenario(scenario, expect, res) {
|
|
|
146
116
|
const body = tryParseJson(stdout);
|
|
147
117
|
const failures = [];
|
|
148
118
|
|
|
149
|
-
//
|
|
150
|
-
// res.
|
|
151
|
-
// sets res.error with status null. Reading only res.status lets a killed-
|
|
152
|
-
// or-never-launched run masquerade as a plain non-zero exit or a JSON-parse
|
|
153
|
-
// failure, hiding the real cause.
|
|
119
|
+
// A timeout or launch failure sets res.error with status null, so reading only
|
|
120
|
+
// res.status lets a killed or never-launched run pass as a non-zero exit.
|
|
154
121
|
if (res.error) failures.push(`spawn error: ${res.error.code || res.error.message}`);
|
|
155
122
|
if (res.signal) failures.push(`killed by signal ${res.signal}${res.signal === "SIGTERM" ? " (likely the 60s timeout)" : ""}`);
|
|
156
123
|
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// crash. (stderr_must_not_match is a negative guard and cannot bind
|
|
161
|
-
// behavior on its own, so it does not satisfy the floor.)
|
|
124
|
+
// Every scenario must bind one positive check: with neither an expect_exit nor
|
|
125
|
+
// a json_path_* matcher, both gates below skip and the scenario passes for any
|
|
126
|
+
// behaviour at all. A negative guard like stderr_must_not_match binds nothing.
|
|
162
127
|
const hasExitAssertion = typeof scenario.expect_exit === "number";
|
|
163
128
|
const hasJsonAssertion = !!(expect.json_path_equals || expect.json_path_present || expect.json_path_min || expect.json_path_match);
|
|
164
129
|
if (!hasExitAssertion && !hasJsonAssertion) {
|
|
@@ -173,10 +138,7 @@ function evaluateScenario(scenario, expect, res) {
|
|
|
173
138
|
}
|
|
174
139
|
if (body) failures.push(...diffExpect(body, expect, { stdout, stderr, status }));
|
|
175
140
|
|
|
176
|
-
//
|
|
177
|
-
// depend on stdout parsing as JSON. A scenario with only an expect_exit
|
|
178
|
-
// assertion (human-banner stdout) must still fail if stderr carries a banned
|
|
179
|
-
// token.
|
|
141
|
+
// Unconditional, outside every `if (body)` branch above.
|
|
180
142
|
failures.push(...stderrBanFailures(expect, stderr));
|
|
181
143
|
return failures;
|
|
182
144
|
}
|
|
@@ -192,7 +154,6 @@ function runScenario(scenarioPath) {
|
|
|
192
154
|
? JSON.parse(fs.readFileSync(path.join(scenarioPath, "expect.json"), "utf8"))
|
|
193
155
|
: {};
|
|
194
156
|
|
|
195
|
-
// Stage temp working dir
|
|
196
157
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), `e2e-${name}-`));
|
|
197
158
|
try {
|
|
198
159
|
const fixturesDir = path.join(scenarioPath, "fixtures");
|
|
@@ -202,7 +163,7 @@ function runScenario(scenarioPath) {
|
|
|
202
163
|
fs.copyFileSync(evidenceSrc, path.join(work, "evidence.json"));
|
|
203
164
|
}
|
|
204
165
|
|
|
205
|
-
//
|
|
166
|
+
// @@FIXTURE@@ inside an env value expands to ROOT/tests/fixtures.
|
|
206
167
|
const env = { ...process.env, EXCEPTD_DEPRECATION_SHOWN: "1", EXCEPTD_UNSIGNED_WARNED: "1" };
|
|
207
168
|
if (scenario.env) {
|
|
208
169
|
for (const [k, v] of Object.entries(scenario.env)) {
|
|
@@ -210,18 +171,13 @@ function runScenario(scenarioPath) {
|
|
|
210
171
|
}
|
|
211
172
|
}
|
|
212
173
|
|
|
213
|
-
// Resolve args
|
|
214
174
|
const args = (scenario.args || []).slice();
|
|
215
175
|
|
|
216
|
-
//
|
|
217
|
-
// the dispatcher in bin/exceptd.js handles the translation, so we just
|
|
218
|
-
// pass the verb + args verbatim. `refresh-curate` is the internal name
|
|
219
|
-
// for `refresh --curate`; surfaced here for test directness.
|
|
176
|
+
// The verb and args pass through verbatim; bin/exceptd.js does the routing.
|
|
220
177
|
const verb = scenario.verb;
|
|
221
178
|
let cmd, cmdArgs;
|
|
222
179
|
if (verb === "refresh-curate") {
|
|
223
|
-
//
|
|
224
|
-
// dispatcher in bin/exceptd.js (which dispatches refresh --curate).
|
|
180
|
+
// The curation helper direct; operators reach it as `refresh --curate`.
|
|
225
181
|
cmd = process.execPath;
|
|
226
182
|
cmdArgs = [path.join(ROOT, "lib", "cve-curation.js"), ...args];
|
|
227
183
|
} else {
|
|
@@ -252,12 +208,9 @@ function runScenario(scenarioPath) {
|
|
|
252
208
|
}
|
|
253
209
|
}
|
|
254
210
|
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
// injection / ReDoS vector, and scenario names are literal NN-name strings, so
|
|
259
|
-
// substring selection is behavior-equivalent for every legitimate filter
|
|
260
|
-
// (e.g. --filter=library-author). Returns sorted basenames.
|
|
211
|
+
// Sorted basenames. The filter is a plain substring, never `new RegExp`: a
|
|
212
|
+
// pattern compiled from a CLI argument is a regex-injection and ReDoS vector,
|
|
213
|
+
// and scenario names are literal NN-name strings anyway.
|
|
261
214
|
function selectScenarios(filterStr, dir = SCENARIO_DIR) {
|
|
262
215
|
return fs.readdirSync(dir)
|
|
263
216
|
.filter(d => /^\d+-/.test(d))
|
|
@@ -1,37 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Syncs the per-skill fields manifest.json caches from each skill's
|
|
5
|
+
* frontmatter, the authoritative source the linter and staleness gate read.
|
|
6
|
+
* Run it whenever frontmatter changes, then re-run sign-all — and, when a
|
|
7
|
+
* cross-ref array changed, refresh-reverse-refs + build-indexes.
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Two sync disciplines, because the fields differ in kind:
|
|
12
|
-
*
|
|
13
|
-
* - MIRROR (exact): `last_threat_review` (scalar) and `forward_watch`
|
|
14
|
-
* (array) are an exact copy of frontmatter. Synced by replace.
|
|
15
|
-
*
|
|
16
|
-
* - COVER (union): the cross-reference arrays (`data_deps`,
|
|
17
|
-
* `framework_gaps`, `atlas_refs`, `attack_refs`, `rfc_refs`, `cwe_refs`,
|
|
18
|
-
* `d3fend_refs`) are an ENRICHED SUPERSET — the manifest may carry extra
|
|
19
|
-
* curated refs that the derived indexes (build-indexes) and the
|
|
20
|
-
* reverse-ref refresh (refresh-reverse-refs) read. The invariant is that
|
|
21
|
-
* every frontmatter-declared ref MUST appear in the manifest, or it
|
|
22
|
-
* silently vanishes from those surfaces. Synced by UNION (append the
|
|
23
|
-
* missing frontmatter refs, preserve the manifest's order + enrichment) —
|
|
24
|
-
* never by replace, which would drop curated refs.
|
|
25
|
-
*
|
|
26
|
-
* Run it whenever skill frontmatter changes, then re-run sign-all (and, when
|
|
27
|
-
* cross-ref arrays changed, refresh-reverse-refs + build-indexes) so the
|
|
28
|
-
* refreshed manifest is signed and the derived surfaces pick up the refs.
|
|
29
|
-
*
|
|
30
|
-
* tests/sync-manifest-metadata.test.js fails the suite if the cache ever
|
|
31
|
-
* drifts again, so a missed run is caught before release rather than shipping
|
|
32
|
-
* a manifest that contradicts its own skill bodies. It covers the fields named
|
|
33
|
-
* above; `description` is cached in the manifest but is NOT one of them, so a
|
|
34
|
-
* frontmatter description edit does not propagate and is not caught here.
|
|
9
|
+
* `last_threat_review` and `forward_watch` MIRROR frontmatter exactly and sync
|
|
10
|
+
* by replace. The cross-reference arrays are an enriched superset — the
|
|
11
|
+
* manifest carries curated refs frontmatter does not — so they sync by UNION;
|
|
12
|
+
* replacing them drops the curated refs build-indexes and refresh-reverse-refs
|
|
13
|
+
* read. `description` is cached in the manifest but is not synced here.
|
|
35
14
|
*
|
|
36
15
|
* Exit codes: 0 = wrote (or already in sync), 1 = a skill file was missing or
|
|
37
16
|
* its frontmatter failed to parse.
|
|
@@ -44,12 +23,9 @@ const lint = require("../lib/lint-skills.js");
|
|
|
44
23
|
const ROOT = path.resolve(__dirname, "..");
|
|
45
24
|
const MANIFEST = path.join(ROOT, "manifest.json");
|
|
46
25
|
|
|
47
|
-
// The frontmatter fields the manifest caches and must mirror verbatim.
|
|
48
26
|
const MIRRORED_SCALAR = ["last_threat_review"];
|
|
49
27
|
const MIRRORED_ARRAY = ["forward_watch"];
|
|
50
|
-
//
|
|
51
|
-
// UNION (cover) — append frontmatter refs the manifest is missing, keep the
|
|
52
|
-
// manifest's own curated refs. See the header for why replace would regress.
|
|
28
|
+
// Union, never replace — a replace drops the manifest's curated refs.
|
|
53
29
|
const MIRRORED_COVER = ["data_deps", "framework_gaps", "atlas_refs", "attack_refs", "rfc_refs", "cwe_refs", "d3fend_refs"];
|
|
54
30
|
|
|
55
31
|
function skillFrontmatter(id) {
|
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* counts — without this sync, the first refresh that changes a count would fail
|
|
11
|
-
* the SBOM description-token gate on the auto-PR.
|
|
12
|
-
*
|
|
13
|
-
* Targeted, format-preserving: replaces only the integer in each known
|
|
14
|
-
* "<N> <label>" token (skills / catalogs / jurisdictions / per-catalog entry
|
|
15
|
-
* counts). Reuses check-sbom-currency's token table so the two can't drift.
|
|
16
|
-
*
|
|
17
|
-
* Run before refresh-sbom in the refresh apply path (and idempotent locally).
|
|
4
|
+
* Rewrites the count-bearing tokens in package.json.description from the live
|
|
5
|
+
* catalogs and manifest, replacing only the integer in each "<N> <label>" pair.
|
|
6
|
+
* refresh-sbom copies the description into sbom.cdx.json and
|
|
7
|
+
* check-sbom-currency validates every token against live counts, so this runs
|
|
8
|
+
* before refresh-sbom; it reads that gate's token table, so the two cannot
|
|
9
|
+
* drift. Idempotent.
|
|
18
10
|
*/
|
|
19
11
|
|
|
20
12
|
const fs = require('fs');
|
|
@@ -39,9 +31,8 @@ function syncPackageDescription(root = path.join(__dirname, '..')) {
|
|
|
39
31
|
liveJurisdictions = Object.keys(gf).filter((k) => !k.startsWith('_')).length;
|
|
40
32
|
} catch { /* leave null — skip the jurisdiction token */ }
|
|
41
33
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// matched label text verbatim.
|
|
34
|
+
// `labelRe` arrives already regex-escaped from check-sbom-currency; $2
|
|
35
|
+
// preserves the matched label text verbatim.
|
|
45
36
|
const sub = (n, labelRe) => {
|
|
46
37
|
if (n == null) return;
|
|
47
38
|
desc = desc.replace(new RegExp('(\\d+)(\\s+' + labelRe + '\\b)'), String(n) + '$2');
|
|
@@ -1,33 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Network-touching companion to lib/validate-vendor.js: for every file in
|
|
5
|
+
* vendor/blamejs/_PROVENANCE.json, fetch the upstream blob at the pinned commit
|
|
6
|
+
* and compare its hash against the recorded `upstream_sha256_at_pin`.
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* (via the raw.githubusercontent.com mirror), hashes it, and compares the
|
|
10
|
-
* result against the `upstream_sha256_at_pin` recorded in _PROVENANCE.json.
|
|
11
|
-
*
|
|
12
|
-
* This catches the class where _PROVENANCE.json was hand-edited to
|
|
13
|
-
* advertise a `upstream_sha256_at_pin` that does not actually match what
|
|
14
|
-
* upstream had at that commit. lib/validate-vendor.js only checks that the
|
|
15
|
-
* local vendored file matches its own recorded hash — that's self-attesting.
|
|
16
|
-
* This script extends the check to upstream, closing the gap.
|
|
17
|
-
*
|
|
18
|
-
* Not part of `npm run predeploy` by default — the predeploy gate sequence
|
|
19
|
-
* must remain network-independent (offline gates only). Run manually:
|
|
20
|
-
*
|
|
21
|
-
* node scripts/validate-vendor-online.js
|
|
22
|
-
* node scripts/validate-vendor-online.js --timeout 30000
|
|
23
|
-
* node scripts/validate-vendor-online.js --json
|
|
24
|
-
*
|
|
25
|
-
* Exit codes:
|
|
26
|
-
* 0 every vendored file's upstream_sha256_at_pin matched upstream
|
|
27
|
-
* 1 at least one mismatch
|
|
28
|
-
* 2 runtime / network error
|
|
29
|
-
*
|
|
30
|
-
* Zero npm deps. Node 24 stdlib only.
|
|
8
|
+
* lib/validate-vendor.js is self-attesting — a hand-edited _PROVENANCE.json
|
|
9
|
+
* passes it. This reaches upstream, so it stays out of the offline-by-design
|
|
10
|
+
* `npm run predeploy`. Exit 0 on match, 1 on a mismatch, 2 on a runtime error.
|
|
31
11
|
*/
|
|
32
12
|
|
|
33
13
|
const fs = require("fs");
|
|
@@ -58,18 +38,13 @@ function parseArgs(argv) {
|
|
|
58
38
|
}
|
|
59
39
|
|
|
60
40
|
function rawUrlForPin(sourceRepo, commit, upstreamPath) {
|
|
61
|
-
//
|
|
62
|
-
// sourceRepo may end in .git; strip it. Tolerate trailing slash.
|
|
41
|
+
// https://github.com/owner/repo → raw.githubusercontent.com/owner/repo.
|
|
63
42
|
const m = (sourceRepo || "").match(
|
|
64
43
|
/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/
|
|
65
44
|
);
|
|
66
45
|
if (!m) return null;
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// _PROVENANCE.json cannot steer the request and no unguarded metadata value
|
|
70
|
-
// reaches the fetch: owner/repo are GitHub-name-shaped, the commit is a hex git
|
|
71
|
-
// object id, and the path is a relative, traversal-free repo path. With the host
|
|
72
|
-
// a string literal, the fetch destination is fully constrained.
|
|
46
|
+
// The URL is built from the same bindings that get validated below, so no
|
|
47
|
+
// unguarded value out of _PROVENANCE.json reaches the fetch.
|
|
73
48
|
const cleanOwner = m[1];
|
|
74
49
|
const cleanRepo = m[2];
|
|
75
50
|
const cleanCommit = String(commit || "");
|
|
@@ -82,12 +57,9 @@ function rawUrlForPin(sourceRepo, commit, upstreamPath) {
|
|
|
82
57
|
|
|
83
58
|
const MAX_REDIRECTS = 5;
|
|
84
59
|
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// the host to github.com / *.githubusercontent.com stops a redirect (or a
|
|
89
|
-
// tampered provenance source_repo) from steering the fetch at an internal or
|
|
90
|
-
// attacker-controlled address.
|
|
60
|
+
// Redirects re-enter fetchBuffer with a server-supplied Location, so the host is
|
|
61
|
+
// checked on every hop, not just the initial URL: that is what stops a redirect
|
|
62
|
+
// or a tampered source_repo steering the fetch at an internal address.
|
|
91
63
|
const ALLOWED_FETCH_HOST = /(?:^|\.)githubusercontent\.com$|^github\.com$/;
|
|
92
64
|
|
|
93
65
|
function assertAllowedHost(url) {
|
|
@@ -103,10 +75,7 @@ function fetchBuffer(url, timeoutMs, redirectsLeft = MAX_REDIRECTS) {
|
|
|
103
75
|
return new Promise((resolve, reject) => {
|
|
104
76
|
try { assertAllowedHost(url); } catch (e) { return reject(e); }
|
|
105
77
|
const req = https.get(url, (res) => {
|
|
106
|
-
//
|
|
107
|
-
// hostile / mis-configured upstream that keeps returning 3xx with
|
|
108
|
-
// Location pointing back to itself) used to recurse until stack
|
|
109
|
-
// overflow or hang. Now: count hops, fail clean on exhaustion.
|
|
78
|
+
// Hops are counted: a self-referential 3xx Location would recurse forever.
|
|
110
79
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
111
80
|
res.resume();
|
|
112
81
|
if (redirectsLeft <= 0) {
|