@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.
Files changed (119) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +20 -54
  8. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +57 -206
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +34 -106
  20. package/lib/collectors/scan-excludes.js +31 -138
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -152
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +35 -114
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +64 -232
  37. package/lib/playbook-runner.js +693 -2095
  38. package/lib/prefetch.js +100 -376
  39. package/lib/refresh-external.js +199 -627
  40. package/lib/refresh-network.js +75 -307
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +77 -145
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +14 -24
  48. package/lib/upstream-check-cli.js +10 -28
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +43 -119
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +69 -275
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -412
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +241 -241
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +6 -16
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +12 -27
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +11 -54
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +50 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +13 -73
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +66 -197
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +3 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +3 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +2 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +40 -137
  114. package/scripts/release.js +69 -232
  115. package/scripts/run-e2e-scenarios.js +24 -71
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -140
@@ -2,30 +2,12 @@
2
2
  "use strict";
3
3
 
4
4
  /**
5
- * scripts/run-e2e-scenarios.js
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
- * Drives the end-to-end scenario harness under tests/e2e-scenarios/. Each
8
- * scenario directory stages a synthetic file tree (real IoC patterns the
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
- // Evaluate the negative stderr guard against raw stderr text. Lives here, NOT
59
- // inside diffExpect, because the ban must hold regardless of whether stdout
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
- // Diff a parsed JSON body against the positive expect matchers. The negative
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
- // Some verbs may emit trailing logs; pick the LAST complete JSON object or
123
- // array on stdout. A verb envelope is always an object/array, so bare
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
- // Evaluate a spawnSync result against a scenario's expectations. Pure: takes
138
- // the raw spawnSync result so the failure logic is unit-testable without
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
- // spawnSync failure channels: a timeout sets res.error (ETIMEDOUT) +
150
- // res.signal 'SIGTERM' with status null; a launch failure (ENOENT/EACCES)
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
- // Assertion floor: every scenario must bind at least one positive check.
158
- // Without an expect_exit or a json_path_* matcher, both gates below are
159
- // skipped and the scenario would pass for ANY CLI behavior, including a
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
- // The forbidden-token ban on stderr runs unconditionally — it does not
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
- // Resolve env. @@FIXTURE@@ in env values expands to ROOT/tests/fixtures.
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
- // Verb routing. `refresh` + `refresh-curate` are not the same as `run` —
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
- // Invoke the curation helper directly. Production path is via the
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
- // Select scenario directories under SCENARIO_DIR, optionally narrowed by a
256
- // filter. The filter is matched as a plain substring (String.includes), NOT
257
- // compiled with new RegExp: a regex built from a CLI argument is a regex-
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
- * scripts/sync-manifest-metadata.js
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
- * Several per-skill fields in manifest.json are a cache of the authoritative
7
- * values in each skill's frontmatter (the linter and the staleness gate read
8
- * frontmatter, so frontmatter is the single source of truth). Nothing kept
9
- * the cache in sync, so editing frontmatter left the manifest copy stale.
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
- // Cross-reference arrays: the manifest is an enriched superset, so sync by
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
- * scripts/sync-package-description.js
5
- *
6
- * Regenerate the count-bearing tokens embedded in package.json.description from
7
- * the live catalogs + manifest, so the description stays in sync when an
8
- * auto-refresh changes an entry count. refresh-sbom copies the description into
9
- * sbom.cdx.json, and check-sbom-currency validates every token against the live
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
- // Replace only the integer in "<N> <label>"; `labelRe` is the same (already
43
- // regex-escaped) pattern check-sbom-currency matches, and $2 preserves the
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
- * scripts/validate-vendor-online.js
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
- * Optional, network-touching companion to lib/validate-vendor.js. For every
7
- * file recorded in vendor/blamejs/_PROVENANCE.json, fetches the upstream
8
- * blob from github.com/<source_repo>/blob/<pinned_commit>/<upstream_path>
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
- // Translate https://github.com/owner/repo → raw.githubusercontent.com/owner/repo
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
- // Validate every file-derived component, then build the URL from the SAME
68
- // validated bindings (cleanOwner/cleanRepo/cleanCommit/cleanPath) so a tampered
69
- // _PROVENANCE.json cannot steer the request and no unguarded metadata value
70
- // reaches the fetch: owner/repo are GitHub-name-shaped, the commit is a hex git
71
- // object id, and the path is a relative, traversal-free repo path. With the host
72
- // a string literal, the fetch destination is fully constrained.
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
- // Only GitHub-controlled hosts may be fetched. The initial URL is always a
86
- // raw.githubusercontent.com URL computed from the committed _PROVENANCE.json,
87
- // but redirects re-enter fetchBuffer with a server-supplied Location; pinning
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
- // v0.12.14 (codex P2): cap redirect hops. A redirect loop (or a
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) {