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.
@@ -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
+ });