mandrel-platform 1.13.0 → 1.13.1
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 +6 -2
- 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/update-semgrep-rules.mjs +83 -5
- package/scripts/update-semgrep-rules.test.mjs +55 -1
- package/scripts/workflow-lint-gate.test.mjs +128 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-husky-hook-modes.test.mjs — regression guard for the mode bit that
|
|
4
|
+
* decides whether this repository's git hooks run at all.
|
|
5
|
+
*
|
|
6
|
+
* The bug this pins: `.husky/pre-commit` was tracked at mode 100644. Git
|
|
7
|
+
* 2.36+ refuses to execute a hook that is not marked executable, and the way
|
|
8
|
+
* it refuses is the problem — it prints
|
|
9
|
+
*
|
|
10
|
+
* hint: The '.../.husky/pre-commit' hook was ignored because it's not
|
|
11
|
+
* set as executable.
|
|
12
|
+
*
|
|
13
|
+
* to stderr and then lets the commit SUCCEED with exit 0. So the quality gate
|
|
14
|
+
* the hook exists to run (`quality-preview.js`, which blocks MI/CRAP drift at
|
|
15
|
+
* commit time) never ran for anybody, in any clone, from the day it was
|
|
16
|
+
* added — and nothing anywhere went red to say so. An inert gate and a
|
|
17
|
+
* passing gate are indistinguishable from the outside.
|
|
18
|
+
*
|
|
19
|
+
* Why the mode is asserted against the INDEX and not the filesystem. `chmod`
|
|
20
|
+
* on the working copy is not the fix and would not be caught here: git stores
|
|
21
|
+
* only two file modes, 100644 and 100755, and it is the tracked one that
|
|
22
|
+
* every fresh clone and every `git checkout` materializes. A working tree can
|
|
23
|
+
* be executable while the committed mode is not (and vice versa), so a
|
|
24
|
+
* `statSync` check would go green on the maintainer's machine and ship the
|
|
25
|
+
* defect to everyone else. `git ls-files -s` reads the index, which is the
|
|
26
|
+
* mode that actually propagates.
|
|
27
|
+
*
|
|
28
|
+
* Why this is not a one-line assertion on one path. The upstream cause is
|
|
29
|
+
* still live: the hook is installed by the vendored bootstrap
|
|
30
|
+
* (`.agents/scripts/lib/bootstrap/quality-bootstrap.js`) with a bare
|
|
31
|
+
* `fs.writeFileSync` and no `chmod`, so a hook file CREATED by a fresh
|
|
32
|
+
* bootstrap run is born 0644. (An existing file survives — `writeFileSync`
|
|
33
|
+
* truncates in place and preserves the mode — which is why fixing the tracked
|
|
34
|
+
* mode holds.) `.agents/**` is vendored payload re-materialized from the
|
|
35
|
+
* mandrel package, so that cause cannot be fixed here; this guard is the
|
|
36
|
+
* compensating control, and it therefore covers every hook the directory may
|
|
37
|
+
* grow, not just the one that was broken.
|
|
38
|
+
*
|
|
39
|
+
* Run: node --test scripts/check-husky-hook-modes.test.mjs
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import assert from "node:assert/strict";
|
|
43
|
+
import { test } from "node:test";
|
|
44
|
+
import { execFileSync } from "node:child_process";
|
|
45
|
+
|
|
46
|
+
const HOOKS_DIR = ".husky";
|
|
47
|
+
const EXECUTABLE = "100755";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every client-side hook name git will invoke. A file in `.husky/` whose name
|
|
51
|
+
* is not on this list is never run by git no matter what its mode is, so a
|
|
52
|
+
* typo (`pre_commit`, `precommit`) is the same silent no-op as a missing
|
|
53
|
+
* executable bit and is asserted against below.
|
|
54
|
+
*
|
|
55
|
+
* Source: `githooks(5)`, client-side hooks only — server-side hooks
|
|
56
|
+
* (`pre-receive`, `update`, `post-receive`) cannot fire from a clone.
|
|
57
|
+
*/
|
|
58
|
+
const GIT_CLIENT_HOOKS = new Set([
|
|
59
|
+
"applypatch-msg",
|
|
60
|
+
"pre-applypatch",
|
|
61
|
+
"post-applypatch",
|
|
62
|
+
"pre-commit",
|
|
63
|
+
"pre-merge-commit",
|
|
64
|
+
"prepare-commit-msg",
|
|
65
|
+
"commit-msg",
|
|
66
|
+
"post-commit",
|
|
67
|
+
"pre-rebase",
|
|
68
|
+
"post-checkout",
|
|
69
|
+
"post-merge",
|
|
70
|
+
"pre-push",
|
|
71
|
+
"pre-auto-gc",
|
|
72
|
+
"post-rewrite",
|
|
73
|
+
"sendemail-validate",
|
|
74
|
+
"post-index-change",
|
|
75
|
+
"reference-transaction",
|
|
76
|
+
"push-to-checkout",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Tracked entries directly under `.husky/`, as `{ mode, path, name }`.
|
|
81
|
+
*
|
|
82
|
+
* `git ls-files -s` emits `<mode> <object> <stage>\t<path>`. Husky's own
|
|
83
|
+
* `_/` shim directory and dotfiles (`.gitignore`) are not hooks and are
|
|
84
|
+
* excluded; everything else in the directory is one by convention.
|
|
85
|
+
*/
|
|
86
|
+
function trackedHooks() {
|
|
87
|
+
const out = execFileSync("git", ["ls-files", "-s", "--", HOOKS_DIR], {
|
|
88
|
+
encoding: "utf8",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return out
|
|
92
|
+
.split("\n")
|
|
93
|
+
.filter((line) => line.length > 0)
|
|
94
|
+
.map((line) => {
|
|
95
|
+
const [meta, path] = line.split("\t");
|
|
96
|
+
return { mode: meta.split(" ")[0], path, name: path.slice(HOOKS_DIR.length + 1) };
|
|
97
|
+
})
|
|
98
|
+
.filter(({ name }) => !name.startsWith(".") && !name.startsWith("_/"));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test("the quality-gate pre-commit hook is still tracked", () => {
|
|
102
|
+
// Without this the mode assertion below passes vacuously the moment the
|
|
103
|
+
// hook is deleted — which is the same outcome (no gate) by another route.
|
|
104
|
+
const names = trackedHooks().map((h) => h.name);
|
|
105
|
+
assert.ok(
|
|
106
|
+
names.includes("pre-commit"),
|
|
107
|
+
`${HOOKS_DIR}/pre-commit is not tracked; the commit-time quality gate would not run. Tracked: ${names.join(", ") || "(none)"}`,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("every tracked hook under .husky/ is executable in the index", () => {
|
|
112
|
+
const hooks = trackedHooks();
|
|
113
|
+
assert.ok(hooks.length > 0, `no tracked hooks found under ${HOOKS_DIR}/`);
|
|
114
|
+
|
|
115
|
+
const nonExecutable = hooks.filter((h) => h.mode !== EXECUTABLE);
|
|
116
|
+
assert.deepEqual(
|
|
117
|
+
nonExecutable.map((h) => `${h.path} (${h.mode})`),
|
|
118
|
+
[],
|
|
119
|
+
`git 2.36+ silently IGNORES a non-executable hook and lets the commit succeed with exit 0. ` +
|
|
120
|
+
`Fix the tracked mode with: git update-index --chmod=+x <path> (a bare chmod does not change it).`,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("every tracked hook under .husky/ has a name git will actually invoke", () => {
|
|
125
|
+
const unknown = trackedHooks()
|
|
126
|
+
.map((h) => h.name)
|
|
127
|
+
.filter((name) => !GIT_CLIENT_HOOKS.has(name));
|
|
128
|
+
|
|
129
|
+
assert.deepEqual(
|
|
130
|
+
unknown,
|
|
131
|
+
[],
|
|
132
|
+
`git invokes hooks by exact filename; a name outside githooks(5) never runs, as silently as a non-executable one.`,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* check-runner-runs-on.test.mjs — regression guard for
|
|
4
|
-
*
|
|
3
|
+
* check-runner-runs-on.test.mjs — regression guard for every shape the
|
|
4
|
+
* `runner` input can arrive in (Stories #421, #493).
|
|
5
5
|
*
|
|
6
6
|
* The bug this pins: every `runs-on:` site consumed the input raw as
|
|
7
7
|
* `${{ inputs.runner }}`. GitHub does not parse a JSON-array *string* in that
|
|
@@ -21,6 +21,25 @@
|
|
|
21
21
|
* expression from the workflow and EVALUATES it under Actions semantics, the
|
|
22
22
|
* same read-then-execute approach as check-toolchain-cache-default.test.mjs.
|
|
23
23
|
*
|
|
24
|
+
* The same failure had a second door, closed by #493: `runner: ''`. A
|
|
25
|
+
* workflow_call `default:` fires only when the key is ABSENT, so a caller
|
|
26
|
+
* who passes the key with an empty value — threading an unset input or a
|
|
27
|
+
* matrix value through — got the label `""` rather than `ubuntu-latest`, and
|
|
28
|
+
* with it the identical never-scheduled job. The fallback therefore lives at
|
|
29
|
+
* the `runs-on` site, and the byte-identical assertion below is what stops a
|
|
30
|
+
* fix that reaches six of the seven workflows from shipping as if it reached
|
|
31
|
+
* all seven.
|
|
32
|
+
*
|
|
33
|
+
* Story #493 also left a downstream door open, closed here: `runs-on` was not
|
|
34
|
+
* the only site reading `inputs.runner`. `pr-quality.yml`'s harden-runner
|
|
35
|
+
* egress-audit step gates on `startsWith(inputs.runner, 'ubuntu-')`, which was
|
|
36
|
+
* unreachable-but-consistent while `runner: ''` never scheduled a job. Once the
|
|
37
|
+
* empty value resolved to the hosted default, the job ran on ubuntu-latest
|
|
38
|
+
* while the gate read the raw `''` and skipped — a SECURITY step opting itself
|
|
39
|
+
* out with nothing red to show for it. So the gate is asserted the same way:
|
|
40
|
+
* extracted and EVALUATED, and required to AGREE with what `runs-on` resolves
|
|
41
|
+
* to for the same input.
|
|
42
|
+
*
|
|
24
43
|
* Run: node --test scripts/check-runner-runs-on.test.mjs
|
|
25
44
|
*/
|
|
26
45
|
|
|
@@ -28,6 +47,7 @@ import assert from "node:assert/strict";
|
|
|
28
47
|
import { test } from "node:test";
|
|
29
48
|
import { readFileSync, readdirSync } from "node:fs";
|
|
30
49
|
import { evaluate } from "./lib/actions-expression.mjs";
|
|
50
|
+
import { stepByName } from "./lib/yaml-step.mjs";
|
|
31
51
|
|
|
32
52
|
const WORKFLOW_DIR = ".github/workflows";
|
|
33
53
|
|
|
@@ -58,6 +78,28 @@ function runsOnExpressions(text) {
|
|
|
58
78
|
return out;
|
|
59
79
|
}
|
|
60
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The `default:` this workflow declares for its `runner` workflow_call input.
|
|
83
|
+
*
|
|
84
|
+
* Read from the YAML rather than hardcoded so the empty-input assertion below
|
|
85
|
+
* pins the real contract: whatever label a caller gets by omitting `runner`
|
|
86
|
+
* is the label an explicitly-empty `runner` must get too. Scanned line by
|
|
87
|
+
* line — the block boundary is indentation, and a dynamically-constructed
|
|
88
|
+
* regex is a SAST finding that buys nothing here.
|
|
89
|
+
*/
|
|
90
|
+
function declaredRunnerDefault(text) {
|
|
91
|
+
const lines = text.split("\n");
|
|
92
|
+
const start = lines.indexOf(" runner:");
|
|
93
|
+
assert.notEqual(start, -1, "no `runner:` workflow_call input found");
|
|
94
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
95
|
+
const line = lines[i];
|
|
96
|
+
if (line.trim() !== "" && !line.startsWith(" ")) break;
|
|
97
|
+
const m = line.match(/^\s+default:\s*'([^']*)'\s*$/);
|
|
98
|
+
if (m) return m[1];
|
|
99
|
+
}
|
|
100
|
+
return assert.fail("the `runner` input declares no `default:` to fall back to");
|
|
101
|
+
}
|
|
102
|
+
|
|
61
103
|
const WORKFLOWS = runnerWorkflows();
|
|
62
104
|
|
|
63
105
|
test("every workflow taking a `runner` input is covered by this guard", () => {
|
|
@@ -127,6 +169,38 @@ for (const { file, text } of WORKFLOWS) {
|
|
|
127
169
|
}
|
|
128
170
|
});
|
|
129
171
|
|
|
172
|
+
test(`${file}: an empty runner resolves to this workflow's documented default`, () => {
|
|
173
|
+
// The silent-queue shape #421 left behind. `runner: ''` is not exotic — a
|
|
174
|
+
// caller threading `runner: ${{ inputs.runner }}` or a matrix value that
|
|
175
|
+
// resolves to nothing passes it without meaning to, and `format('"{0}"',
|
|
176
|
+
// '')` yielded the label `""`. An empty label matches no runner, so the
|
|
177
|
+
// job sat `queued` with no logs and no red, exactly as the JSON-array
|
|
178
|
+
// string did. The fallback belongs at the `runs-on` site because the
|
|
179
|
+
// input `default:` only fires when the key is ABSENT, never when it is
|
|
180
|
+
// present and empty.
|
|
181
|
+
const fallback = declaredRunnerDefault(text);
|
|
182
|
+
assert.notEqual(fallback, "", `${file}: the declared default is itself empty`);
|
|
183
|
+
for (const { line, expr } of sites) {
|
|
184
|
+
const resolved = evaluate(expr, { runner: "" });
|
|
185
|
+
assert.equal(
|
|
186
|
+
typeof resolved,
|
|
187
|
+
"string",
|
|
188
|
+
`${file}:${line}: an empty runner must resolve to a single label, not ${JSON.stringify(resolved)}`,
|
|
189
|
+
);
|
|
190
|
+
assert.notEqual(
|
|
191
|
+
resolved,
|
|
192
|
+
"",
|
|
193
|
+
`${file}:${line}: an empty runner resolves to an empty label — no runner ` +
|
|
194
|
+
`carries it, so the job queues until the 24-hour timeout with nothing to read`,
|
|
195
|
+
);
|
|
196
|
+
assert.equal(
|
|
197
|
+
resolved,
|
|
198
|
+
fallback,
|
|
199
|
+
`${file}:${line}: an empty runner must land on the input's documented default`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
130
204
|
test(`${file}: every runs-on site resolves identically`, () => {
|
|
131
205
|
// One workflow must not drift into two dialects of the same decision.
|
|
132
206
|
const rendered = sites.map(({ expr }) =>
|
|
@@ -136,6 +210,25 @@ for (const { file, text } of WORKFLOWS) {
|
|
|
136
210
|
});
|
|
137
211
|
}
|
|
138
212
|
|
|
213
|
+
|
|
214
|
+
test("every runs-on expression across the seven workflows is byte-identical", () => {
|
|
215
|
+
// The per-workflow tests above each score one file, so a fix applied to six
|
|
216
|
+
// of the seven passes every one of them and ships the seventh still broken.
|
|
217
|
+
// This is the assertion a partial edit cannot survive: one decision, spelled
|
|
218
|
+
// one way, everywhere it is made.
|
|
219
|
+
const distinct = new Map();
|
|
220
|
+
for (const { file, text } of WORKFLOWS) {
|
|
221
|
+
for (const { line, expr } of runsOnExpressions(text)) {
|
|
222
|
+
if (!distinct.has(expr)) distinct.set(expr, []);
|
|
223
|
+
distinct.get(expr).push(`${file}:${line}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const report = [...distinct.entries()]
|
|
227
|
+
.map(([expr, at]) => `${expr} @ ${at.join(", ")}`)
|
|
228
|
+
.join("\n ");
|
|
229
|
+
assert.equal(distinct.size, 1, `runs-on expressions have drifted apart:\n ${report}`);
|
|
230
|
+
});
|
|
231
|
+
|
|
139
232
|
test("the documented array form resolves AND derives toolchain-cache 'false'", () => {
|
|
140
233
|
// The coupling the gap report found: before this fix the only `runner` value
|
|
141
234
|
// that derived the correct cache posture was the one that never reached a
|
|
@@ -152,3 +245,131 @@ test("the documented array form resolves AND derives toolchain-cache 'false'", (
|
|
|
152
245
|
assert.ok(cache, "no `cache: ${{ … }}` value found at the setup-toolchain call site");
|
|
153
246
|
assert.equal(evaluate(cache[1].trim(), { runner, "toolchain-cache": "auto" }), "false");
|
|
154
247
|
});
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// The harden-runner egress-audit gate (`pr-quality.yml`)
|
|
251
|
+
//
|
|
252
|
+
// `runs-on` was not the only expression reading `inputs.runner`. Anything that
|
|
253
|
+
// branches on the runner class has to resolve the input the SAME way, or the
|
|
254
|
+
// job and the step disagree about which machine they are on. This section
|
|
255
|
+
// pins that agreement behaviourally — extract the real `if:` and run it.
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The `${{ … }}` body of the harden-runner step's `if:` gate.
|
|
260
|
+
*
|
|
261
|
+
* Keyed off the step, not off a line pattern that happens to contain
|
|
262
|
+
* `startsWith` — the point is to score whatever expression actually guards
|
|
263
|
+
* that step, including one a future edit spells differently.
|
|
264
|
+
*/
|
|
265
|
+
function hardenRunnerGate(text) {
|
|
266
|
+
const block = stepByName(text, "Harden runner (egress audit)");
|
|
267
|
+
assert.match(
|
|
268
|
+
block,
|
|
269
|
+
/uses: step-security\/harden-runner@/,
|
|
270
|
+
"the extracted block is not the harden-runner step",
|
|
271
|
+
);
|
|
272
|
+
const m = block.match(/^\s*if:\s*\$\{\{(.+)\}\}\s*$/m);
|
|
273
|
+
assert.ok(m, "the harden-runner step has no single-expression `if:` gate to score");
|
|
274
|
+
return m[1].trim();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* What `runs-on` resolves to for `runner`, as a hosted-ubuntu predicate.
|
|
279
|
+
*
|
|
280
|
+
* A single string label starting with `ubuntu-` is a GitHub-hosted ubuntu
|
|
281
|
+
* image — the one environment where harden-runner installs its own monitor.
|
|
282
|
+
* An ARRAY (the documented self-hosted form) is not, regardless of the labels
|
|
283
|
+
* inside it: harden-runner ships its agent in a self-hosted runner image, so
|
|
284
|
+
* the step is correctly a no-op there.
|
|
285
|
+
*/
|
|
286
|
+
function resolvesToHostedUbuntu(runsOnExpr, runner) {
|
|
287
|
+
const resolved = evaluate(runsOnExpr, { runner });
|
|
288
|
+
return typeof resolved === "string" && resolved.startsWith("ubuntu-");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const QUALITY_FILE = `${WORKFLOW_DIR}/pr-quality.yml`;
|
|
292
|
+
const QUALITY_TEXT = readFileSync(QUALITY_FILE, "utf8");
|
|
293
|
+
|
|
294
|
+
test("pr-quality.yml: the harden-runner gate reaches every tier through one anchor", () => {
|
|
295
|
+
// The gate is written once (`&harden-runner`) and aliased into the other
|
|
296
|
+
// tiers. A second literal copy could carry a stale expression that every
|
|
297
|
+
// behavioural assertion below would miss, because they score the anchor.
|
|
298
|
+
assert.match(QUALITY_TEXT, /^ {6}- &harden-runner$/m, "the harden-runner anchor is missing");
|
|
299
|
+
assert.ok(
|
|
300
|
+
(QUALITY_TEXT.match(/^ {6}- \*harden-runner$/gm) ?? []).length > 0,
|
|
301
|
+
"expected the harden-runner anchor to be aliased into the other tiers",
|
|
302
|
+
);
|
|
303
|
+
assert.equal(
|
|
304
|
+
(QUALITY_TEXT.match(/uses: step-security\/harden-runner@/g) ?? []).length,
|
|
305
|
+
1,
|
|
306
|
+
"expected exactly one harden-runner step — a second one would bypass the anchor",
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("pr-quality.yml: an empty runner keeps the egress audit ON", () => {
|
|
311
|
+
// The regression. `runner: ''` resolves to the hosted ubuntu-latest default
|
|
312
|
+
// at `runs-on` (#493), so the job DOES run on a GitHub-hosted machine — but
|
|
313
|
+
// a gate reading the raw input saw `startsWith('', 'ubuntu-')` → false and
|
|
314
|
+
// skipped. Nothing goes red when a step is skipped, so the egress baseline
|
|
315
|
+
// silently disappears for exactly the callers who never asked to opt out.
|
|
316
|
+
const gate = hardenRunnerGate(QUALITY_TEXT);
|
|
317
|
+
assert.equal(
|
|
318
|
+
evaluate(gate, { runner: "", "enable-harden-runner": true }),
|
|
319
|
+
true,
|
|
320
|
+
"an empty runner lands on hosted ubuntu-latest, so the egress audit must run there",
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("pr-quality.yml: the gate agrees with what runs-on resolves to", () => {
|
|
325
|
+
// The real contract, and the one that survives a respelling of either
|
|
326
|
+
// expression: the step runs precisely when the job is on a hosted ubuntu
|
|
327
|
+
// image. Scoring both sides against the same input is what makes a future
|
|
328
|
+
// change to one of them fail here instead of shipping a silent divergence.
|
|
329
|
+
const gate = hardenRunnerGate(QUALITY_TEXT);
|
|
330
|
+
const [runsOn] = runsOnExpressions(QUALITY_TEXT);
|
|
331
|
+
for (const runner of [
|
|
332
|
+
"",
|
|
333
|
+
"ubuntu-latest",
|
|
334
|
+
"ubuntu-24.04",
|
|
335
|
+
"ubuntu-22.04",
|
|
336
|
+
"ubuntu-latest-8-cores",
|
|
337
|
+
"macos-14",
|
|
338
|
+
"windows-latest",
|
|
339
|
+
'["self-hosted","beestera-runner"]',
|
|
340
|
+
'["ubuntu-latest"]',
|
|
341
|
+
]) {
|
|
342
|
+
assert.equal(
|
|
343
|
+
evaluate(gate, { runner, "enable-harden-runner": true }),
|
|
344
|
+
resolvesToHostedUbuntu(runsOn.expr, runner),
|
|
345
|
+
`runner ${JSON.stringify(runner)}: the gate and the resolved runs-on disagree ` +
|
|
346
|
+
`about whether this job is on a GitHub-hosted ubuntu image`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("pr-quality.yml: `enable-harden-runner: false` still opts out everywhere", () => {
|
|
352
|
+
// The documented escape hatch. A fallback added to the runner half of the
|
|
353
|
+
// gate must not make the boolean half unreachable — `false && …` yields
|
|
354
|
+
// `false`, but only if the operands stayed in that order.
|
|
355
|
+
const gate = hardenRunnerGate(QUALITY_TEXT);
|
|
356
|
+
for (const runner of ["", "ubuntu-latest", "ubuntu-24.04", '["self-hosted","x"]']) {
|
|
357
|
+
assert.equal(
|
|
358
|
+
evaluate(gate, { runner, "enable-harden-runner": false }),
|
|
359
|
+
false,
|
|
360
|
+
`runner ${JSON.stringify(runner)}: opting out must win regardless of the runner`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("pr-quality.yml: the toolchain-cache derivation reads an empty runner as hosted", () => {
|
|
366
|
+
// The sibling `inputs.runner` reader, checked rather than assumed. It is
|
|
367
|
+
// correct as written for `runner: ''` — but only incidentally, because
|
|
368
|
+
// `contains('', 'self-hosted')` is false and the derivation is
|
|
369
|
+
// self-hosted-side. Pinning it here means a future inversion to a
|
|
370
|
+
// hosted-side test (`contains(runner, 'ubuntu')`) trips instead of quietly
|
|
371
|
+
// disabling the cache for every empty-runner caller.
|
|
372
|
+
const cache = QUALITY_TEXT.match(/^\s*cache:\s*\$\{\{(.+)\}\}\s*$/m);
|
|
373
|
+
assert.ok(cache, "no `cache: ${{ … }}` value found at the setup-toolchain call site");
|
|
374
|
+
assert.equal(evaluate(cache[1].trim(), { runner: "", "toolchain-cache": "auto" }), "true");
|
|
375
|
+
});
|
|
@@ -38,9 +38,51 @@ const LOCKFILE = "scripts/semgrep-requirements.txt";
|
|
|
38
38
|
const WORKFLOW = ".github/workflows/pr-quality.yml";
|
|
39
39
|
const UPDATER = "scripts/update-semgrep-rules.mjs";
|
|
40
40
|
|
|
41
|
+
const DOCS = "docs/reusable-workflows.md";
|
|
42
|
+
|
|
41
43
|
const lockfile = readFileSync(LOCKFILE, "utf8");
|
|
42
44
|
const workflow = readFileSync(WORKFLOW, "utf8");
|
|
43
45
|
|
|
46
|
+
const SAST_STEP_HEADER = "- name: SAST (Semgrep, blocking, no SARIF upload)";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Return the SAST step's `run:` body — from its `- name:` line to the next
|
|
50
|
+
* step at the same indentation — with comment-only lines stripped.
|
|
51
|
+
*
|
|
52
|
+
* Both halves earn their place. Scoping to the step is what stops a whole-file
|
|
53
|
+
* `indexOf` from resolving an anchor against an unrelated tier a thousand
|
|
54
|
+
* lines away; dropping comments is what stops it from resolving against PROSE
|
|
55
|
+
* ABOUT the code instead of the code. The selector-before-venv guard below did
|
|
56
|
+
* both: its `select-semgrep-python.sh` hit landed in the side-checkout step's
|
|
57
|
+
* comment block and its `-m venv` hit in a paragraph explaining venvs, so the
|
|
58
|
+
* ordering it asserted was the ordering of two comments.
|
|
59
|
+
*/
|
|
60
|
+
function sastStepCode(text = workflow) {
|
|
61
|
+
const start = text.indexOf(SAST_STEP_HEADER);
|
|
62
|
+
if (start === -1) return "";
|
|
63
|
+
const rest = text.slice(start + SAST_STEP_HEADER.length);
|
|
64
|
+
const end = rest.indexOf("\n - ");
|
|
65
|
+
const step = end === -1 ? rest : rest.slice(0, end);
|
|
66
|
+
return step
|
|
67
|
+
.split("\n")
|
|
68
|
+
.filter((line) => !line.trimStart().startsWith("#"))
|
|
69
|
+
.join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* True when the SAST step chooses its interpreter before it builds the venv.
|
|
74
|
+
* A predicate rather than a bare assertion so the guard can be exercised
|
|
75
|
+
* against a MUTATED workflow — a test that only ever sees the passing input
|
|
76
|
+
* cannot show it would notice the failure.
|
|
77
|
+
*/
|
|
78
|
+
function selectorPrecedesVenv(text) {
|
|
79
|
+
const code = sastStepCode(text);
|
|
80
|
+
const select = code.indexOf('source "${SELECT_PYTHON}"');
|
|
81
|
+
const venv = code.indexOf('"${SEMGREP_PYTHON}" -m venv');
|
|
82
|
+
if (select === -1 || venv === -1) return false;
|
|
83
|
+
return select < venv;
|
|
84
|
+
}
|
|
85
|
+
|
|
44
86
|
// semgrep's own `requires_python`, recorded per release. Verified on PyPI
|
|
45
87
|
// 2026-09-10: 1.97.0 was `>=3.8`, 1.136.0 `>=3.9`, and 1.137.0 raised it to
|
|
46
88
|
// `>=3.10` — which is why a runner on macOS system Python (3.9.6) could not
|
|
@@ -265,16 +307,38 @@ test("the selector rides the same side-checkout as the lockfile", () => {
|
|
|
265
307
|
test("the SAST step selects an interpreter before it creates the venv", () => {
|
|
266
308
|
// A venv inherits the interpreter that built it, so a floor enforced after
|
|
267
309
|
// `-m venv` cannot fix anything. Order is the whole guarantee.
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
assert.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
310
|
+
const code = sastStepCode();
|
|
311
|
+
assert.notEqual(code, "", `${WORKFLOW}: the SAST step was renamed or removed`);
|
|
312
|
+
assert.ok(
|
|
313
|
+
code.includes('source "${SELECT_PYTHON}"'),
|
|
314
|
+
`${WORKFLOW}: the SAST step must source the interpreter selector`,
|
|
315
|
+
);
|
|
274
316
|
assert.ok(
|
|
275
|
-
|
|
317
|
+
code.includes('"${SEMGREP_PYTHON}" -m venv'),
|
|
276
318
|
"the venv must be built from the selected interpreter, not from bare python3",
|
|
277
319
|
);
|
|
320
|
+
assert.ok(selectorPrecedesVenv(workflow), "the selector must be sourced BEFORE the venv");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("the selector-before-venv guard fails when the two are swapped", () => {
|
|
324
|
+
// The mutation the guard exists to catch, performed on a copy: move the venv
|
|
325
|
+
// line above the `source` line and the check must go red. Without this, the
|
|
326
|
+
// guard could be resolving anchors that no reordering of the real step would
|
|
327
|
+
// ever move — which is precisely how it passed while comparing two comments.
|
|
328
|
+
const VENV_LINE = ' "${SEMGREP_PYTHON}" -m venv "${venv_dir}"\n';
|
|
329
|
+
const SOURCE_LINE = ' source "${SELECT_PYTHON}"\n';
|
|
330
|
+
assert.ok(workflow.includes(VENV_LINE), `${WORKFLOW}: the venv line changed shape`);
|
|
331
|
+
assert.ok(workflow.includes(SOURCE_LINE), `${WORKFLOW}: the source line changed shape`);
|
|
332
|
+
|
|
333
|
+
const mutated = workflow
|
|
334
|
+
.replace(VENV_LINE, "")
|
|
335
|
+
.replace(SOURCE_LINE, `${VENV_LINE}${SOURCE_LINE}`);
|
|
336
|
+
assert.notEqual(mutated, workflow, "the mutation must actually change the workflow");
|
|
337
|
+
assert.equal(
|
|
338
|
+
selectorPrecedesVenv(mutated),
|
|
339
|
+
false,
|
|
340
|
+
"a venv built before the interpreter is chosen must fail the guard",
|
|
341
|
+
);
|
|
278
342
|
});
|
|
279
343
|
|
|
280
344
|
test("the non-lockfile path installs exactly SEMGREP_PIN, never a resolved older release", () => {
|
|
@@ -341,3 +405,170 @@ test("the floor added no workflow_call input and no new job permission", () => {
|
|
|
341
405
|
assert.ok(!header.includes("id-token:"), "no new permission was needed for an interpreter floor");
|
|
342
406
|
assert.ok(!header.includes("packages:"), "no new permission was needed for an interpreter floor");
|
|
343
407
|
});
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// 6. The hash-pinned path's real preconditions, and an audible fallback (#495)
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
test("the hash-pinned branch tests architecture as well as OS and ABI", () => {
|
|
414
|
+
// The lockfile was resolved with `--platform manylinux*_x86_64` only, and
|
|
415
|
+
// PyPI ships a separate `manylinux_2_34_aarch64` semgrep wheel that no entry
|
|
416
|
+
// covers. On an arm64 Linux runner the OS+ABI test passed and
|
|
417
|
+
// `--require-hashes --only-binary` hard-failed on a hash mismatch — a red
|
|
418
|
+
// that reads like a tampered artifact rather than an unsupported arch.
|
|
419
|
+
const code = sastStepCode();
|
|
420
|
+
assert.ok(
|
|
421
|
+
code.includes('[ "${pyver}" = "312" ]'),
|
|
422
|
+
`${WORKFLOW}: the cp312 test must stay an equality`,
|
|
423
|
+
);
|
|
424
|
+
assert.ok(
|
|
425
|
+
code.includes('[ "${runner_arch}" = "x86_64" ]'),
|
|
426
|
+
`${WORKFLOW}: the hash-pinned branch must also require x86_64`,
|
|
427
|
+
);
|
|
428
|
+
assert.ok(
|
|
429
|
+
code.includes('runner_arch="$(uname -m)"'),
|
|
430
|
+
"the architecture must be read from the runner, not assumed",
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("the lockfile's declared ABI matches the branch's cp equality", () => {
|
|
435
|
+
// Two spellings of one fact: `3.12` steers the interpreter probe and `312`
|
|
436
|
+
// gates the install. A bump that moved one and not the other would probe for
|
|
437
|
+
// an interpreter the install then rejects.
|
|
438
|
+
const abi = workflow.match(/export SEMGREP_LOCKFILE_ABI='([^']+)'/);
|
|
439
|
+
assert.ok(abi, `${WORKFLOW}: SEMGREP_LOCKFILE_ABI must be exported for the selector`);
|
|
440
|
+
assert.equal(
|
|
441
|
+
abi[1].replace(".", ""),
|
|
442
|
+
"312",
|
|
443
|
+
`SEMGREP_LOCKFILE_ABI is ${abi[1]} but the hash-pinned branch installs only on cp312`,
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("every reason for leaving the hash-pinned path is warned about by name", () => {
|
|
448
|
+
// The single line this replaced said "Non-Linux runner (or no lockfile)" on
|
|
449
|
+
// all four, so the two that actually cost the fleet its supply-chain pin — a
|
|
450
|
+
// rolled interpreter and an unsupported arch — were reported as neither of
|
|
451
|
+
// them, at log level, on a green job.
|
|
452
|
+
const code = sastStepCode();
|
|
453
|
+
const reasons = code.split("\n").filter((l) => l.includes("fallback_reason="));
|
|
454
|
+
assert.equal(reasons.length, 4, "expected one named reason per precondition");
|
|
455
|
+
|
|
456
|
+
for (const [precondition, phrase] of [
|
|
457
|
+
["OS", "not Linux"],
|
|
458
|
+
["architecture", "not x86_64"],
|
|
459
|
+
["interpreter ABI", "not the lockfile's cp312"],
|
|
460
|
+
["missing lockfile", "lockfile is missing"],
|
|
461
|
+
]) {
|
|
462
|
+
assert.ok(
|
|
463
|
+
reasons.some((line) => line.includes(phrase)),
|
|
464
|
+
`no fallback reason names the ${precondition} precondition (looked for "${phrase}")`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const warning = code
|
|
469
|
+
.split("\n")
|
|
470
|
+
.find((l) => l.includes("::warning::") && l.includes("${fallback_reason}"));
|
|
471
|
+
assert.ok(
|
|
472
|
+
warning,
|
|
473
|
+
"the fallback must emit a ::warning:: carrying the resolved reason, not a log line",
|
|
474
|
+
);
|
|
475
|
+
assert.ok(
|
|
476
|
+
warning.includes("WITHOUT hash-pinning"),
|
|
477
|
+
"the warning must say what was actually lost",
|
|
478
|
+
);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("the SAST exclude list covers every platform side-checkout", () => {
|
|
482
|
+
// Two directories are materialized from mandrel-platform's own tree —
|
|
483
|
+
// `_mandrel-platform-sast` (the ruleset) and `_mandrel-platform-semgrep`
|
|
484
|
+
// (the lockfile + selector). Only the first was ever excluded, so platform
|
|
485
|
+
// source could enter a caller's finding set. The glob covers the next one
|
|
486
|
+
// too.
|
|
487
|
+
const code = sastStepCode();
|
|
488
|
+
assert.ok(
|
|
489
|
+
code.includes("--exclude '_mandrel-platform-*'"),
|
|
490
|
+
`${WORKFLOW}: the exclude list must cover _mandrel-platform-* , not one checkout by name`,
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
// Both checkout paths must fall under the glob, or the exclude is cosmetic.
|
|
494
|
+
for (const path of ["_mandrel-platform-sast", "_mandrel-platform-semgrep"]) {
|
|
495
|
+
assert.ok(
|
|
496
|
+
workflow.includes(`path: ${path}`),
|
|
497
|
+
`${WORKFLOW}: expected a side-checkout at ${path}`,
|
|
498
|
+
);
|
|
499
|
+
assert.ok(path.startsWith("_mandrel-platform-"), `${path} is not covered by the glob`);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// 7. The rules updater shares the selector, and counts its own hashes (#495)
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
test("the rules updater builds its venv from the shared interpreter selector", () => {
|
|
508
|
+
const updater = readFileSync(UPDATER, "utf8");
|
|
509
|
+
assert.ok(
|
|
510
|
+
updater.includes("select-semgrep-python.sh"),
|
|
511
|
+
`${UPDATER}: the venv interpreter must come from the same selector the SAST step uses`,
|
|
512
|
+
);
|
|
513
|
+
assert.ok(
|
|
514
|
+
!/spawnSync\("python3"/.test(updater),
|
|
515
|
+
`${UPDATER}: a hard-coded python3 installs nothing on a macOS system interpreter`,
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("the rules updater no longer installs setuptools", () => {
|
|
520
|
+
// It was there only because semgrep 1.97.0's transitive
|
|
521
|
+
// opentelemetry-instrumentation 0.46b0 imported pkg_resources at load.
|
|
522
|
+
// 0.58b0 does not, and 80.9.0 — the last release shipping pkg_resources —
|
|
523
|
+
// carries its own advisories.
|
|
524
|
+
const updater = readFileSync(UPDATER, "utf8");
|
|
525
|
+
assert.ok(
|
|
526
|
+
!/["']setuptools["']/.test(updater),
|
|
527
|
+
`${UPDATER}: setuptools must not be reinstalled into the vendoring venv`,
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("the SEMGREP_HASHES comment states the artifact count it actually carries", () => {
|
|
532
|
+
// The comment read "the four platform wheels + the sdist" while the array
|
|
533
|
+
// held eight. A reader checking whether the hash set is complete is reading
|
|
534
|
+
// the comment, so a stale count is a supply-chain claim that is not true.
|
|
535
|
+
const updater = readFileSync(UPDATER, "utf8");
|
|
536
|
+
const version = REQS.get("semgrep");
|
|
537
|
+
|
|
538
|
+
const mapStart = updater.indexOf("const SEMGREP_HASHES = {");
|
|
539
|
+
assert.notEqual(mapStart, -1, `${UPDATER}: SEMGREP_HASHES not found`);
|
|
540
|
+
const entryStart = updater.indexOf(`"${version}": [`, mapStart);
|
|
541
|
+
assert.notEqual(entryStart, -1, `${UPDATER}: SEMGREP_HASHES has no entry for ${version}`);
|
|
542
|
+
const entry = updater.slice(entryStart, updater.indexOf("],", entryStart));
|
|
543
|
+
const recorded = entry.split("sha256:").length - 1;
|
|
544
|
+
assert.ok(recorded > 0, `${UPDATER}: the ${version} entry carries no hashes`);
|
|
545
|
+
|
|
546
|
+
const comment = updater.slice(0, mapStart);
|
|
547
|
+
const claimed = comment.match(/(\d+) artifacts/);
|
|
548
|
+
assert.ok(
|
|
549
|
+
claimed,
|
|
550
|
+
`${UPDATER}: the SEMGREP_HASHES comment must state how many artifacts it pins`,
|
|
551
|
+
);
|
|
552
|
+
assert.equal(
|
|
553
|
+
Number.parseInt(claimed[1], 10),
|
|
554
|
+
recorded,
|
|
555
|
+
`the comment claims ${claimed[1]} artifacts but the ${version} entry pins ${recorded}`,
|
|
556
|
+
);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
// 8. The documented behaviour is the shipped behaviour (#495)
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
test("the SAST docs describe the ABI-first probe order and the fallback warning", () => {
|
|
564
|
+
// A consumer debugging a lost hash pin reads this section, not the workflow.
|
|
565
|
+
const docs = readFileSync(DOCS, "utf8");
|
|
566
|
+
const start = docs.indexOf("> **Python interpreter floor.**");
|
|
567
|
+
assert.notEqual(start, -1, `${DOCS}: the SAST interpreter section was renamed or removed`);
|
|
568
|
+
const section = docs.slice(start, docs.indexOf("#### SAST ruleset provenance", start));
|
|
569
|
+
|
|
570
|
+
assert.match(section, /SEMGREP_LOCKFILE_ABI|lockfile.{0,40}ABI/i, "the ABI input must be named");
|
|
571
|
+
assert.match(section, /probed first|first on Linux/i, "the probe order must be stated");
|
|
572
|
+
assert.match(section, /::warning::/, "the fallback warning must be documented");
|
|
573
|
+
assert.match(section, /x86_64/, "the architecture precondition must be documented");
|
|
574
|
+
});
|