mandrel-platform 1.12.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/package.json
CHANGED
|
@@ -39,6 +39,18 @@ const WORKFLOW = ".github/workflows/pr-quality.yml";
|
|
|
39
39
|
const UPDATER = "scripts/update-semgrep-rules.mjs";
|
|
40
40
|
|
|
41
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"]]);
|
|
42
54
|
|
|
43
55
|
/**
|
|
44
56
|
* Parse `name==version` requirement lines, ignoring comments and hash
|
|
@@ -118,7 +130,6 @@ test("the lockfile, the workflow, and the rules updater pin the same semgrep", (
|
|
|
118
130
|
const lockVersion = REQS.get("semgrep");
|
|
119
131
|
assert.ok(lockVersion, `${LOCKFILE}: semgrep must be pinned`);
|
|
120
132
|
|
|
121
|
-
const workflow = readFileSync(WORKFLOW, "utf8");
|
|
122
133
|
const wf = workflow.match(/SEMGREP_PIN='semgrep==([^']+)'/);
|
|
123
134
|
assert.ok(wf, `${WORKFLOW}: SEMGREP_PIN not found`);
|
|
124
135
|
assert.equal(wf[1], lockVersion, "workflow SEMGREP_PIN disagrees with the lockfile");
|
|
@@ -203,3 +214,130 @@ test("the header warns about the manylinux_2_34 wheel tag", () => {
|
|
|
203
214
|
"the header must carry a regeneration command",
|
|
204
215
|
);
|
|
205
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(
|
|
@@ -1339,6 +1339,236 @@ test("a multi-folder key with a shape earns ONE verdict per environment, not one
|
|
|
1339
1339
|
}
|
|
1340
1340
|
});
|
|
1341
1341
|
|
|
1342
|
+
// ---------------------------------------------------------------------------
|
|
1343
|
+
// Cloudflare Worker residency — per-environment presence (Story #483)
|
|
1344
|
+
// ---------------------------------------------------------------------------
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* A manifest whose workers carry no `config` and whose keys have no local,
|
|
1348
|
+
* GitHub or Infisical residency, so the offline arm over an empty repo root
|
|
1349
|
+
* contributes nothing and every finding under test comes from the Cloudflare
|
|
1350
|
+
* probe.
|
|
1351
|
+
*/
|
|
1352
|
+
function cloudflareOnlyManifest({ workers, keys, environments = ["staging", "production"] }) {
|
|
1353
|
+
return parseManifest({
|
|
1354
|
+
environments,
|
|
1355
|
+
workers: Object.fromEntries(workers.map((id) => [id, { scriptName: `swarm-${id}-{env}` }])),
|
|
1356
|
+
keys: keys.map((k) => ({
|
|
1357
|
+
kind: "secret",
|
|
1358
|
+
sensitivity: "secret",
|
|
1359
|
+
residency: { local: null, github: null, cloudflare: { workers: k.workers, kind: "secret" } },
|
|
1360
|
+
infisical: "unmanaged",
|
|
1361
|
+
...k,
|
|
1362
|
+
workers: undefined,
|
|
1363
|
+
})),
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/** Mock `secretNames` from a `{"<worker>-<env>": [names]}` map. */
|
|
1368
|
+
function cloudflareProbe(present) {
|
|
1369
|
+
return {
|
|
1370
|
+
secretNames: async (scriptName) => {
|
|
1371
|
+
const key = scriptName.replace(/^swarm-/, "");
|
|
1372
|
+
return present[key] ?? [];
|
|
1373
|
+
},
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
async function cloudflareFindings({ manifest, present, environments = ["staging", "production"] }) {
|
|
1378
|
+
const root = makeRepo({});
|
|
1379
|
+
try {
|
|
1380
|
+
const report = await runDoctor({
|
|
1381
|
+
manifest,
|
|
1382
|
+
repoRoot: root,
|
|
1383
|
+
environments,
|
|
1384
|
+
cloudflare: cloudflareProbe(present),
|
|
1385
|
+
});
|
|
1386
|
+
return report.findings.filter((f) => f.surface === "cloudflare");
|
|
1387
|
+
} finally {
|
|
1388
|
+
rmSync(root, { recursive: true, force: true });
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
test("a bare worker id still means every environment — every manifest in existence says it that way", () => {
|
|
1393
|
+
const manifest = cloudflareOnlyManifest({
|
|
1394
|
+
workers: ["api"],
|
|
1395
|
+
keys: [{ name: "SHARED_TOKEN", workers: ["api"] }],
|
|
1396
|
+
});
|
|
1397
|
+
assert.deepEqual(manifest.keys[0].residency.cloudflare.workers, [
|
|
1398
|
+
{ worker: "api", environments: ["staging", "production"] },
|
|
1399
|
+
]);
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
test("the object form narrows one entry while a bare sibling keeps defaulting to every environment", () => {
|
|
1403
|
+
const manifest = cloudflareOnlyManifest({
|
|
1404
|
+
workers: ["staff", "api"],
|
|
1405
|
+
keys: [{ name: "SHARED_TOKEN", workers: [{ worker: "staff", environments: ["production"] }, "api"] }],
|
|
1406
|
+
});
|
|
1407
|
+
assert.deepEqual(manifest.keys[0].residency.cloudflare.workers, [
|
|
1408
|
+
{ worker: "staff", environments: ["production"] },
|
|
1409
|
+
{ worker: "api", environments: ["staging", "production"] },
|
|
1410
|
+
]);
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
test("a production-only key present only in production reports NOTHING — the defect #481 filed", async () => {
|
|
1414
|
+
const findings = await cloudflareFindings({
|
|
1415
|
+
manifest: cloudflareOnlyManifest({
|
|
1416
|
+
workers: ["staff"],
|
|
1417
|
+
keys: [{ name: "PEER_DATABASE_URL", workers: [{ worker: "staff", environments: ["production"] }] }],
|
|
1418
|
+
}),
|
|
1419
|
+
present: { "staff-production": ["PEER_DATABASE_URL"] },
|
|
1420
|
+
});
|
|
1421
|
+
assert.deepEqual(findings, []);
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
test("cloudflare suppression does not cross environment — a production-only key in staging orphans", async () => {
|
|
1425
|
+
const findings = await cloudflareFindings({
|
|
1426
|
+
manifest: cloudflareOnlyManifest({
|
|
1427
|
+
workers: ["staff"],
|
|
1428
|
+
keys: [{ name: "PEER_DATABASE_URL", workers: [{ worker: "staff", environments: ["production"] }] }],
|
|
1429
|
+
}),
|
|
1430
|
+
present: { "staff-production": ["PEER_DATABASE_URL"], "staff-staging": ["PEER_DATABASE_URL"] },
|
|
1431
|
+
});
|
|
1432
|
+
assert.equal(findings.length, 1);
|
|
1433
|
+
assert.equal(findings[0].kind, "orphan");
|
|
1434
|
+
assert.equal(findings[0].key, "PEER_DATABASE_URL");
|
|
1435
|
+
assert.equal(findings[0].environment, "staging");
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
test("a narrowed entry still reports a REAL absence in the environment it does name", async () => {
|
|
1439
|
+
const findings = await cloudflareFindings({
|
|
1440
|
+
manifest: cloudflareOnlyManifest({
|
|
1441
|
+
workers: ["staff"],
|
|
1442
|
+
keys: [{ name: "PEER_DATABASE_URL", workers: [{ worker: "staff", environments: ["production"] }] }],
|
|
1443
|
+
}),
|
|
1444
|
+
present: {},
|
|
1445
|
+
});
|
|
1446
|
+
assert.equal(findings.length, 1);
|
|
1447
|
+
assert.equal(findings[0].kind, "missing");
|
|
1448
|
+
assert.equal(findings[0].environment, "production");
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
test("cloudflare worker residency fails closed on every malformed shape", () => {
|
|
1452
|
+
const bad = (workers) => () =>
|
|
1453
|
+
cloudflareOnlyManifest({ workers: ["api", "staff"], keys: [{ name: "SHARED_TOKEN", workers }] });
|
|
1454
|
+
|
|
1455
|
+
assert.throws(bad([]), /must be a non-empty array of worker ids/);
|
|
1456
|
+
assert.throws(bad([42]), /must be a worker id string or \{worker, environments\}/);
|
|
1457
|
+
assert.throws(bad(["nope"]), /references unknown worker id "nope"/);
|
|
1458
|
+
assert.throws(bad([{ worker: "nope", environments: ["staging"] }]), /references unknown worker id "nope"/);
|
|
1459
|
+
assert.throws(bad(["api", "api"]), /repeats the worker "api"/);
|
|
1460
|
+
assert.throws(bad(["api", { worker: "api", environments: ["staging"] }]), /repeats the worker "api"/);
|
|
1461
|
+
assert.throws(bad([{ worker: "api", environments: ["preview"] }]), /absent from manifest.environments/);
|
|
1462
|
+
assert.throws(bad([{ worker: "api", environments: [] }]), /must not be empty/);
|
|
1463
|
+
assert.throws(bad([{ worker: "api", environments: "staging" }]), /must be an array of environment slugs/);
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
test("a worker deployed to one environment by design does not 404-fail in the other", async () => {
|
|
1467
|
+
// The narrowing's second consequence: probing a worker in an environment it
|
|
1468
|
+
// expects nothing in must not turn that worker's deliberate absence into a
|
|
1469
|
+
// finding, or the false failure comes back one layer down.
|
|
1470
|
+
const root = makeRepo({});
|
|
1471
|
+
try {
|
|
1472
|
+
const report = await runDoctor({
|
|
1473
|
+
manifest: cloudflareOnlyManifest({
|
|
1474
|
+
workers: ["staff"],
|
|
1475
|
+
keys: [{ name: "PEER_DATABASE_URL", workers: [{ worker: "staff", environments: ["production"] }] }],
|
|
1476
|
+
}),
|
|
1477
|
+
repoRoot: root,
|
|
1478
|
+
environments: ["staging", "production"],
|
|
1479
|
+
cloudflare: {
|
|
1480
|
+
secretNames: async (scriptName) => {
|
|
1481
|
+
if (scriptName === "swarm-staff-staging") {
|
|
1482
|
+
const err = new Error("not found");
|
|
1483
|
+
err.httpStatus = 404;
|
|
1484
|
+
throw err;
|
|
1485
|
+
}
|
|
1486
|
+
return ["PEER_DATABASE_URL"];
|
|
1487
|
+
},
|
|
1488
|
+
},
|
|
1489
|
+
});
|
|
1490
|
+
assert.deepEqual(
|
|
1491
|
+
report.findings.filter((f) => f.surface === "cloudflare"),
|
|
1492
|
+
[]
|
|
1493
|
+
);
|
|
1494
|
+
assert.equal(report.surfaces.find((s) => s.surface === "cloudflare").status, "checked");
|
|
1495
|
+
} finally {
|
|
1496
|
+
rmSync(root, { recursive: true, force: true });
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
test("the six single-environment keys from #481 report zero failures on a correct manifest", async () => {
|
|
1501
|
+
// The consumer evidence that filed the gap, reconstructed: ten false
|
|
1502
|
+
// `missing` findings across six keys whose single-environment placement is
|
|
1503
|
+
// deliberate. Every one of them must now be silent.
|
|
1504
|
+
const production = ["production"];
|
|
1505
|
+
const staging = ["staging"];
|
|
1506
|
+
const manifest = cloudflareOnlyManifest({
|
|
1507
|
+
workers: ["staff", "api", "web"],
|
|
1508
|
+
keys: [
|
|
1509
|
+
{ name: "PEER_DATABASE_URL", workers: [{ worker: "staff", environments: production }] },
|
|
1510
|
+
{ name: "PEER_TURSO_AUTH_TOKEN", workers: [{ worker: "staff", environments: production }] },
|
|
1511
|
+
{ name: "SENTRY_WEBHOOK_SIGNING_SECRET", workers: [{ worker: "api", environments: production }] },
|
|
1512
|
+
{ name: "GITHUB_INTAKE_TOKEN", workers: [{ worker: "api", environments: production }] },
|
|
1513
|
+
{
|
|
1514
|
+
name: "EMAIL_RECIPIENT_ALLOWLIST",
|
|
1515
|
+
workers: ["api", "web", "staff"].map((worker) => ({ worker, environments: staging })),
|
|
1516
|
+
},
|
|
1517
|
+
{
|
|
1518
|
+
name: "SMS_RECIPIENT_ALLOWLIST",
|
|
1519
|
+
workers: ["api", "web", "staff"].map((worker) => ({ worker, environments: staging })),
|
|
1520
|
+
},
|
|
1521
|
+
],
|
|
1522
|
+
});
|
|
1523
|
+
const findings = await cloudflareFindings({
|
|
1524
|
+
manifest,
|
|
1525
|
+
present: {
|
|
1526
|
+
"staff-production": ["PEER_DATABASE_URL", "PEER_TURSO_AUTH_TOKEN"],
|
|
1527
|
+
"api-production": ["SENTRY_WEBHOOK_SIGNING_SECRET", "GITHUB_INTAKE_TOKEN"],
|
|
1528
|
+
"api-staging": ["EMAIL_RECIPIENT_ALLOWLIST", "SMS_RECIPIENT_ALLOWLIST"],
|
|
1529
|
+
"web-staging": ["EMAIL_RECIPIENT_ALLOWLIST", "SMS_RECIPIENT_ALLOWLIST"],
|
|
1530
|
+
"staff-staging": ["EMAIL_RECIPIENT_ALLOWLIST", "SMS_RECIPIENT_ALLOWLIST"],
|
|
1531
|
+
},
|
|
1532
|
+
});
|
|
1533
|
+
assert.deepEqual(findings, []);
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
test("KEY_SCHEMA names the per-environment worker entry so the script and the docs cannot drift", () => {
|
|
1537
|
+
assert.match(KEY_SCHEMA.residency, /worker, environments/);
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
test("a var-residency key is unaffected by the environment axis at the wrangler [vars] check", () => {
|
|
1541
|
+
// The wrangler check has no environment axis — it reports `environment: null`
|
|
1542
|
+
// and `parseWranglerVars` flattens `[env.X.vars]` into one set — so a var
|
|
1543
|
+
// declared for ONE environment stays expected in that worker's config.
|
|
1544
|
+
const root = makeRepo({ wrangler: '[env.staging.vars]\nSTAGING_ONLY_FLAG = "1"\n' });
|
|
1545
|
+
try {
|
|
1546
|
+
const manifest = parseManifest({
|
|
1547
|
+
environments: ["staging", "production"],
|
|
1548
|
+
workers: { web: { config: "wrangler.toml", scriptName: "swarm-web-{env}" } },
|
|
1549
|
+
keys: [
|
|
1550
|
+
{
|
|
1551
|
+
name: "STAGING_ONLY_FLAG",
|
|
1552
|
+
kind: "var",
|
|
1553
|
+
sensitivity: "public",
|
|
1554
|
+
residency: {
|
|
1555
|
+
local: null,
|
|
1556
|
+
github: null,
|
|
1557
|
+
cloudflare: { workers: [{ worker: "web", environments: ["staging"] }], kind: "var" },
|
|
1558
|
+
},
|
|
1559
|
+
infisical: "unmanaged",
|
|
1560
|
+
},
|
|
1561
|
+
],
|
|
1562
|
+
});
|
|
1563
|
+
const findings = runOfflineChecks({ manifest, repoRoot: root }).findings.filter(
|
|
1564
|
+
(f) => f.surface === "wrangler"
|
|
1565
|
+
);
|
|
1566
|
+
assert.deepEqual(findings, []);
|
|
1567
|
+
} finally {
|
|
1568
|
+
rmSync(root, { recursive: true, force: true });
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1342
1572
|
test("MANIFEST_SCHEMA and KEY_SCHEMA describe the slug container and the folders array", () => {
|
|
1343
1573
|
assert.ok(Object.hasOwn(MANIFEST_SCHEMA, "environmentSlugs"));
|
|
1344
1574
|
assert.match(MANIFEST_SCHEMA.environmentSlugs, /infisical/);
|
|
@@ -1354,6 +1584,19 @@ test("the documented manifest schema block names the new shapes", () => {
|
|
|
1354
1584
|
assert.match(doc, /"folders"/);
|
|
1355
1585
|
});
|
|
1356
1586
|
|
|
1587
|
+
test("the docs carry a Cloudflare per-environment residency section beside its two siblings", () => {
|
|
1588
|
+
// Same bargain as the test above, for the third surface to take the
|
|
1589
|
+
// treatment: the doc must describe the entry form the script now accepts.
|
|
1590
|
+
const doc = readFileSync(join(HERE, "..", "docs", "reusable-workflows.md"), "utf8");
|
|
1591
|
+
// `includes` + a message, not `assert.match`: a regex miss here dumps the
|
|
1592
|
+
// whole 250KB document into the failure output and buries the reason.
|
|
1593
|
+
assert.ok(
|
|
1594
|
+
doc.includes("#### Cloudflare Worker residency: per-environment presence"),
|
|
1595
|
+
"docs/reusable-workflows.md must carry the Cloudflare per-environment residency section"
|
|
1596
|
+
);
|
|
1597
|
+
assert.ok(doc.includes('"worker": "staff"'), "the manifest-schema block must show the object entry form");
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1357
1600
|
test("no secret VALUE reaches stdout or stderr through the remapped-slug, multi-folder path", async () => {
|
|
1358
1601
|
// The values-safety guarantee, re-asserted over the shapes #464 adds: a
|
|
1359
1602
|
// remapped environment slug and a key resident in two folders. Same
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# select-semgrep-python.sh — choose the interpreter the pr-quality SAST step
|
|
3
|
+
# builds its Semgrep venv from, and refuse to run on one that is too old
|
|
4
|
+
# (Story #482).
|
|
5
|
+
#
|
|
6
|
+
# WHY THIS EXISTS
|
|
7
|
+
# ---------------
|
|
8
|
+
# The SAST step installs Semgrep two ways: a hash-pinned closure from
|
|
9
|
+
# `scripts/semgrep-requirements.txt` on Linux/cp312, and the bare top pin
|
|
10
|
+
# everywhere else. That second branch is deliberate — it is where a Python
|
|
11
|
+
# roll-FORWARD degrades to, so a CI image moving to 3.13 goes
|
|
12
|
+
# unpinned-but-green rather than fleet-red on ABI-incompatible cp312 wheels.
|
|
13
|
+
#
|
|
14
|
+
# But it carried no floor of its own, so an interpreter that is too OLD
|
|
15
|
+
# hard-failed instead. semgrep raised `requires_python` to `>=3.10` at 1.137.0,
|
|
16
|
+
# and macOS ships `/usr/bin/python3` = 3.9.6, so every consumer on a macOS
|
|
17
|
+
# self-hosted runner with system Python went red on `ci-required` with nothing
|
|
18
|
+
# but pip's resolver error:
|
|
19
|
+
#
|
|
20
|
+
# ERROR: Could not find a version that satisfies the requirement
|
|
21
|
+
# semgrep==1.176.1 (from versions: ..., 1.135.0, 1.136.0)
|
|
22
|
+
#
|
|
23
|
+
# which reads like a network or registry problem and never names the
|
|
24
|
+
# interpreter as the cause (issue #480, observed in Beestera/swarm-os#2496).
|
|
25
|
+
#
|
|
26
|
+
# WHY IT DOES NOT JUST INSTALL AN OLDER SEMGREP
|
|
27
|
+
# ---------------------------------------------
|
|
28
|
+
# Resolving "the newest semgrep this interpreter supports" is the obvious fix
|
|
29
|
+
# and it is a security regression. The newest release supporting Python 3.9 is
|
|
30
|
+
# 1.136.0, which hard-pins `opentelemetry-*~=1.25.0`; `opentelemetry-proto` at
|
|
31
|
+
# that version requires `protobuf<5.0`, and EVERY protobuf 4.x is affected by
|
|
32
|
+
# CVE-2026-0994 (CVSS 8.2) — the advisory cleared by the 1.176.1 bump
|
|
33
|
+
# (#477/#472). The same downgrade drags back `opentelemetry-instrumentation`
|
|
34
|
+
# 0.46b0, whose `pkg_resources` import is why `setuptools` used to be in the
|
|
35
|
+
# closure at all. And because THIS install path is deliberately not
|
|
36
|
+
# hash-pinned and its closure is not OSV-scanned, the downgrade would be
|
|
37
|
+
# silent. So the floor fails closed: a usable interpreter or a named error,
|
|
38
|
+
# never a quieter, older Semgrep.
|
|
39
|
+
#
|
|
40
|
+
# CONTRACT
|
|
41
|
+
# --------
|
|
42
|
+
# SOURCE this file (do NOT exec it) from a `shell: bash` step, BEFORE the venv
|
|
43
|
+
# is created — the venv inherits whichever interpreter builds it, so a check
|
|
44
|
+
# made afterwards is already too late.
|
|
45
|
+
#
|
|
46
|
+
# Inputs (read from the caller's shell / the step `env:` block):
|
|
47
|
+
# SEMGREP_PIN = the exact pip requirement the step installs,
|
|
48
|
+
# e.g. `semgrep==1.176.1` (diagnostics only)
|
|
49
|
+
# SEMGREP_PYTHON_FLOOR = minimum `major.minor`, e.g. `3.10` — semgrep's
|
|
50
|
+
# own `requires_python` for the pinned version
|
|
51
|
+
#
|
|
52
|
+
# Outputs (set on the caller's shell):
|
|
53
|
+
# SEMGREP_PYTHON = the interpreter to build the venv with
|
|
54
|
+
# SEMGREP_PYTHON_VERSION = its `major.minor`
|
|
55
|
+
#
|
|
56
|
+
# Candidates are probed in order — `python3` first, so a compliant runner
|
|
57
|
+
# behaves exactly as it did before this file existed, then the versioned
|
|
58
|
+
# names newest-first. Returns non-zero after emitting a `::error::` when
|
|
59
|
+
# nothing on PATH qualifies; under the caller's `set -e` that fails the step.
|
|
60
|
+
|
|
61
|
+
_semgrep_python_probe() {
|
|
62
|
+
# Echo "<major> <minor>" for the interpreter named by $1, or return non-zero
|
|
63
|
+
# when it is absent from PATH or not a runnable interpreter.
|
|
64
|
+
local cmd="$1"
|
|
65
|
+
command -v "${cmd}" >/dev/null 2>&1 || return 1
|
|
66
|
+
"${cmd}" -c 'import sys; print("%d %d" % sys.version_info[:2])' 2>/dev/null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
select_semgrep_python() {
|
|
70
|
+
local floor="${SEMGREP_PYTHON_FLOOR:-}"
|
|
71
|
+
local pin="${SEMGREP_PIN:-<unset>}"
|
|
72
|
+
local floor_major floor_minor cmd probe major minor system_python
|
|
73
|
+
|
|
74
|
+
SEMGREP_PYTHON=""
|
|
75
|
+
SEMGREP_PYTHON_VERSION=""
|
|
76
|
+
|
|
77
|
+
# A missing or malformed floor must not silently degrade to "anything goes":
|
|
78
|
+
# that is the exact fail-open this file exists to close.
|
|
79
|
+
case "${floor}" in
|
|
80
|
+
[0-9]*.[0-9]*) ;;
|
|
81
|
+
*)
|
|
82
|
+
echo "::error::SEMGREP_PYTHON_FLOOR is unset or malformed ('${floor}') — it must be a major.minor version such as 3.10. Without it this step cannot tell whether the runner's Python is new enough to install ${pin}, and it will not guess."
|
|
83
|
+
return 1
|
|
84
|
+
;;
|
|
85
|
+
esac
|
|
86
|
+
floor_major="${floor%%.*}"
|
|
87
|
+
floor_minor="${floor#*.}"
|
|
88
|
+
floor_minor="${floor_minor%%.*}"
|
|
89
|
+
|
|
90
|
+
# Reported in the failure message: the interpreter a consumer would expect to
|
|
91
|
+
# be used, so the error names what they actually have rather than only what
|
|
92
|
+
# is required.
|
|
93
|
+
system_python="absent"
|
|
94
|
+
|
|
95
|
+
for cmd in python3 python3.13 python3.12 python3.11 python3.10; do
|
|
96
|
+
probe="$(_semgrep_python_probe "${cmd}")" || continue
|
|
97
|
+
read -r major minor <<<"${probe}"
|
|
98
|
+
[ -n "${major:-}" ] && [ -n "${minor:-}" ] || continue
|
|
99
|
+
if [ "${cmd}" = "python3" ]; then
|
|
100
|
+
system_python="${major}.${minor}"
|
|
101
|
+
fi
|
|
102
|
+
# Compare major and minor as SEPARATE integers. A concatenated "${major}${minor}"
|
|
103
|
+
# compares wrong across the tens boundary — "39" sorts above "310" as a
|
|
104
|
+
# string, and as an integer 39 is below 310 only by accident of digit count.
|
|
105
|
+
if [ "${major}" -gt "${floor_major}" ] ||
|
|
106
|
+
{ [ "${major}" -eq "${floor_major}" ] && [ "${minor}" -ge "${floor_minor}" ]; }; then
|
|
107
|
+
SEMGREP_PYTHON="${cmd}"
|
|
108
|
+
SEMGREP_PYTHON_VERSION="${major}.${minor}"
|
|
109
|
+
echo "Semgrep interpreter: ${cmd} (Python ${SEMGREP_PYTHON_VERSION}); floor ${floor} for ${pin}."
|
|
110
|
+
return 0
|
|
111
|
+
fi
|
|
112
|
+
done
|
|
113
|
+
|
|
114
|
+
echo "::error::${pin} requires Python >= ${floor}, but no interpreter on this runner's PATH satisfies it (python3 is ${system_python})."
|
|
115
|
+
echo "Probed, in order: python3 python3.13 python3.12 python3.11 python3.10."
|
|
116
|
+
echo "Remedy: put a Python >= ${floor} earlier on the runner's PATH than /usr/bin — e.g. 'brew install python@3.12' plus a python3 symlink in a directory the runner's .path lists first — or set 'enable-sast: false' to skip the Semgrep sub-step."
|
|
117
|
+
echo "Semgrep is deliberately NOT downgraded to fit an older interpreter: the newest release supporting Python 3.9 (1.136.0) pins opentelemetry ~=1.25.0, which caps protobuf below 5.0, and every protobuf 4.x is affected by CVE-2026-0994 (CVSS 8.2). This install path is not hash-pinned and its closure is not OSV-scanned, so the downgrade would be silent."
|
|
118
|
+
return 1
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# When EXECUTED directly (not sourced) — e.g. by the unit test — echo the
|
|
122
|
+
# selection as `KEY=value` lines and exit with the selection's own status, so
|
|
123
|
+
# both the happy path and the fail-closed path are assertable without a
|
|
124
|
+
# GitHub runner. `BASH_SOURCE[0] == $0` iff the file was run, not sourced.
|
|
125
|
+
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
|
|
126
|
+
if select_semgrep_python; then
|
|
127
|
+
printf 'SEMGREP_PYTHON=%s\n' "${SEMGREP_PYTHON}"
|
|
128
|
+
printf 'SEMGREP_PYTHON_VERSION=%s\n' "${SEMGREP_PYTHON_VERSION}"
|
|
129
|
+
exit 0
|
|
130
|
+
fi
|
|
131
|
+
exit 1
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
select_semgrep_python
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* select-semgrep-python.test.mjs — node:test suite for the SAST interpreter
|
|
4
|
+
* floor (Story #482).
|
|
5
|
+
*
|
|
6
|
+
* The pr-quality SAST step `source`s `select-semgrep-python.sh` before it
|
|
7
|
+
* creates the Semgrep venv, so this script's decision IS which interpreter
|
|
8
|
+
* the fleet's blocking SAST tier installs against. The suite executes the
|
|
9
|
+
* script directly (it echoes `SEMGREP_PYTHON=…` lines and exits with the
|
|
10
|
+
* selection's status when run rather than sourced) against fixture PATHs
|
|
11
|
+
* built from stub interpreters, so every branch is assertable without a
|
|
12
|
+
* macOS runner or a real Python matrix.
|
|
13
|
+
*
|
|
14
|
+
* Why stubs rather than the host's real interpreters: the failure this closes
|
|
15
|
+
* only reproduces on a runner whose `python3` is BELOW the floor, which no CI
|
|
16
|
+
* tier here provides. A stub that answers `-c` with a chosen `major minor` is
|
|
17
|
+
* the whole interface the script depends on.
|
|
18
|
+
*
|
|
19
|
+
* Run: node --test scripts/select-semgrep-python.test.mjs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import { mkdtempSync, statSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { test } from "node:test";
|
|
29
|
+
|
|
30
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const SCRIPT = join(HERE, "select-semgrep-python.sh");
|
|
32
|
+
|
|
33
|
+
const PIN = "semgrep==1.176.1";
|
|
34
|
+
const FLOOR = "3.10";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build a directory of stub interpreters and return its path. `versions` maps
|
|
38
|
+
* an interpreter name to the `major.minor` it should report; a stub ignores
|
|
39
|
+
* its arguments and echoes `"<major> <minor>"`, which is the only thing the
|
|
40
|
+
* script asks of it.
|
|
41
|
+
*/
|
|
42
|
+
function stubPath(versions) {
|
|
43
|
+
const dir = mkdtempSync(join(tmpdir(), "semgrep-python-stubs-"));
|
|
44
|
+
for (const [name, version] of Object.entries(versions)) {
|
|
45
|
+
const [major, minor] = version.split(".");
|
|
46
|
+
writeFileSync(join(dir, name), `#!/bin/sh\necho "${major} ${minor}"\n`, { mode: 0o755 });
|
|
47
|
+
}
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Execute the selector with the given PATH and env. Returns the exit status
|
|
53
|
+
* and stdout — the script writes its `::error::` annotation to stdout, which
|
|
54
|
+
* is where GitHub reads workflow commands from.
|
|
55
|
+
*/
|
|
56
|
+
function select({ path, floor = FLOOR, pin = PIN }) {
|
|
57
|
+
const env = { PATH: path };
|
|
58
|
+
if (floor !== null) env.SEMGREP_PYTHON_FLOOR = floor;
|
|
59
|
+
if (pin !== null) env.SEMGREP_PIN = pin;
|
|
60
|
+
try {
|
|
61
|
+
const stdout = execFileSync("/bin/bash", [SCRIPT], { encoding: "utf8", env });
|
|
62
|
+
return { status: 0, stdout };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return { status: err.status, stdout: err.stdout ?? "" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Parse the `KEY=value` lines the script emits when it succeeds. */
|
|
69
|
+
function parse(stdout) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const line of stdout.split("\n")) {
|
|
72
|
+
const eq = line.indexOf("=");
|
|
73
|
+
if (eq === -1) continue;
|
|
74
|
+
out[line.slice(0, eq)] = line.slice(eq + 1);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// 1. The script is a usable, sourceable artifact
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
test("the selector is executable", () => {
|
|
84
|
+
// The workflow sources it, but the suite executes it, and a non-executable
|
|
85
|
+
// file would pass `source` while failing every assertion below in a way
|
|
86
|
+
// that reads as a logic bug rather than a mode bug.
|
|
87
|
+
const mode = statSync(SCRIPT).mode;
|
|
88
|
+
assert.ok(mode & 0o111, "scripts/select-semgrep-python.sh must be executable");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// 2. Selection order — a compliant runner is unchanged
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
test("a python3 at the floor is selected as-is", () => {
|
|
96
|
+
const r = select({ path: stubPath({ python3: "3.10" }) });
|
|
97
|
+
assert.equal(r.status, 0);
|
|
98
|
+
const out = parse(r.stdout);
|
|
99
|
+
assert.equal(out.SEMGREP_PYTHON, "python3");
|
|
100
|
+
assert.equal(out.SEMGREP_PYTHON_VERSION, "3.10");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("python3 wins even when newer versioned interpreters are also present", () => {
|
|
104
|
+
// A runner that already works must not silently change interpreter — that
|
|
105
|
+
// would be a behaviour change shipped to the whole fleet as a side effect.
|
|
106
|
+
const r = select({ path: stubPath({ python3: "3.12", "python3.13": "3.13" }) });
|
|
107
|
+
assert.equal(r.status, 0);
|
|
108
|
+
assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// 3. Discovery — the reported consumer-side remedy, performed by the workflow
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
test("a below-floor python3 falls through to a qualifying python3.N", () => {
|
|
116
|
+
// The exact shape of issue #480: macOS /usr/bin/python3 is 3.9.6 while a
|
|
117
|
+
// brew-installed 3.12 sits on PATH under its versioned name.
|
|
118
|
+
const r = select({ path: stubPath({ python3: "3.9", "python3.12": "3.12" }) });
|
|
119
|
+
assert.equal(r.status, 0);
|
|
120
|
+
const out = parse(r.stdout);
|
|
121
|
+
assert.equal(out.SEMGREP_PYTHON, "python3.12");
|
|
122
|
+
assert.equal(out.SEMGREP_PYTHON_VERSION, "3.12");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("versioned candidates are probed newest-first", () => {
|
|
126
|
+
const r = select({
|
|
127
|
+
path: stubPath({ python3: "3.9", "python3.11": "3.11", "python3.13": "3.13" }),
|
|
128
|
+
});
|
|
129
|
+
assert.equal(r.status, 0);
|
|
130
|
+
assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3.13");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("3.9 is rejected and 3.10 accepted — the floor compares minor numerically", () => {
|
|
134
|
+
// Guards the tens-boundary trap: as strings "39" sorts ABOVE "310", so a
|
|
135
|
+
// concatenated comparison would accept 3.9 and reject the floor itself.
|
|
136
|
+
const rejected = select({ path: stubPath({ python3: "3.9" }) });
|
|
137
|
+
assert.equal(rejected.status, 1);
|
|
138
|
+
const accepted = select({ path: stubPath({ python3: "3.10" }) });
|
|
139
|
+
assert.equal(accepted.status, 0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a future major version satisfies the floor", () => {
|
|
143
|
+
const r = select({ path: stubPath({ python3: "4.0" }) });
|
|
144
|
+
assert.equal(r.status, 0);
|
|
145
|
+
assert.equal(parse(r.stdout).SEMGREP_PYTHON_VERSION, "4.0");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// 4. Fail closed, with an actionable message
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
test("no qualifying interpreter fails with an ::error:: naming floor, pin and version found", () => {
|
|
153
|
+
const r = select({ path: stubPath({ python3: "3.9" }) });
|
|
154
|
+
assert.equal(r.status, 1, "the step must fail rather than install something older");
|
|
155
|
+
|
|
156
|
+
const error = r.stdout.split("\n").find((l) => l.startsWith("::error::"));
|
|
157
|
+
assert.ok(error, "the failure must be a GitHub ::error:: annotation, not bare output");
|
|
158
|
+
assert.ok(error.includes(FLOOR), `the annotation must name the floor (${FLOOR}): ${error}`);
|
|
159
|
+
assert.ok(error.includes(PIN), `the annotation must name the pin (${PIN}): ${error}`);
|
|
160
|
+
assert.ok(
|
|
161
|
+
error.includes("3.9"),
|
|
162
|
+
`the annotation must name the interpreter version actually found: ${error}`,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("the failure names the PATH remedy and the enable-sast escape hatch", () => {
|
|
167
|
+
const r = select({ path: stubPath({ python3: "3.9" }) });
|
|
168
|
+
assert.ok(r.stdout.includes("PATH"), "the remedy must name PATH");
|
|
169
|
+
assert.ok(
|
|
170
|
+
r.stdout.includes("enable-sast"),
|
|
171
|
+
"the remedy must name the enable-sast escape hatch, since no semgrep-pin input exists",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("the failure records why semgrep is not downgraded instead", () => {
|
|
176
|
+
// The rationale belongs in the runner output: the next person to hit this
|
|
177
|
+
// reads the log, not the Story, and "just pin an older semgrep" is the
|
|
178
|
+
// wrong fix for a documented reason.
|
|
179
|
+
const r = select({ path: stubPath({ python3: "3.9" }) });
|
|
180
|
+
assert.ok(r.stdout.includes("CVE-2026-0994"), "must name the advisory a downgrade re-admits");
|
|
181
|
+
assert.ok(r.stdout.includes("1.136.0"), "must name the last py3.9-compatible release");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("an absent python3 is reported as absent, not as a version", () => {
|
|
185
|
+
const r = select({ path: stubPath({}) });
|
|
186
|
+
assert.equal(r.status, 1);
|
|
187
|
+
const error = r.stdout.split("\n").find((l) => l.startsWith("::error::"));
|
|
188
|
+
assert.ok(error.includes("absent"), `expected 'absent' in: ${error}`);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("a non-interpreter on PATH under an interpreter name is skipped, not trusted", () => {
|
|
192
|
+
// `command -v` finding the name is not proof it answers `-c`. A stub that
|
|
193
|
+
// exits non-zero must be passed over rather than selected with an empty
|
|
194
|
+
// version.
|
|
195
|
+
const dir = mkdtempSync(join(tmpdir(), "semgrep-python-stubs-"));
|
|
196
|
+
writeFileSync(join(dir, "python3"), "#!/bin/sh\nexit 127\n", { mode: 0o755 });
|
|
197
|
+
writeFileSync(join(dir, "python3.12"), '#!/bin/sh\necho "3 12"\n', { mode: 0o755 });
|
|
198
|
+
const r = select({ path: dir });
|
|
199
|
+
assert.equal(r.status, 0);
|
|
200
|
+
assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3.12");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// 5. The floor itself cannot go missing quietly
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
test("an unset floor fails closed rather than accepting any interpreter", () => {
|
|
208
|
+
const r = select({ path: stubPath({ python3: "3.9" }), floor: null });
|
|
209
|
+
assert.equal(r.status, 1);
|
|
210
|
+
const error = r.stdout.split("\n").find((l) => l.startsWith("::error::"));
|
|
211
|
+
assert.ok(error.includes("SEMGREP_PYTHON_FLOOR"), `expected the floor named in: ${error}`);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a malformed floor fails closed", () => {
|
|
215
|
+
const r = select({ path: stubPath({ python3: "3.12" }), floor: "latest" });
|
|
216
|
+
assert.equal(r.status, 1, "a malformed floor must not be treated as satisfied");
|
|
217
|
+
});
|