mandrel-platform 1.11.0 → 1.13.0
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/README.md +14 -5
- package/package.json +4 -1
- package/scripts/audit-check.mjs +345 -106
- package/scripts/audit-check.test.mjs +208 -3
- package/scripts/check-advisory-scan-setup.test.mjs +247 -0
- package/scripts/check-semgrep-lockfile.test.mjs +343 -0
- package/scripts/env-doctor.mjs +154 -28
- package/scripts/env-doctor.test.mjs +243 -0
- package/scripts/select-semgrep-python.sh +134 -0
- package/scripts/select-semgrep-python.test.mjs +217 -0
- package/scripts/semgrep-requirements.txt +146 -74
- package/scripts/update-semgrep-rules.mjs +15 -2
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-semgrep-lockfile.test.mjs — guards the SAST toolchain lockfile
|
|
4
|
+
* (Story #477).
|
|
5
|
+
*
|
|
6
|
+
* What this pins, and why each part earned a test:
|
|
7
|
+
*
|
|
8
|
+
* 1. **The advisories stay gone.** The lockfile carried protobuf 4.25.9
|
|
9
|
+
* (CVE-2026-0994, CVSS 8.2) and setuptools 80.9.0 (GHSA-h35f-9h28-mq5c).
|
|
10
|
+
* Neither could be fixed in place: every protobuf 4.x is affected, and
|
|
11
|
+
* semgrep 1.97.0's `opentelemetry-*~=1.25.0` pin capped protobuf below
|
|
12
|
+
* every patched release. A future bump that lands back inside an affected
|
|
13
|
+
* range would reintroduce a high with no other signal until the next
|
|
14
|
+
* scheduled OSV scan.
|
|
15
|
+
*
|
|
16
|
+
* 2. **The pin sites cannot drift.** The semgrep version lives in THREE
|
|
17
|
+
* places — this lockfile, `SEMGREP_PIN` in pr-quality.yml, and
|
|
18
|
+
* `DEFAULT_SEMGREP_PIN` in update-semgrep-rules.mjs — plus the
|
|
19
|
+
* `SEMGREP_HASHES` map that must carry digests for whatever the default is.
|
|
20
|
+
* Nothing detected disagreement between them before this file.
|
|
21
|
+
*
|
|
22
|
+
* 3. **`--require-hashes` stays satisfiable.** Every entry must be `==`-pinned
|
|
23
|
+
* with a sha256, or the install fails at CI time rather than here.
|
|
24
|
+
*
|
|
25
|
+
* The end-to-end proof (a real `pip install --require-hashes` on linux/cp312)
|
|
26
|
+
* cannot run in this suite — it needs that platform and a network. It is a
|
|
27
|
+
* `verify[]` step on the Story instead; these are the invariants checkable
|
|
28
|
+
* from the tree.
|
|
29
|
+
*
|
|
30
|
+
* Run: node --test scripts/check-semgrep-lockfile.test.mjs
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import assert from "node:assert/strict";
|
|
34
|
+
import { test } from "node:test";
|
|
35
|
+
import { readFileSync } from "node:fs";
|
|
36
|
+
|
|
37
|
+
const LOCKFILE = "scripts/semgrep-requirements.txt";
|
|
38
|
+
const WORKFLOW = ".github/workflows/pr-quality.yml";
|
|
39
|
+
const UPDATER = "scripts/update-semgrep-rules.mjs";
|
|
40
|
+
|
|
41
|
+
const lockfile = readFileSync(LOCKFILE, "utf8");
|
|
42
|
+
const workflow = readFileSync(WORKFLOW, "utf8");
|
|
43
|
+
|
|
44
|
+
// semgrep's own `requires_python`, recorded per release. Verified on PyPI
|
|
45
|
+
// 2026-09-10: 1.97.0 was `>=3.8`, 1.136.0 `>=3.9`, and 1.137.0 raised it to
|
|
46
|
+
// `>=3.10` — which is why a runner on macOS system Python (3.9.6) could not
|
|
47
|
+
// install the 1.176.1 pin at all (issue #480).
|
|
48
|
+
//
|
|
49
|
+
// This table is what makes the floor in pr-quality.yml checkable without a
|
|
50
|
+
// network call: a bump to a release with no entry here fails loudly, so
|
|
51
|
+
// "look up the new requires_python" becomes a step of the bump rather than
|
|
52
|
+
// something discovered by a consumer's red CI.
|
|
53
|
+
const SEMGREP_PYTHON_FLOORS = new Map([["1.176.1", "3.10"]]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse `name==version` requirement lines, ignoring comments and hash
|
|
57
|
+
* continuations. Keyed by lowercased name.
|
|
58
|
+
*/
|
|
59
|
+
function requirements(text) {
|
|
60
|
+
/** @type {Map<string, string>} */
|
|
61
|
+
const out = new Map();
|
|
62
|
+
for (const line of text.split("\n")) {
|
|
63
|
+
const trimmed = line.trim();
|
|
64
|
+
if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("--hash")) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const m = trimmed.match(/^([A-Za-z0-9._-]+)==([^\s\\]+)/);
|
|
68
|
+
if (m) {
|
|
69
|
+
out.set(m[1].toLowerCase(), m[2]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const REQS = requirements(lockfile);
|
|
76
|
+
|
|
77
|
+
/** Compare dotted release segments numerically. Returns -1 / 0 / 1. */
|
|
78
|
+
function compareVersions(a, b) {
|
|
79
|
+
const pa = a.split(".").map((n) => Number.parseInt(n, 10));
|
|
80
|
+
const pb = b.split(".").map((n) => Number.parseInt(n, 10));
|
|
81
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
82
|
+
const x = Number.isNaN(pa[i]) || pa[i] === undefined ? 0 : pa[i];
|
|
83
|
+
const y = Number.isNaN(pb[i]) || pb[i] === undefined ? 0 : pb[i];
|
|
84
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
85
|
+
}
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// 1. The advisories that put this Story on the board
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
test("protobuf is outside every CVE-2026-0994 affected range", () => {
|
|
94
|
+
// Affected: `< 5.29.6` and `>= 6.30.0rc1, <= 6.33.4`. Patched: 5.29.6, 6.33.5.
|
|
95
|
+
// A DoS in google.protobuf.json_format.ParseDict — 8.2, and the finding that
|
|
96
|
+
// opened tracking issue #472.
|
|
97
|
+
const version = REQS.get("protobuf");
|
|
98
|
+
assert.ok(version, `${LOCKFILE}: protobuf must be pinned`);
|
|
99
|
+
assert.doesNotMatch(version, /[a-zA-Z]/, "expected a final release, not a pre-release");
|
|
100
|
+
|
|
101
|
+
assert.ok(
|
|
102
|
+
compareVersions(version, "5.29.6") >= 0,
|
|
103
|
+
`protobuf ${version} is below the 5.29.6 patch — inside the "< 5.29.6" affected range`,
|
|
104
|
+
);
|
|
105
|
+
const inSixLine = compareVersions(version, "6.0.0") >= 0;
|
|
106
|
+
if (inSixLine) {
|
|
107
|
+
assert.ok(
|
|
108
|
+
compareVersions(version, "6.33.5") >= 0,
|
|
109
|
+
`protobuf ${version} is inside the ">= 6.30.0rc1, <= 6.33.4" affected range`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("setuptools is absent from the closure", () => {
|
|
115
|
+
// It was pinned to 80.9.0 only because semgrep 1.97.0's transitive
|
|
116
|
+
// opentelemetry-instrumentation imported pkg_resources at load. 0.58b0 does
|
|
117
|
+
// not, so the package — and its own advisory — leaves the graph entirely.
|
|
118
|
+
assert.equal(
|
|
119
|
+
REQS.has("setuptools"),
|
|
120
|
+
false,
|
|
121
|
+
"setuptools carries its own advisories and is no longer needed; do not re-add it without a stated reason",
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// 2. Drift between the three pin sites
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
test("the lockfile, the workflow, and the rules updater pin the same semgrep", () => {
|
|
130
|
+
const lockVersion = REQS.get("semgrep");
|
|
131
|
+
assert.ok(lockVersion, `${LOCKFILE}: semgrep must be pinned`);
|
|
132
|
+
|
|
133
|
+
const wf = workflow.match(/SEMGREP_PIN='semgrep==([^']+)'/);
|
|
134
|
+
assert.ok(wf, `${WORKFLOW}: SEMGREP_PIN not found`);
|
|
135
|
+
assert.equal(wf[1], lockVersion, "workflow SEMGREP_PIN disagrees with the lockfile");
|
|
136
|
+
|
|
137
|
+
const updater = readFileSync(UPDATER, "utf8");
|
|
138
|
+
const up = updater.match(/const DEFAULT_SEMGREP_PIN = "semgrep==([^"]+)";/);
|
|
139
|
+
assert.ok(up, `${UPDATER}: DEFAULT_SEMGREP_PIN not found`);
|
|
140
|
+
assert.equal(up[1], lockVersion, "updater DEFAULT_SEMGREP_PIN disagrees with the lockfile");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("the rules updater carries artifact hashes for the version it defaults to", () => {
|
|
144
|
+
// SEMGREP_HASHES is a fail-fast supply-chain guard: a version with no entry
|
|
145
|
+
// is rejected rather than installed unverified. A bump that moved the default
|
|
146
|
+
// without adding digests would turn that guard into a hard stop.
|
|
147
|
+
const updater = readFileSync(UPDATER, "utf8");
|
|
148
|
+
const version = REQS.get("semgrep");
|
|
149
|
+
const map = updater.slice(updater.indexOf("const SEMGREP_HASHES = {"));
|
|
150
|
+
const block = map.slice(0, map.indexOf("\n};"));
|
|
151
|
+
assert.ok(
|
|
152
|
+
block.includes(`"${version}": [`),
|
|
153
|
+
`${UPDATER}: SEMGREP_HASHES has no entry for ${version}`,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// 3. --require-hashes remains satisfiable
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
test("every requirement is == pinned and carries a sha256 hash", () => {
|
|
162
|
+
const pins = lockfile
|
|
163
|
+
.split("\n")
|
|
164
|
+
.filter((l) => /^[A-Za-z0-9._-]+==/.test(l.trim())).length;
|
|
165
|
+
const hashes = lockfile.split("\n").filter((l) => l.trim().startsWith("--hash=sha256:")).length;
|
|
166
|
+
|
|
167
|
+
assert.ok(pins > 0, "expected at least one pinned requirement");
|
|
168
|
+
assert.equal(REQS.size, pins, "every pinned line must parse to a requirement");
|
|
169
|
+
assert.ok(
|
|
170
|
+
hashes >= pins,
|
|
171
|
+
`${hashes} hash line(s) for ${pins} requirement(s) — --require-hashes needs at least one each`,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("no requirement is pinned with a loose operator", () => {
|
|
176
|
+
// `--require-hashes` rejects these at install time; catching it here names
|
|
177
|
+
// the offending line instead of failing inside CI's pip.
|
|
178
|
+
//
|
|
179
|
+
// The operator is matched by string comparison, NOT by a regex alternating
|
|
180
|
+
// `<` and `>`. CodeQL reads such a pattern as an attempted HTML-tag filter
|
|
181
|
+
// and raises js/bad-tag-filter at HIGH — which blocks the merge, since
|
|
182
|
+
// code-scanning gates on high. Comparing prefixes says the same thing with
|
|
183
|
+
// nothing for that query to match on.
|
|
184
|
+
const LOOSE_OPERATORS = [">=", "<=", "~=", "!=", ">", "<"];
|
|
185
|
+
for (const line of lockfile.split("\n")) {
|
|
186
|
+
const trimmed = line.trim();
|
|
187
|
+
if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("--hash")) continue;
|
|
188
|
+
const name = trimmed.match(/^[A-Za-z0-9._-]+/);
|
|
189
|
+
if (!name) continue;
|
|
190
|
+
const operator = trimmed.slice(name[0].length).trimStart();
|
|
191
|
+
for (const loose of LOOSE_OPERATORS) {
|
|
192
|
+
assert.ok(
|
|
193
|
+
!operator.startsWith(loose),
|
|
194
|
+
`loose pin (${loose}) — --require-hashes needs an exact ==: ${trimmed}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// 4. The regeneration trap
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
test("the header warns about the manylinux_2_34 wheel tag", () => {
|
|
205
|
+
// semgrep moved its Linux wheel tag after 1.157.0. A `pip download` whose
|
|
206
|
+
// --platform list omits the new tag resolves NOTHING newer and reports only
|
|
207
|
+
// "No matching distribution found", never naming the tag as the cause.
|
|
208
|
+
const header = lockfile.slice(0, lockfile.indexOf("\n\n\n") + 1 || 4000);
|
|
209
|
+
assert.match(header, /manylinux_2_34_x86_64/, "the header must name the current wheel tag");
|
|
210
|
+
assert.match(header, /1\.157\.0/, "the header must say which version the tag changed after");
|
|
211
|
+
assert.match(
|
|
212
|
+
lockfile,
|
|
213
|
+
/pip download semgrep/,
|
|
214
|
+
"the header must carry a regeneration command",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// 5. The interpreter floor on the non-lockfile install path (Story #482)
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
test("pr-quality.yml declares an interpreter floor matching the pinned semgrep", () => {
|
|
223
|
+
const version = REQS.get("semgrep");
|
|
224
|
+
const declared = workflow.match(/SEMGREP_PYTHON_FLOOR='([^']+)'/);
|
|
225
|
+
assert.ok(
|
|
226
|
+
declared,
|
|
227
|
+
`${WORKFLOW}: SEMGREP_PYTHON_FLOOR must be declared beside SEMGREP_PIN — without it the SAST step cannot tell a too-old interpreter from a working one`,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const recorded = SEMGREP_PYTHON_FLOORS.get(version);
|
|
231
|
+
assert.ok(
|
|
232
|
+
recorded,
|
|
233
|
+
`no requires_python floor recorded for semgrep ${version} — read it off PyPI and add it to SEMGREP_PYTHON_FLOORS before bumping SEMGREP_PIN`,
|
|
234
|
+
);
|
|
235
|
+
assert.equal(
|
|
236
|
+
declared[1],
|
|
237
|
+
recorded,
|
|
238
|
+
`SEMGREP_PYTHON_FLOOR is ${declared[1]} but semgrep ${version} requires Python >= ${recorded}`,
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("the selector rides the same side-checkout as the lockfile", () => {
|
|
243
|
+
// The sparse-checkout is NON-CONE and lists exact paths, so a script the
|
|
244
|
+
// SAST step sources is absent at run time unless it is named here — and a
|
|
245
|
+
// missing `source` target kills the security tier for every consumer at
|
|
246
|
+
// once. Both files must sit in the one list.
|
|
247
|
+
const start = workflow.indexOf("- name: Checkout Semgrep lockfile");
|
|
248
|
+
assert.notEqual(start, -1, `${WORKFLOW}: the Semgrep side-checkout step was renamed or removed`);
|
|
249
|
+
const step = workflow.slice(start, workflow.indexOf("path: _mandrel-platform-semgrep", start));
|
|
250
|
+
|
|
251
|
+
assert.ok(
|
|
252
|
+
step.includes("scripts/semgrep-requirements.txt"),
|
|
253
|
+
"the side-checkout must still carry the lockfile",
|
|
254
|
+
);
|
|
255
|
+
assert.ok(
|
|
256
|
+
step.includes("scripts/select-semgrep-python.sh"),
|
|
257
|
+
"the side-checkout must carry the interpreter selector the SAST step sources",
|
|
258
|
+
);
|
|
259
|
+
assert.ok(
|
|
260
|
+
step.includes("sparse-checkout-cone-mode: false"),
|
|
261
|
+
"non-cone mode is what makes the exact-path list meaningful",
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("the SAST step selects an interpreter before it creates the venv", () => {
|
|
266
|
+
// A venv inherits the interpreter that built it, so a floor enforced after
|
|
267
|
+
// `-m venv` cannot fix anything. Order is the whole guarantee.
|
|
268
|
+
const select = workflow.indexOf("select-semgrep-python.sh");
|
|
269
|
+
const venv = workflow.indexOf("-m venv");
|
|
270
|
+
assert.notEqual(select, -1, `${WORKFLOW}: the SAST step must source the interpreter selector`);
|
|
271
|
+
assert.notEqual(venv, -1, `${WORKFLOW}: the SAST step must still create a venv`);
|
|
272
|
+
assert.ok(select < venv, "the selector must be sourced BEFORE the venv is created");
|
|
273
|
+
|
|
274
|
+
assert.ok(
|
|
275
|
+
workflow.includes('"${SEMGREP_PYTHON}" -m venv'),
|
|
276
|
+
"the venv must be built from the selected interpreter, not from bare python3",
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("the non-lockfile path installs exactly SEMGREP_PIN, never a resolved older release", () => {
|
|
281
|
+
// Downgrading to fit an old interpreter re-admits CVE-2026-0994: the newest
|
|
282
|
+
// py3.9-compatible semgrep (1.136.0) pins opentelemetry ~=1.25.0, which caps
|
|
283
|
+
// protobuf below 5.0, and every protobuf 4.x is affected. This path is not
|
|
284
|
+
// hash-pinned and its closure is not OSV-scanned, so it would be silent.
|
|
285
|
+
assert.ok(
|
|
286
|
+
workflow.includes('--retries 3 "${SEMGREP_PIN}"'),
|
|
287
|
+
"the fallback must install the exact pin",
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// Matched as literal substrings rather than a regex alternating the
|
|
291
|
+
// comparison operators: CodeQL reads such a pattern as an attempted HTML-tag
|
|
292
|
+
// filter and raises js/bad-tag-filter at HIGH, which blocks the merge.
|
|
293
|
+
const LOOSE = ["semgrep<", "semgrep>", "semgrep~=", "semgrep!=", 'semgrep=="${'];
|
|
294
|
+
for (const loose of LOOSE) {
|
|
295
|
+
assert.ok(
|
|
296
|
+
!workflow.includes(loose),
|
|
297
|
+
`${WORKFLOW}: '${loose}' would let pip resolve a semgrep other than the pin`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("the Linux hash-pinned branch keeps its exact cp312 equality", () => {
|
|
303
|
+
// Widening this to a `>=` (proposed in issue #480) would route a cp313
|
|
304
|
+
// interpreter onto the lockfile's cp312-only wheels under
|
|
305
|
+
// `--only-binary :all:`, with no sdist fallback — the fleet-red the fallback
|
|
306
|
+
// exists to avoid. The equality is the guard, not the oversight.
|
|
307
|
+
assert.ok(
|
|
308
|
+
workflow.includes('[ "${pyver}" = "312" ]'),
|
|
309
|
+
`${WORKFLOW}: the cp312 test must stay an equality`,
|
|
310
|
+
);
|
|
311
|
+
assert.ok(
|
|
312
|
+
workflow.includes('The `= "312"` below is an EQUALITY on purpose'),
|
|
313
|
+
"the equality must carry a comment saying why a >= test would be wrong",
|
|
314
|
+
);
|
|
315
|
+
assert.ok(
|
|
316
|
+
workflow.includes("sdist fallback"),
|
|
317
|
+
"that comment must name the missing sdist fallback as the mechanism",
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("the floor added no workflow_call input and no new job permission", () => {
|
|
322
|
+
// A consumer-set semgrep pin would re-open the same un-scanned downgrade
|
|
323
|
+
// hole operator-side; `enable-sast: false` is the escape hatch. And a new
|
|
324
|
+
// job-level permission is a COMPILE-TIME break for every caller of this
|
|
325
|
+
// reusable workflow, not a runtime one.
|
|
326
|
+
assert.ok(!workflow.includes("semgrep-pin:"), "no semgrep-pin input — see the Story's non-goals");
|
|
327
|
+
assert.ok(!workflow.includes("python-version:"), "no python-version input — see the Story's non-goals");
|
|
328
|
+
|
|
329
|
+
const start = workflow.indexOf("name: Security (secret scan + SAST)");
|
|
330
|
+
assert.notEqual(start, -1, `${WORKFLOW}: the security job was renamed`);
|
|
331
|
+
const header = workflow.slice(start, workflow.indexOf("steps:", start));
|
|
332
|
+
const granted = header
|
|
333
|
+
.split("\n")
|
|
334
|
+
.map((l) => l.trim())
|
|
335
|
+
.filter((l) => l === "contents: read" || l === "actions: write");
|
|
336
|
+
assert.equal(
|
|
337
|
+
granted.length,
|
|
338
|
+
2,
|
|
339
|
+
"the security job's permissions must remain exactly contents: read + actions: write",
|
|
340
|
+
);
|
|
341
|
+
assert.ok(!header.includes("id-token:"), "no new permission was needed for an interpreter floor");
|
|
342
|
+
assert.ok(!header.includes("packages:"), "no new permission was needed for an interpreter floor");
|
|
343
|
+
});
|
package/scripts/env-doctor.mjs
CHANGED
|
@@ -133,7 +133,7 @@ export const KEY_SCHEMA = Object.freeze({
|
|
|
133
133
|
kind: "'var' | 'secret'",
|
|
134
134
|
sensitivity: "'public' | 'secret'",
|
|
135
135
|
residency:
|
|
136
|
-
"object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers,kind}|null}",
|
|
136
|
+
"object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers: (string | {worker, environments})[], kind}|null}",
|
|
137
137
|
infisical:
|
|
138
138
|
"{folder, environments} | {folders: (string | {folder, environments})[]} | 'unmanaged'",
|
|
139
139
|
shape: `string? — one of ${SHAPE_NAMES.join(", ")}`,
|
|
@@ -456,6 +456,103 @@ function normalizeInfisicalResidency(raw, { at, name, environments }) {
|
|
|
456
456
|
return { folders };
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Normalize `residency.cloudflare` to its canonical
|
|
461
|
+
* `{workers: [{worker, environments}], kind}` form.
|
|
462
|
+
*
|
|
463
|
+
* `workers` accepts a bare worker id — the shape every manifest written before
|
|
464
|
+
* Story #483 uses — or a `{worker, environments}` object, because a key can be
|
|
465
|
+
* deliberately resident on one Worker in one environment only: a peer-database
|
|
466
|
+
* credential scoped that tightly to bound its blast radius, or a recipient
|
|
467
|
+
* allowlist that exists only where non-production sending is gated. With no
|
|
468
|
+
* per-entry `environments`, `probeCloudflare` reconciled ONE expected-name list
|
|
469
|
+
* against EVERY environment, so a deliberate single-environment placement had
|
|
470
|
+
* to report `missing` from the others — ten findings on one consumer's correct
|
|
471
|
+
* manifest, every one of them false (Story #481).
|
|
472
|
+
*
|
|
473
|
+
* A bare entry keeps meaning "every environment". That is the load-bearing
|
|
474
|
+
* constraint rather than a convenience: every manifest in existence declares
|
|
475
|
+
* `workers` as a bare string array, so any other reading would break them all.
|
|
476
|
+
*
|
|
477
|
+
* Both authored shapes normalize to one array of `{worker, environments}` with
|
|
478
|
+
* `environments` defaulted and materialized to `manifest.environments`, so
|
|
479
|
+
* `probeCloudflare` has exactly one shape to read — the same
|
|
480
|
+
* normalize-at-parse treatment `residency.github` received in Story #459 and
|
|
481
|
+
* `infisical` in Story #464. Cloudflare is the surface that never got it, and
|
|
482
|
+
* matching them matters more than the field shape itself: three expressive
|
|
483
|
+
* residencies with one idiom, not three.
|
|
484
|
+
*
|
|
485
|
+
* One deliberate divergence from those two: an **empty** `environments` array
|
|
486
|
+
* is rejected rather than read as "resident nowhere". That state is
|
|
487
|
+
* indistinguishable from omitting the residency altogether, and silently
|
|
488
|
+
* accepting it is precisely how a manifest author comes to believe they have
|
|
489
|
+
* scoped something they have not — the same fail-closed posture this module
|
|
490
|
+
* takes on an unknown `shape`.
|
|
491
|
+
*
|
|
492
|
+
* @param {unknown} raw
|
|
493
|
+
* @param {{at: string, name: string, workers: Record<string, object>, environments: string[]}} ctx
|
|
494
|
+
* @returns {{workers: Array<{worker: string, environments: string[]}>, kind: string} | null}
|
|
495
|
+
*/
|
|
496
|
+
function normalizeCloudflareResidency(raw, { at, name, workers, environments }) {
|
|
497
|
+
if (raw === undefined || raw === null) return null;
|
|
498
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
499
|
+
throw new Error(`${at}.residency.cloudflare must be an object with {workers, kind} (key ${name})`);
|
|
500
|
+
}
|
|
501
|
+
if (!Array.isArray(raw.workers) || raw.workers.length === 0) {
|
|
502
|
+
throw new Error(`${at}.residency.cloudflare.workers must be a non-empty array of worker ids (key ${name})`);
|
|
503
|
+
}
|
|
504
|
+
if (raw.kind !== "secret" && raw.kind !== "var") {
|
|
505
|
+
throw new Error(`${at}.residency.cloudflare.kind must be "secret" or "var" (key ${name})`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const seenWorkers = new Set();
|
|
509
|
+
const normalized = raw.workers.map((entry, j) => {
|
|
510
|
+
const where = `${at}.residency.cloudflare.workers[${j}]`;
|
|
511
|
+
let worker;
|
|
512
|
+
let authoredEnvs;
|
|
513
|
+
if (typeof entry === "string") {
|
|
514
|
+
worker = entry;
|
|
515
|
+
} else if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
516
|
+
worker = entry.worker;
|
|
517
|
+
authoredEnvs = entry.environments;
|
|
518
|
+
} else {
|
|
519
|
+
throw new Error(`${where} must be a worker id string or {worker, environments} (key ${name})`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (typeof worker !== "string" || !Object.hasOwn(workers, worker)) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`${at}.residency.cloudflare.workers references unknown worker id ${JSON.stringify(worker)} (key ${name})`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
if (seenWorkers.has(worker)) {
|
|
528
|
+
throw new Error(
|
|
529
|
+
`${at}.residency.cloudflare repeats the worker "${worker}" — declare one entry per worker (key ${name})`
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
seenWorkers.add(worker);
|
|
533
|
+
|
|
534
|
+
if (authoredEnvs !== undefined) {
|
|
535
|
+
if (!Array.isArray(authoredEnvs) || !authoredEnvs.every((e) => typeof e === "string")) {
|
|
536
|
+
throw new Error(`${where}.environments must be an array of environment slugs (key ${name})`);
|
|
537
|
+
}
|
|
538
|
+
if (authoredEnvs.length === 0) {
|
|
539
|
+
throw new Error(
|
|
540
|
+
`${where}.environments must not be empty — omit it to mean every environment, or drop the entry (key ${name})`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
for (const e of authoredEnvs) {
|
|
544
|
+
if (!environments.includes(e)) {
|
|
545
|
+
throw new Error(`${where}.environments names "${e}", absent from manifest.environments (key ${name})`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return { worker, environments: authoredEnvs ? [...authoredEnvs] : [...environments] };
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
return { workers: normalized, kind: raw.kind };
|
|
554
|
+
}
|
|
555
|
+
|
|
459
556
|
/**
|
|
460
557
|
* @param {unknown} entry
|
|
461
558
|
* @param {number} index
|
|
@@ -494,20 +591,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
|
|
|
494
591
|
|
|
495
592
|
const github = normalizeGitHubResidency(residency.github, { at, name, environments });
|
|
496
593
|
|
|
497
|
-
const cloudflare = residency.cloudflare
|
|
498
|
-
if (cloudflare !== null) {
|
|
499
|
-
if (!Array.isArray(cloudflare.workers) || cloudflare.workers.length === 0) {
|
|
500
|
-
throw new Error(`${at}.residency.cloudflare.workers must be a non-empty array of worker ids (key ${name})`);
|
|
501
|
-
}
|
|
502
|
-
for (const id of cloudflare.workers) {
|
|
503
|
-
if (!Object.hasOwn(workers, id)) {
|
|
504
|
-
throw new Error(`${at}.residency.cloudflare.workers references unknown worker id "${id}" (key ${name})`);
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
if (cloudflare.kind !== "secret" && cloudflare.kind !== "var") {
|
|
508
|
-
throw new Error(`${at}.residency.cloudflare.kind must be "secret" or "var" (key ${name})`);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
594
|
+
const cloudflare = normalizeCloudflareResidency(residency.cloudflare, { at, name, workers, environments });
|
|
511
595
|
|
|
512
596
|
const infisical = normalizeInfisicalResidency(entry.infisical, { at, name, environments });
|
|
513
597
|
|
|
@@ -530,7 +614,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
|
|
|
530
614
|
residency: {
|
|
531
615
|
local,
|
|
532
616
|
github,
|
|
533
|
-
cloudflare
|
|
617
|
+
cloudflare,
|
|
534
618
|
},
|
|
535
619
|
infisical,
|
|
536
620
|
shape: entry.shape ?? null,
|
|
@@ -980,7 +1064,13 @@ export function runOfflineChecks({ manifest, repoRoot }) {
|
|
|
980
1064
|
checked.push(`wrangler:${id}`);
|
|
981
1065
|
const present = new Set(parseWranglerVars(readFileSync(configPath, "utf8"), configPath));
|
|
982
1066
|
const expected = manifest.keys.filter(
|
|
983
|
-
|
|
1067
|
+
// Environment-agnostic by design: this check reports `environment: null`
|
|
1068
|
+
// and `parseWranglerVars` flattens `[env.X.vars]` into one set, so there
|
|
1069
|
+
// is no environment axis to narrow against. A var declared for ANY
|
|
1070
|
+
// environment stays expected in that worker's config.
|
|
1071
|
+
(k) =>
|
|
1072
|
+
k.residency.cloudflare?.kind === "var" &&
|
|
1073
|
+
k.residency.cloudflare.workers.some((w) => w.worker === id)
|
|
984
1074
|
);
|
|
985
1075
|
for (const key of expected) {
|
|
986
1076
|
if (!present.has(key.name)) {
|
|
@@ -1417,10 +1507,37 @@ async function probeGitHub({ manifest, environments, github, surfaces, findings,
|
|
|
1417
1507
|
}
|
|
1418
1508
|
|
|
1419
1509
|
/**
|
|
1510
|
+
* Reconcile the Cloudflare surface per `(worker, environment)` pair.
|
|
1511
|
+
*
|
|
1512
|
+
* `expected` is narrowed to the keys whose residency names BOTH this worker
|
|
1513
|
+
* and this environment, so a deliberate single-environment placement no longer
|
|
1514
|
+
* reports `missing` from the environments it never claimed (Story #481).
|
|
1515
|
+
*
|
|
1516
|
+
* Two consequences of that narrowing are load-bearing, and neither is
|
|
1517
|
+
* incidental:
|
|
1518
|
+
*
|
|
1519
|
+
* 1. **A worker is still probed in an environment it expects nothing in**,
|
|
1520
|
+
* as long as it expects something SOMEWHERE. Skipping it would take the
|
|
1521
|
+
* surface's most interesting finding with it: a production-only key
|
|
1522
|
+
* turning up in staging is undeclared presence, and only an
|
|
1523
|
+
* empty-`expected` reconcile against a non-empty `present` reports it.
|
|
1524
|
+
* Cross-environment orphans go unsuppressed here exactly as they do on
|
|
1525
|
+
* the GitHub and Infisical surfaces. A worker that declares nothing in
|
|
1526
|
+
* any environment is still skipped entirely — that is the manifest
|
|
1527
|
+
* saying it has no opinion, which is not the same statement.
|
|
1528
|
+
* 2. **A 404 is only a finding where something WAS expected.** A worker
|
|
1529
|
+
* deployed to one environment by design 404s in the other, and with
|
|
1530
|
+
* nothing declared there that agrees with the manifest rather than
|
|
1531
|
+
* contradicting it. Reporting it would re-introduce, one layer down, the
|
|
1532
|
+
* same false failure this narrowing removes.
|
|
1533
|
+
*
|
|
1420
1534
|
* @param {object} ctx
|
|
1421
1535
|
*/
|
|
1422
1536
|
async function probeCloudflare({ manifest, environments, cloudflare, surfaces, findings, unavailability = {} }) {
|
|
1423
1537
|
const cfKeys = manifest.keys.filter((k) => k.residency.cloudflare?.kind === "secret");
|
|
1538
|
+
/** Does any key declare this worker in any environment at all? */
|
|
1539
|
+
const declaresWorker = (id) =>
|
|
1540
|
+
cfKeys.some((k) => k.residency.cloudflare.workers.some((w) => w.worker === id));
|
|
1424
1541
|
if (!cloudflare) {
|
|
1425
1542
|
surfaces.push({
|
|
1426
1543
|
surface: "cloudflare",
|
|
@@ -1432,8 +1549,12 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
|
|
|
1432
1549
|
try {
|
|
1433
1550
|
for (const environment of environments) {
|
|
1434
1551
|
for (const [id, worker] of Object.entries(manifest.workers)) {
|
|
1435
|
-
const expected = cfKeys
|
|
1436
|
-
|
|
1552
|
+
const expected = cfKeys
|
|
1553
|
+
.filter((k) =>
|
|
1554
|
+
k.residency.cloudflare.workers.some((w) => w.worker === id && w.environments.includes(environment))
|
|
1555
|
+
)
|
|
1556
|
+
.map((k) => k.name);
|
|
1557
|
+
if (!declaresWorker(id)) continue;
|
|
1437
1558
|
const scriptName = resolveScriptName(worker.scriptName, environment);
|
|
1438
1559
|
let present;
|
|
1439
1560
|
try {
|
|
@@ -1441,15 +1562,20 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
|
|
|
1441
1562
|
} catch (err) {
|
|
1442
1563
|
if (!isAbsentStatus(err)) throw err;
|
|
1443
1564
|
// A 404 is the one status that legitimately means "absent": the
|
|
1444
|
-
// Worker has not been deployed to this environment yet.
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1565
|
+
// Worker has not been deployed to this environment yet. That is only
|
|
1566
|
+
// drift where the manifest expected something here; a worker
|
|
1567
|
+
// deliberately absent from an environment it declares nothing in is
|
|
1568
|
+
// agreement, not a finding.
|
|
1569
|
+
if (expected.length > 0) {
|
|
1570
|
+
findings.push({
|
|
1571
|
+
severity: "fail",
|
|
1572
|
+
kind: "missing",
|
|
1573
|
+
key: null,
|
|
1574
|
+
surface: "cloudflare",
|
|
1575
|
+
environment,
|
|
1576
|
+
detail: `Worker script "${scriptName}" does not exist (404) — ${expected.length} declared secret(s) cannot be verified`,
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1453
1579
|
continue;
|
|
1454
1580
|
}
|
|
1455
1581
|
findings.push(
|