mandrel-platform 1.13.0 → 1.13.2
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 +27 -3
- package/config/stryker.base.json +2 -0
- package/package.json +4 -2
- package/scripts/audit-check.mjs +210 -47
- package/scripts/audit-check.test.mjs +479 -49
- package/scripts/check-action-download-retries.mjs +155 -11
- package/scripts/check-action-download-retries.test.mjs +168 -3
- package/scripts/check-advisory-scan-setup.test.mjs +186 -5
- package/scripts/check-coverage-threshold.mjs +112 -17
- package/scripts/check-coverage-threshold.test.mjs +335 -0
- package/scripts/check-husky-hook-modes.test.mjs +134 -0
- package/scripts/check-runner-runs-on.test.mjs +223 -2
- package/scripts/check-semgrep-lockfile.test.mjs +238 -7
- package/scripts/check-workflow-lint-tier.test.mjs +176 -0
- package/scripts/check-workflow-portability.mjs +6 -3
- package/scripts/check-workflow-portability.test.mjs +1 -1
- package/scripts/env-doctor.mjs +493 -62
- package/scripts/env-doctor.test.mjs +529 -3
- package/scripts/fixtures/npm-audit-v2.json +66 -0
- package/scripts/install-git-hooks.mjs +123 -0
- package/scripts/install-git-hooks.test.mjs +160 -0
- package/scripts/issue-intake.test.mjs +351 -21
- package/scripts/runner-toggle.test.mjs +407 -0
- package/scripts/select-semgrep-python.sh +98 -17
- package/scripts/select-semgrep-python.test.mjs +139 -12
- package/scripts/stryker-base-config.test.mjs +34 -0
- package/scripts/update-semgrep-rules.mjs +83 -5
- package/scripts/update-semgrep-rules.test.mjs +55 -1
- package/scripts/workflow-lint-gate.test.mjs +128 -0
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import assert from "node:assert/strict";
|
|
23
|
-
import {
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
24
|
import { mkdtempSync, statSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { tmpdir } from "node:os";
|
|
26
26
|
import { dirname, join } from "node:path";
|
|
@@ -38,31 +38,42 @@ const FLOOR = "3.10";
|
|
|
38
38
|
* an interpreter name to the `major.minor` it should report; a stub ignores
|
|
39
39
|
* its arguments and echoes `"<major> <minor>"`, which is the only thing the
|
|
40
40
|
* script asks of it.
|
|
41
|
+
*
|
|
42
|
+
* `os`, when given, additionally plants a `uname` stub that reports it. The
|
|
43
|
+
* fixture PATH is the process's WHOLE PATH, so a stub `uname` is the only
|
|
44
|
+
* `uname` the script can resolve — which is what makes the Linux-only
|
|
45
|
+
* ABI-first branch assertable from a macOS laptop and a Linux runner alike.
|
|
46
|
+
* Omit it to model a runner where the OS cannot be read at all; the script
|
|
47
|
+
* must still choose an interpreter rather than dying in a command
|
|
48
|
+
* substitution.
|
|
41
49
|
*/
|
|
42
|
-
function stubPath(versions) {
|
|
50
|
+
function stubPath(versions, { os = null } = {}) {
|
|
43
51
|
const dir = mkdtempSync(join(tmpdir(), "semgrep-python-stubs-"));
|
|
44
52
|
for (const [name, version] of Object.entries(versions)) {
|
|
45
53
|
const [major, minor] = version.split(".");
|
|
46
54
|
writeFileSync(join(dir, name), `#!/bin/sh\necho "${major} ${minor}"\n`, { mode: 0o755 });
|
|
47
55
|
}
|
|
56
|
+
if (os !== null) {
|
|
57
|
+
// `uname -s` and `uname -m` are both answered; the selector only asks for
|
|
58
|
+
// `-s`, and a stub that ignores its flags cannot drift from that.
|
|
59
|
+
writeFileSync(join(dir, "uname"), `#!/bin/sh\necho "${os}"\n`, { mode: 0o755 });
|
|
60
|
+
}
|
|
48
61
|
return dir;
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
/**
|
|
52
|
-
* Execute the selector with the given PATH and env. Returns the exit status
|
|
53
|
-
* and
|
|
54
|
-
* is where GitHub reads workflow commands from
|
|
65
|
+
* Execute the selector with the given PATH and env. Returns the exit status,
|
|
66
|
+
* stdout and stderr — the script writes its `::error::` annotation to stdout,
|
|
67
|
+
* which is where GitHub reads workflow commands from, and mirrors the reason
|
|
68
|
+
* (without the prefix) to stderr for callers that capture only that stream.
|
|
55
69
|
*/
|
|
56
|
-
function select({ path, floor = FLOOR, pin = PIN }) {
|
|
70
|
+
function select({ path, floor = FLOOR, pin = PIN, lockfileAbi = null }) {
|
|
57
71
|
const env = { PATH: path };
|
|
58
72
|
if (floor !== null) env.SEMGREP_PYTHON_FLOOR = floor;
|
|
59
73
|
if (pin !== null) env.SEMGREP_PIN = pin;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
} catch (err) {
|
|
64
|
-
return { status: err.status, stdout: err.stdout ?? "" };
|
|
65
|
-
}
|
|
74
|
+
if (lockfileAbi !== null) env.SEMGREP_LOCKFILE_ABI = lockfileAbi;
|
|
75
|
+
const r = spawnSync("/bin/bash", [SCRIPT], { encoding: "utf8", env });
|
|
76
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
/** Parse the `KEY=value` lines the script emits when it succeeds. */
|
|
@@ -215,3 +226,119 @@ test("a malformed floor fails closed", () => {
|
|
|
215
226
|
const r = select({ path: stubPath({ python3: "3.12" }), floor: "latest" });
|
|
216
227
|
assert.equal(r.status, 1, "a malformed floor must not be treated as satisfied");
|
|
217
228
|
});
|
|
229
|
+
|
|
230
|
+
test("a malformed floor is named as malformed, not as bash's integer error", () => {
|
|
231
|
+
// `3.1O` is the letter O, and it is the exact shape a shape-only check
|
|
232
|
+
// misses: `[0-9]*.[0-9]*` pins the first character of each field, so the
|
|
233
|
+
// typo reached `[ ... -ge "1O" ]` and produced bash's own
|
|
234
|
+
// "integer expression expected" on stderr — a message that names the shell,
|
|
235
|
+
// never the input, and leaves the step to blame the runner's interpreters
|
|
236
|
+
// for a typo in its own configuration.
|
|
237
|
+
const r = select({ path: stubPath({ python3: "3.12" }), floor: "3.1O" });
|
|
238
|
+
assert.equal(r.status, 1, "a malformed floor must not be treated as satisfied");
|
|
239
|
+
assert.match(r.stderr, /malformed/, `the reason must reach stderr: ${r.stderr}`);
|
|
240
|
+
assert.ok(
|
|
241
|
+
r.stderr.includes("3.1O"),
|
|
242
|
+
`stderr must quote the offending value back: ${r.stderr}`,
|
|
243
|
+
);
|
|
244
|
+
const combined = `${r.stdout}${r.stderr}`;
|
|
245
|
+
assert.ok(
|
|
246
|
+
!combined.includes("integer expression expected"),
|
|
247
|
+
`the input must be rejected before it reaches an arithmetic test: ${combined}`,
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("a floor with no minor field is malformed rather than half-read", () => {
|
|
252
|
+
// `310` splits to major "310" and a minor equal to the whole string, which
|
|
253
|
+
// an unvalidated read would silently treat as 310.310.
|
|
254
|
+
const r = select({ path: stubPath({ python3: "3.12" }), floor: "310" });
|
|
255
|
+
assert.equal(r.status, 1);
|
|
256
|
+
assert.match(r.stderr, /malformed/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// 6. Lockfile-ABI-first probe order on Linux (Story #495)
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
test("on Linux the lockfile's ABI interpreter is probed before bare python3", () => {
|
|
264
|
+
// The failure this closes: the hash-pinned install is valid only on a cp312
|
|
265
|
+
// interpreter, so the day a CI image rolls bare `python3` to 3.13 the old
|
|
266
|
+
// order routed the WHOLE fleet onto the un-hash-pinned fallback while an
|
|
267
|
+
// eligible python3.12 sat unused on the same PATH.
|
|
268
|
+
const r = select({
|
|
269
|
+
path: stubPath({ python3: "3.13", "python3.12": "3.12" }, { os: "Linux" }),
|
|
270
|
+
lockfileAbi: "3.12",
|
|
271
|
+
});
|
|
272
|
+
assert.equal(r.status, 0);
|
|
273
|
+
const out = parse(r.stdout);
|
|
274
|
+
assert.equal(out.SEMGREP_PYTHON, "python3.12", "the lockfile ABI must win the probe order");
|
|
275
|
+
assert.equal(out.SEMGREP_PYTHON_VERSION, "3.12");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("on Darwin the same fixture still selects bare python3", () => {
|
|
279
|
+
// The mirror case, and the reason the OS is read rather than assumed: no
|
|
280
|
+
// darwin hashes are generated, so there is no ABI worth steering toward and
|
|
281
|
+
// the historical order must stand untouched.
|
|
282
|
+
const r = select({
|
|
283
|
+
path: stubPath({ python3: "3.13", "python3.12": "3.12" }, { os: "Darwin" }),
|
|
284
|
+
lockfileAbi: "3.12",
|
|
285
|
+
});
|
|
286
|
+
assert.equal(r.status, 0);
|
|
287
|
+
assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("with no lockfile ABI declared, Linux keeps the historical order", () => {
|
|
291
|
+
const r = select({
|
|
292
|
+
path: stubPath({ python3: "3.13", "python3.12": "3.12" }, { os: "Linux" }),
|
|
293
|
+
});
|
|
294
|
+
assert.equal(r.status, 0);
|
|
295
|
+
assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a malformed lockfile ABI is ignored, not fatal", () => {
|
|
299
|
+
// The ABI only reorders probing; the floor is the check that fails closed.
|
|
300
|
+
// Turning a bad optimisation hint into a hard stop would take the security
|
|
301
|
+
// tier down for every consumer over a cosmetic input.
|
|
302
|
+
const r = select({
|
|
303
|
+
path: stubPath({ python3: "3.13", "python3.12": "3.12" }, { os: "Linux" }),
|
|
304
|
+
lockfileAbi: "cp312",
|
|
305
|
+
});
|
|
306
|
+
assert.equal(r.status, 0);
|
|
307
|
+
assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("the ABI candidate still has to clear the floor", () => {
|
|
311
|
+
// Probing it first is an ordering preference, not an exemption: a
|
|
312
|
+
// below-floor python3.12 must fall through exactly as any other candidate
|
|
313
|
+
// does, because the install would fail on it either way.
|
|
314
|
+
const r = select({
|
|
315
|
+
path: stubPath({ python3: "3.13", "python3.12": "3.9" }, { os: "Linux" }),
|
|
316
|
+
lockfileAbi: "3.12",
|
|
317
|
+
});
|
|
318
|
+
assert.equal(r.status, 0);
|
|
319
|
+
assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("an absent uname does not break selection", () => {
|
|
323
|
+
// The selector runs under the caller's `set -e`, where an unguarded command
|
|
324
|
+
// substitution on a missing binary would abort the step before any
|
|
325
|
+
// interpreter was chosen.
|
|
326
|
+
const r = select({ path: stubPath({ python3: "3.12" }), lockfileAbi: "3.12" });
|
|
327
|
+
assert.equal(r.status, 0);
|
|
328
|
+
assert.equal(parse(r.stdout).SEMGREP_PYTHON, "python3");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("the fail-closed message reports the order actually probed", () => {
|
|
332
|
+
// A hard-coded list in the message would go stale the moment the ABI
|
|
333
|
+
// reorders it, and the log line is the only place a consumer can see which
|
|
334
|
+
// names were tried.
|
|
335
|
+
const r = select({
|
|
336
|
+
path: stubPath({ python3: "3.9" }, { os: "Linux" }),
|
|
337
|
+
lockfileAbi: "3.12",
|
|
338
|
+
});
|
|
339
|
+
assert.equal(r.status, 1);
|
|
340
|
+
assert.ok(
|
|
341
|
+
r.stdout.includes("Probed, in order: python3.12 python3 "),
|
|
342
|
+
`the probe order must be reported as it ran: ${r.stdout}`,
|
|
343
|
+
);
|
|
344
|
+
});
|
|
@@ -82,6 +82,30 @@ test("base config disables bail so every mutant runs its full covering set", ()
|
|
|
82
82
|
);
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The cap this base pins. Stryker's own default is `n-1` logical cores (`n`
|
|
87
|
+
* when `n <= 4`), so an omitted key is not a small overshoot — it is one
|
|
88
|
+
* worker per core, 17 on an 18-core host.
|
|
89
|
+
*/
|
|
90
|
+
const PINNED_CONCURRENCY = 1;
|
|
91
|
+
|
|
92
|
+
test("concurrency is capped so an omitted key cannot fan out to one worker per core", () => {
|
|
93
|
+
const config = readConfig();
|
|
94
|
+
|
|
95
|
+
// Equality, not presence. `Object.hasOwn(config, "concurrency")` passes on
|
|
96
|
+
// `concurrency: 17` — the very value the cap exists to prevent — so a
|
|
97
|
+
// presence check would read as covered while leaving the defect fully
|
|
98
|
+
// reachable. The number is the contract; assert the number.
|
|
99
|
+
assert.equal(
|
|
100
|
+
config.concurrency,
|
|
101
|
+
PINNED_CONCURRENCY,
|
|
102
|
+
`${CONFIG_PATH} must pin "concurrency": ${PINNED_CONCURRENCY}. Stryker ` +
|
|
103
|
+
"defaults it to one worker per core less one, so a consumer scope that " +
|
|
104
|
+
"spreads this base and omits the key takes cores no caller budgeted — " +
|
|
105
|
+
"on a shared runner host that reds a neighbouring repo's required checks.",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
85
109
|
test("timeouts are raised above both Stryker's defaults and the pre-change floor", async (t) => {
|
|
86
110
|
const config = readConfig();
|
|
87
111
|
|
|
@@ -172,6 +196,15 @@ test("the documented spread-import mechanism delivers the bail-free settings", a
|
|
|
172
196
|
`that bail-free runs need; got timeoutMS ${base.timeoutMS}.`,
|
|
173
197
|
);
|
|
174
198
|
|
|
199
|
+
assert.equal(
|
|
200
|
+
base.concurrency,
|
|
201
|
+
PINNED_CONCURRENCY,
|
|
202
|
+
`Importing "${PACKAGE_SPECIFIER}" must carry the concurrency cap. The ` +
|
|
203
|
+
"on-disk assertion above guards this repo; this one guards what a " +
|
|
204
|
+
"consumer's stryker.config.mjs actually receives, which is the only " +
|
|
205
|
+
"place the uncapped fan-out can happen.",
|
|
206
|
+
);
|
|
207
|
+
|
|
175
208
|
// The exports map must reach *this* file, or the assertions in the rest of
|
|
176
209
|
// this suite are guarding a config no consumer receives.
|
|
177
210
|
assert.deepEqual(
|
|
@@ -214,6 +247,7 @@ test("annotations use the `_comment` suffix Stryker's validator exempts", () =>
|
|
|
214
247
|
"coverageAnalysis",
|
|
215
248
|
"ignoreStatic",
|
|
216
249
|
"cleanTempDir",
|
|
250
|
+
"concurrency",
|
|
217
251
|
"disableBail",
|
|
218
252
|
"timeoutMS",
|
|
219
253
|
"timeoutFactor",
|
|
@@ -79,8 +79,19 @@ const REPO_ROOT = resolve(__dirname, "..");
|
|
|
79
79
|
// generate the file is a (harmless but inconsistent) version skew.
|
|
80
80
|
const DEFAULT_SEMGREP_PIN = "semgrep==1.176.1";
|
|
81
81
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
82
|
+
// Minimum interpreter for the pin above — semgrep's own `requires_python`,
|
|
83
|
+
// and the same value `SEMGREP_PYTHON_FLOOR` carries in pr-quality.yml's SAST
|
|
84
|
+
// step. Passed to the shared selector below so this script fails with the
|
|
85
|
+
// same named error a runner gets, rather than pip's "could not find a version
|
|
86
|
+
// that satisfies the requirement", which reads like a registry outage.
|
|
87
|
+
const DEFAULT_SEMGREP_PYTHON_FLOOR = "3.10";
|
|
88
|
+
|
|
89
|
+
// SHA-256 hashes for all 8 artifacts published on PyPI for
|
|
90
|
+
// `DEFAULT_SEMGREP_PIN` — the platform wheels plus the sdist. The count is
|
|
91
|
+
// asserted against this map by `check-semgrep-lockfile.test.mjs`: it read
|
|
92
|
+
// "the four platform wheels + the sdist" while the array below held eight,
|
|
93
|
+
// which is exactly the kind of stale gloss that makes a reader trust a
|
|
94
|
+
// partial hash set. pip's `--require-hashes` mode
|
|
84
95
|
// verifies the downloaded `semgrep` artifact against this set before it is
|
|
85
96
|
// installed, so a compromised or swapped PyPI artifact for this exact version
|
|
86
97
|
// is rejected at install time — the same "pin the supply-chain input" posture
|
|
@@ -170,11 +181,72 @@ function parseArgs(argv) {
|
|
|
170
181
|
return opts;
|
|
171
182
|
}
|
|
172
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Return the interpreter name to build the vendoring venv from, by RUNNING
|
|
186
|
+
* `scripts/select-semgrep-python.sh` — the same selector `pr-quality.yml`'s
|
|
187
|
+
* SAST step sources. Executed rather than sourced: run directly, the script
|
|
188
|
+
* prints `SEMGREP_PYTHON=<name>` / `SEMGREP_PYTHON_VERSION=<x.y>` and exits
|
|
189
|
+
* with the selection's own status, which is the whole interface a non-shell
|
|
190
|
+
* caller needs.
|
|
191
|
+
*
|
|
192
|
+
* A hard-coded `python3` was the alternative, and it is wrong for the reason
|
|
193
|
+
* the selector exists: macOS ships `/usr/bin/python3` = 3.9.6, below the pin's
|
|
194
|
+
* `requires_python`, so this script would install nothing and report pip's
|
|
195
|
+
* resolver error rather than naming the interpreter. Sharing the selector also
|
|
196
|
+
* means the two callers cannot drift on what "acceptable" means.
|
|
197
|
+
*
|
|
198
|
+
* @param {object} [options]
|
|
199
|
+
* @param {NodeJS.ProcessEnv} [options.env] environment the selector runs
|
|
200
|
+
* under. Defaults to this process's. Injectable so the unit suite can hand
|
|
201
|
+
* it a fixture PATH of stub interpreters instead of asserting against
|
|
202
|
+
* whatever Python the host running the tests happens to ship.
|
|
203
|
+
* @returns {string} an interpreter resolvable on PATH
|
|
204
|
+
*/
|
|
205
|
+
export function selectPythonInterpreter({ env = process.env } = {}) {
|
|
206
|
+
const selector = join(REPO_ROOT, "scripts", "select-semgrep-python.sh");
|
|
207
|
+
// `/bin/bash` absolutely, never a bare `bash`: the PATH this resolves
|
|
208
|
+
// against is the CALLER'S `env`, so a bare name would make the interpreter
|
|
209
|
+
// itself a function of the very variable under test — and a caller passing a
|
|
210
|
+
// narrowed PATH would get a different bash, or none.
|
|
211
|
+
const result = spawnSync("/bin/bash", [selector], {
|
|
212
|
+
encoding: "utf8",
|
|
213
|
+
env: {
|
|
214
|
+
...env,
|
|
215
|
+
SEMGREP_PIN: DEFAULT_SEMGREP_PIN,
|
|
216
|
+
SEMGREP_PYTHON_FLOOR: DEFAULT_SEMGREP_PYTHON_FLOOR,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// The selector's own `::error::` explains the failure far better than a
|
|
221
|
+
// wrapper could — it names the floor, the pin, the version found and the
|
|
222
|
+
// remedy — so it is surfaced verbatim rather than summarized away.
|
|
223
|
+
if (result.status !== 0) {
|
|
224
|
+
process.stderr.write(result.stdout ?? "");
|
|
225
|
+
process.stderr.write(result.stderr ?? "");
|
|
226
|
+
throw new Error(
|
|
227
|
+
`no interpreter on PATH satisfies Python >= ${DEFAULT_SEMGREP_PYTHON_FLOOR} for ${DEFAULT_SEMGREP_PIN}`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const line = (result.stdout ?? "")
|
|
232
|
+
.split("\n")
|
|
233
|
+
.find((l) => l.startsWith("SEMGREP_PYTHON="));
|
|
234
|
+
const selected = line ? line.slice("SEMGREP_PYTHON=".length).trim() : "";
|
|
235
|
+
if (selected === "") {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`${selector} exited 0 without reporting SEMGREP_PYTHON — its run-not-source output contract changed`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return selected;
|
|
241
|
+
}
|
|
242
|
+
|
|
173
243
|
/**
|
|
174
244
|
* Resolve `p/default` against a pinned, ephemeral Semgrep install and return
|
|
175
245
|
* the full rule list as parsed JSON objects. Mirrors the hermetic-venv
|
|
176
246
|
* install strategy `pr-quality.yml`'s SAST step uses (Story #92): an
|
|
177
|
-
* ephemeral
|
|
247
|
+
* ephemeral venv under `mktemp -d`, never the shared user site — built from
|
|
248
|
+
* the interpreter `scripts/select-semgrep-python.sh` picks, so this script and
|
|
249
|
+
* the SAST step cannot disagree about which Python is acceptable.
|
|
178
250
|
*/
|
|
179
251
|
function resolveRegistryRules(semgrepPin) {
|
|
180
252
|
const venvDir = join(mkdtempSync(join(tmpdir(), "semgrep-vendor-")), "venv");
|
|
@@ -183,7 +255,7 @@ function resolveRegistryRules(semgrepPin) {
|
|
|
183
255
|
const reqsFile = join(mkdtempSync(join(tmpdir(), "semgrep-vendor-reqs-")), "semgrep.txt");
|
|
184
256
|
|
|
185
257
|
try {
|
|
186
|
-
spawnSync(
|
|
258
|
+
spawnSync(selectPythonInterpreter(), ["-m", "venv", venvDir], { stdio: "inherit" });
|
|
187
259
|
const pip = join(venvDir, "bin", "pip");
|
|
188
260
|
const semgrep = join(venvDir, "bin", "semgrep");
|
|
189
261
|
|
|
@@ -234,9 +306,15 @@ function resolveRegistryRules(semgrepPin) {
|
|
|
234
306
|
);
|
|
235
307
|
}
|
|
236
308
|
|
|
309
|
+
// Dependencies only — semgrep itself is already installed, hash-verified,
|
|
310
|
+
// above. Nothing is added to this list: the package that used to sit here
|
|
311
|
+
// was needed solely because semgrep 1.97.0's transitive
|
|
312
|
+
// `opentelemetry-instrumentation==0.46b0` imported `pkg_resources` at
|
|
313
|
+
// load, which 0.58b0 no longer does. Re-adding it would drag its own
|
|
314
|
+
// advisories back into a venv this script then runs.
|
|
237
315
|
const install = spawnSync(
|
|
238
316
|
pip,
|
|
239
|
-
["install", "--quiet", "--disable-pip-version-check",
|
|
317
|
+
["install", "--quiet", "--disable-pip-version-check", semgrepPin],
|
|
240
318
|
{ stdio: "inherit" }
|
|
241
319
|
);
|
|
242
320
|
if (install.status !== 0) {
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
import assert from "node:assert/strict";
|
|
18
18
|
import { test } from "node:test";
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { buildVendoredRuleset, selectPythonInterpreter } from "./update-semgrep-rules.mjs";
|
|
21
25
|
|
|
22
26
|
function rule(id, languages) {
|
|
23
27
|
return { id, languages, message: "m", severity: "ERROR", metadata: {} };
|
|
@@ -148,3 +152,53 @@ test("the committed .semgrep/rules.json vendored file is well-formed and non-emp
|
|
|
148
152
|
"pnpm trustPolicy rule must remain in force (Story #132 AC)"
|
|
149
153
|
);
|
|
150
154
|
});
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// The vendoring venv is built from the shared interpreter selector (#495)
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build a stub interpreter directory to be used as the WHOLE PATH.
|
|
162
|
+
*
|
|
163
|
+
* The fixture must be the whole PATH, not a prefix of it: appending a real
|
|
164
|
+
* directory leaks that host's interpreters into the probe, and the selector
|
|
165
|
+
* tries every versioned candidate rather than `python3` alone. On a
|
|
166
|
+
* merged-usr Linux runner `/bin/python3.12` is real and clears the floor, so
|
|
167
|
+
* a `${dir}:/bin` PATH quietly passed on macOS and failed in CI — the exact
|
|
168
|
+
* host-dependence this Story exists to remove from the SAST step.
|
|
169
|
+
*
|
|
170
|
+
* A `uname` stub ships with the interpreters because it is the selector's one
|
|
171
|
+
* external command, and a stub-only PATH could not otherwise resolve it. This
|
|
172
|
+
* mirrors `select-semgrep-python.test.mjs`'s own fixture builder.
|
|
173
|
+
*/
|
|
174
|
+
function pythonStubDir(versions, { os = "Linux" } = {}) {
|
|
175
|
+
const dir = mkdtempSync(join(tmpdir(), "updater-python-stubs-"));
|
|
176
|
+
for (const [name, version] of Object.entries(versions)) {
|
|
177
|
+
const [major, minor] = version.split(".");
|
|
178
|
+
writeFileSync(join(dir, name), `#!/bin/sh\necho "${major} ${minor}"\n`, { mode: 0o755 });
|
|
179
|
+
}
|
|
180
|
+
writeFileSync(join(dir, "uname"), `#!/bin/sh\necho "${os}"\n`, { mode: 0o755 });
|
|
181
|
+
return dir;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
test("selectPythonInterpreter returns the selector's choice, not a hard-coded python3", () => {
|
|
185
|
+
// A fixture PATH rather than the host's real interpreters: the branch that
|
|
186
|
+
// matters is a below-floor `python3` with a versioned sibling, which no CI
|
|
187
|
+
// tier here provides and a developer laptop provides only by accident.
|
|
188
|
+
const dir = pythonStubDir({ python3: "3.9", "python3.12": "3.12" });
|
|
189
|
+
|
|
190
|
+
assert.equal(selectPythonInterpreter({ env: { PATH: dir } }), "python3.12");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("selectPythonInterpreter fails closed when nothing clears the floor", () => {
|
|
194
|
+
// Hard-coding `python3` here installed nothing on macOS system Python and
|
|
195
|
+
// reported pip's resolver error, which names neither the interpreter nor
|
|
196
|
+
// the floor. Sharing the selector means this script fails the same way the
|
|
197
|
+
// SAST step does.
|
|
198
|
+
const dir = pythonStubDir({ python3: "3.9" });
|
|
199
|
+
|
|
200
|
+
assert.throws(
|
|
201
|
+
() => selectPythonInterpreter({ env: { PATH: dir } }),
|
|
202
|
+
/satisfies Python >= 3\.10/,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
// The other invariant these tests hold: a missing or malformed report is a
|
|
12
12
|
// TOOL FAILURE and exits non-zero even in advisory mode. A gate that reports
|
|
13
13
|
// "no findings" because the linter never ran is worse than no gate at all.
|
|
14
|
+
//
|
|
15
|
+
// Story #496 adds the third class and pins its split from the second: an
|
|
16
|
+
// INFRASTRUCTURE failure — a tool that never arrived at all, because the
|
|
17
|
+
// release CDN blipped, the checksum did not match, or the platform has no
|
|
18
|
+
// pinned entry — obeys the same dial findings do. It used to `exit 1` inside
|
|
19
|
+
// the composite's inline bash, so a CDN blip reddened every consumer's
|
|
20
|
+
// required check while this tier was documented as advisory. What it must
|
|
21
|
+
// never become is silent: an advisory infra failure still says, loudly, that
|
|
22
|
+
// nothing was linted.
|
|
14
23
|
|
|
15
24
|
import { test } from "node:test";
|
|
16
25
|
import assert from "node:assert/strict";
|
|
@@ -30,9 +39,21 @@ import {
|
|
|
30
39
|
loadReport,
|
|
31
40
|
severityRank,
|
|
32
41
|
resolveEnforcement,
|
|
42
|
+
classifyInfraFailure,
|
|
43
|
+
renderInfraSummary,
|
|
44
|
+
INFRA_FAILURE_ENV,
|
|
33
45
|
WorkflowLintGateError,
|
|
34
46
|
} from "../.github/actions/workflow-lint/workflow-lint-gate.mjs";
|
|
35
47
|
|
|
48
|
+
// A composite-supplied reason, in the shape action.yml actually emits. Kept
|
|
49
|
+
// free of any URL-shaped literal on purpose: asserting a substring against one
|
|
50
|
+
// is CodeQL js/incomplete-url-substring-sanitization, so the marker asserted
|
|
51
|
+
// on below is the slug prefix, never a host.
|
|
52
|
+
const INFRA_REASON =
|
|
53
|
+
"download-failed: curl exited 22 fetching actionlint_1.7.12_linux_amd64.tar.gz " +
|
|
54
|
+
"from the actionlint release assets.";
|
|
55
|
+
const INFRA_MARKER = "download-failed";
|
|
56
|
+
|
|
36
57
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
37
58
|
const GATE = join(repoRoot, ".github/actions/workflow-lint/workflow-lint-gate.mjs");
|
|
38
59
|
|
|
@@ -423,3 +444,110 @@ test("CLI: only the literal string 'true' enables enforcement", () => {
|
|
|
423
444
|
assert.equal(res.status, 0, `enforce=${JSON.stringify(value)} must stay advisory`);
|
|
424
445
|
}
|
|
425
446
|
});
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Infrastructure failure — a tool that never arrived (Story #496, AC-1)
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
test("classifyInfraFailure: no reason means there is nothing to report", () => {
|
|
453
|
+
assert.equal(classifyInfraFailure(undefined, false), null);
|
|
454
|
+
assert.equal(classifyInfraFailure("", true), null);
|
|
455
|
+
assert.equal(classifyInfraFailure(" \n ", true), null, "whitespace is not a reason");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("classifyInfraFailure: advisory tier warns and does NOT fail", () => {
|
|
459
|
+
const infra = classifyInfraFailure(INFRA_REASON, { actionlint: false, zizmor: false });
|
|
460
|
+
assert.equal(infra.enforced, false);
|
|
461
|
+
assert.equal(infra.level, "warning");
|
|
462
|
+
assert.equal(infra.exitCode, 0, "a CDN blip must not red an advisory tier");
|
|
463
|
+
assert.ok(infra.message.includes(INFRA_MARKER), infra.message);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("classifyInfraFailure: an enforcing tier fails — tier-wide or per tool", () => {
|
|
467
|
+
assert.equal(classifyInfraFailure(INFRA_REASON, true).exitCode, 1);
|
|
468
|
+
assert.equal(classifyInfraFailure(INFRA_REASON, true).level, "error");
|
|
469
|
+
// A consumer who enforced ONE tool still asked this tier to block, so a
|
|
470
|
+
// gate that could not run is a failure for them.
|
|
471
|
+
const perTool = classifyInfraFailure(INFRA_REASON, { actionlint: true, zizmor: false });
|
|
472
|
+
assert.equal(perTool.enforced, true);
|
|
473
|
+
assert.equal(perTool.exitCode, 1);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("renderInfraSummary states the posture and that nothing was linted", () => {
|
|
477
|
+
const advisory = renderInfraSummary(classifyInfraFailure(INFRA_REASON, false));
|
|
478
|
+
assert.match(advisory, /advisory/);
|
|
479
|
+
assert.match(advisory, /no workflows were linted/i);
|
|
480
|
+
assert.ok(advisory.includes(INFRA_MARKER), advisory);
|
|
481
|
+
const enforcing = renderInfraSummary(classifyInfraFailure(INFRA_REASON, true));
|
|
482
|
+
assert.match(enforcing, /enforcing/);
|
|
483
|
+
assert.doesNotMatch(enforcing, /does \*\*not\*\* fail/);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("CLI: an infra failure exits 0 and warns while nothing is enforced", () => {
|
|
487
|
+
const res = runGate({
|
|
488
|
+
[INFRA_FAILURE_ENV]: INFRA_REASON,
|
|
489
|
+
WORKFLOW_LINT_ENFORCE: "false",
|
|
490
|
+
WORKFLOW_LINT_ENFORCE_ACTIONLINT: "false",
|
|
491
|
+
WORKFLOW_LINT_ENFORCE_ZIZMOR: "false",
|
|
492
|
+
});
|
|
493
|
+
assert.equal(res.status, 0, res.stderr);
|
|
494
|
+
assert.match(res.stdout, /::warning::/);
|
|
495
|
+
assert.doesNotMatch(res.stdout, /::error::/);
|
|
496
|
+
assert.ok(res.stdout.includes(INFRA_MARKER), res.stdout);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("CLI: the same infra failure exits 1 and errors when the tier is enforced", () => {
|
|
500
|
+
const res = runGate({
|
|
501
|
+
[INFRA_FAILURE_ENV]: INFRA_REASON,
|
|
502
|
+
WORKFLOW_LINT_ENFORCE: "true",
|
|
503
|
+
});
|
|
504
|
+
assert.equal(res.status, 1);
|
|
505
|
+
assert.match(res.stdout, /::error::/);
|
|
506
|
+
assert.ok(res.stdout.includes(INFRA_MARKER), res.stdout);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("CLI: a per-tool enforce alone makes an infra failure blocking", () => {
|
|
510
|
+
const res = runGate({
|
|
511
|
+
[INFRA_FAILURE_ENV]: INFRA_REASON,
|
|
512
|
+
WORKFLOW_LINT_ENFORCE: "false",
|
|
513
|
+
WORKFLOW_LINT_ENFORCE_ZIZMOR: "true",
|
|
514
|
+
});
|
|
515
|
+
assert.equal(res.status, 1);
|
|
516
|
+
assert.match(res.stdout, /::error::/);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("CLI: an advisory infra failure is never reported as a clean run", () => {
|
|
520
|
+
// The empty reports the composite writes alongside the signal carry no
|
|
521
|
+
// information. Rendering them as "no findings" would turn a gate that never
|
|
522
|
+
// ran into a green tick that looks audited.
|
|
523
|
+
const res = runGate({
|
|
524
|
+
[INFRA_FAILURE_ENV]: INFRA_REASON,
|
|
525
|
+
WORKFLOW_LINT_ENFORCE: "false",
|
|
526
|
+
});
|
|
527
|
+
assert.equal(res.status, 0, res.stderr);
|
|
528
|
+
assert.doesNotMatch(res.stdout, /no findings/);
|
|
529
|
+
assert.doesNotMatch(res.stdout, /reported no findings/);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("CLI: an unset infra reason leaves the findings path untouched", () => {
|
|
533
|
+
const res = runGate({ [INFRA_FAILURE_ENV]: "" }, { zizmor: [zizmorRow()] });
|
|
534
|
+
assert.equal(res.status, 0, res.stderr);
|
|
535
|
+
assert.match(res.stdout, /excessive-permissions/);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("an infra failure does not relax the missing-report rule", () => {
|
|
539
|
+
// Different class, different answer: a report that never arrived means the
|
|
540
|
+
// linter ran and produced nothing readable, which stays fatal.
|
|
541
|
+
const res = spawnSync(process.execPath, [GATE], {
|
|
542
|
+
encoding: "utf8",
|
|
543
|
+
env: {
|
|
544
|
+
...process.env,
|
|
545
|
+
ACTIONLINT_REPORT: "/no/such/al.json",
|
|
546
|
+
ZIZMOR_REPORT: "",
|
|
547
|
+
WORKFLOW_LINT_ENFORCE: "false",
|
|
548
|
+
[INFRA_FAILURE_ENV]: "",
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
assert.equal(res.status, 1);
|
|
552
|
+
assert.match(res.stderr, /::error::workflow-lint gate/);
|
|
553
|
+
});
|