mandrel-platform 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -12
- package/config/stryker.base.json +7 -2
- package/package.json +1 -1
- package/scripts/audit-check.mjs +331 -6
- package/scripts/audit-check.test.mjs +382 -1
- package/scripts/check-action-pins.mjs +87 -15
- package/scripts/check-action-pins.test.mjs +103 -4
- package/scripts/check-affected-mode.test.mjs +5 -51
- package/scripts/check-codeql-gating.test.mjs +649 -0
- package/scripts/check-destructive-migration.mjs +277 -11
- package/scripts/check-destructive-migration.test.mjs +334 -0
- package/scripts/check-environments-isolation-audit.test.mjs +212 -0
- package/scripts/check-fail-fast-attribution.test.mjs +90 -4
- package/scripts/check-first-party-pin-freshness.mjs +648 -0
- package/scripts/check-first-party-pin-freshness.test.mjs +624 -0
- package/scripts/check-gitleaks-allowlist.test.mjs +312 -0
- package/scripts/check-osv-scan-mode.test.mjs +5 -50
- package/scripts/check-release-type.mjs +591 -0
- package/scripts/check-release-type.test.mjs +678 -0
- package/scripts/check-setup-toolchain-store.test.mjs +139 -0
- package/scripts/check-toolchain-cache-default.test.mjs +308 -0
- package/scripts/lib/yaml-step.mjs +109 -0
- package/scripts/lib/yaml-step.test.mjs +156 -0
- package/scripts/osv-report-gate.test.mjs +289 -0
- package/scripts/runner-env-drift.test.mjs +554 -0
- package/scripts/stryker-base-config.test.mjs +256 -0
- package/templates/runbooks/runner-provisioning.md +50 -6
- package/templates/runner/check-runner-env-drift.sh +248 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-codeql-gating.test.mjs — regression guard for the repository-local
|
|
4
|
+
* CodeQL merge gate (Story #366, hardened in Story #380).
|
|
5
|
+
*
|
|
6
|
+
* CodeQL ran on this repo's pull requests for a long time without gating
|
|
7
|
+
* anything: it was not among the required status contexts, and the delivery
|
|
8
|
+
* path arms GitHub native auto-merge, which waits only on required contexts.
|
|
9
|
+
* A pull request that introduced a new high-severity alert merged green, and
|
|
10
|
+
* the delivery envelope reported the Story landed. Two ReDoS regressions
|
|
11
|
+
* reached `main` that way — "landed" was not the same as "scanned".
|
|
12
|
+
*
|
|
13
|
+
* The gate routes through the AGGREGATOR rather than through repository
|
|
14
|
+
* settings: ci.yml calls codeql.yml as a job, and the required `ci-required`
|
|
15
|
+
* aggregator lists that job in `needs:`. The required-context set is
|
|
16
|
+
* unchanged, so no branch protection is edited and no phantom context can
|
|
17
|
+
* appear.
|
|
18
|
+
*
|
|
19
|
+
* ## Why the gate's shell is EXECUTED here, not matched
|
|
20
|
+
*
|
|
21
|
+
* The decision this gate makes lives in a shell branch — a severity rank
|
|
22
|
+
* compared against a threshold — and the whole failure mode is silence. Four
|
|
23
|
+
* regexes over the step's `run:` text used to stand in for that branch, which
|
|
24
|
+
* proved only that certain words were present: an inverted comparator, a
|
|
25
|
+
* dropped page of alerts, or a severity the scale does not cover would all
|
|
26
|
+
* have shipped green. So this suite extracts the real `run:` body with
|
|
27
|
+
* `lib/yaml-step.mjs` and runs it under bash against a stubbed `gh`, the same
|
|
28
|
+
* read-then-execute approach as check-gitleaks-allowlist.test.mjs and
|
|
29
|
+
* check-release-type.test.mjs.
|
|
30
|
+
*
|
|
31
|
+
* The static assertions that remain are the ones that are genuinely about
|
|
32
|
+
* structure rather than behaviour:
|
|
33
|
+
*
|
|
34
|
+
* 1. WIRING — ci.yml has a `code-scanning` job that calls codeql.yml, and
|
|
35
|
+
* `ci-required` lists it in `needs:`. It also passes a
|
|
36
|
+
* `fail-on-alert-severity` threshold: `codeql-action/analyze` exits 0
|
|
37
|
+
* whatever it finds, so without one the gate would only assert that the
|
|
38
|
+
* scan RAN. The alert signal GitHub itself reds is a check run, which no
|
|
39
|
+
* aggregator can `needs:`.
|
|
40
|
+
* 2. FAIL TOWARD BLOCKING — neither the caller's `code-scanning` job nor
|
|
41
|
+
* codeql.yml's own `analyze` job declares `if:` or `needs:`. The
|
|
42
|
+
* aggregator passes a job whose result is `success` or `skipped`, and
|
|
43
|
+
* `skipped` is reachable only through an `if:` condition or a
|
|
44
|
+
* skipped/failed dependency. A called workflow whose only job skips
|
|
45
|
+
* reports `skipped` to its caller, so an `if:` on `analyze` reopens the
|
|
46
|
+
* exact hole `needs: code-scanning` closed.
|
|
47
|
+
* 3. ONE SCAN PATH — codeql.yml no longer triggers itself on push/PR. Both
|
|
48
|
+
* paths would analyze the same commit under the same `category`, so
|
|
49
|
+
* keeping them would burn two 30-minute analyses per event and let the
|
|
50
|
+
* later SARIF upload overwrite the earlier one.
|
|
51
|
+
* 4. CONSUMER BLAST RADIUS ZERO — codeql.yml keeps its `workflow_call`
|
|
52
|
+
* contract, and no reusable workflow a consumer calls gained a CodeQL
|
|
53
|
+
* job. The fleet's private repos have code scanning disabled, where a
|
|
54
|
+
* CodeQL job fails closed on a 403; the vendored Semgrep tier stays
|
|
55
|
+
* their blocking SAST.
|
|
56
|
+
*
|
|
57
|
+
* Run: node --test scripts/check-codeql-gating.test.mjs
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import assert from "node:assert/strict";
|
|
61
|
+
import { test } from "node:test";
|
|
62
|
+
import { execFileSync } from "node:child_process";
|
|
63
|
+
import {
|
|
64
|
+
readFileSync,
|
|
65
|
+
existsSync,
|
|
66
|
+
mkdtempSync,
|
|
67
|
+
writeFileSync,
|
|
68
|
+
chmodSync,
|
|
69
|
+
rmSync,
|
|
70
|
+
} from "node:fs";
|
|
71
|
+
import { tmpdir } from "node:os";
|
|
72
|
+
import { delimiter, join, resolve, dirname } from "node:path";
|
|
73
|
+
import { fileURLToPath } from "node:url";
|
|
74
|
+
|
|
75
|
+
import { stepByName, runScript } from "./lib/yaml-step.mjs";
|
|
76
|
+
|
|
77
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
78
|
+
|
|
79
|
+
const CI = ".github/workflows/ci.yml";
|
|
80
|
+
const CODEQL = ".github/workflows/codeql.yml";
|
|
81
|
+
const PR_QUALITY = ".github/workflows/pr-quality.yml";
|
|
82
|
+
const CONTRACT = "docs/runbooks/main-protection.json";
|
|
83
|
+
const ROLLBACK = "docs/runbooks/rollback.md";
|
|
84
|
+
|
|
85
|
+
const SCAN_JOB = "code-scanning";
|
|
86
|
+
const ANALYZE_JOB = "analyze";
|
|
87
|
+
const AGGREGATOR = "ci-required";
|
|
88
|
+
const GATE_STEP = "Fail on alerts at or above the gating severity";
|
|
89
|
+
|
|
90
|
+
const read = (rel) => readFileSync(join(repoRoot, rel), "utf8");
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Minimal indentation-based extraction. Dependency-free by house style (the
|
|
94
|
+
// repo's `npm test` is Node's built-in runner with no package install), and
|
|
95
|
+
// deliberately the same shape as check-ci-required-aggregator.test.mjs: jobs
|
|
96
|
+
// are declared at 2-space indent, so a job block runs from its ` <id>:` line
|
|
97
|
+
// to the next non-blank line at indent <= 2.
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function extractJobBlock(content, jobId) {
|
|
101
|
+
const lines = content.split("\n");
|
|
102
|
+
const start = lines.findIndex((l) => l === ` ${jobId}:`);
|
|
103
|
+
assert.notEqual(start, -1, `job \`${jobId}\` not found`);
|
|
104
|
+
let end = lines.length;
|
|
105
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
106
|
+
if (/^\s*$/.test(lines[i])) continue;
|
|
107
|
+
if (lines[i].match(/^(\s*)/)[1].length <= 2) {
|
|
108
|
+
end = i;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return lines.slice(start, end).join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Keys declared directly on the job (4-space indent), comments excluded. */
|
|
116
|
+
function jobKeys(jobBlock) {
|
|
117
|
+
const keys = new Set();
|
|
118
|
+
for (const line of jobBlock.split("\n").slice(1)) {
|
|
119
|
+
const m = line.match(/^ {4}([A-Za-z0-9_-]+):/);
|
|
120
|
+
if (m) keys.add(m[1]);
|
|
121
|
+
}
|
|
122
|
+
return keys;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The `- <name>` entries under the job's `needs:` key. */
|
|
126
|
+
function extractNeeds(jobBlock) {
|
|
127
|
+
const lines = jobBlock.split("\n");
|
|
128
|
+
const start = lines.findIndex((l) => l === " needs:");
|
|
129
|
+
assert.notEqual(start, -1, "`needs:` block not found");
|
|
130
|
+
const names = [];
|
|
131
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
132
|
+
const m = lines[i].match(/^\s+-\s+([A-Za-z0-9_-]+)\s*$/);
|
|
133
|
+
if (m) {
|
|
134
|
+
names.push(m[1]);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Interleaved comment lines are part of the list; anything else ends it.
|
|
138
|
+
if (/^\s+#/.test(lines[i])) continue;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
return names;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The top-level `on:` trigger keys of a workflow. */
|
|
145
|
+
function extractTriggers(content) {
|
|
146
|
+
const lines = content.split("\n");
|
|
147
|
+
const start = lines.findIndex((l) => /^on:\s*$/.test(l));
|
|
148
|
+
assert.notEqual(start, -1, "top-level `on:` block not found");
|
|
149
|
+
const triggers = new Set();
|
|
150
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
151
|
+
if (/^\s*$/.test(lines[i])) continue;
|
|
152
|
+
if (/^[A-Za-z0-9_-]/.test(lines[i])) break; // next top-level key
|
|
153
|
+
const m = lines[i].match(/^ {2}([A-Za-z0-9_-]+):/);
|
|
154
|
+
if (m) triggers.add(m[1]);
|
|
155
|
+
}
|
|
156
|
+
return triggers;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// The gate harness: the real `run:` body, executed against a stubbed `gh`.
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
const gateScript = runScript(stepByName(read(CODEQL), GATE_STEP));
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A `gh` stub that reproduces the one behaviour the gate depends on:
|
|
167
|
+
* `gh api --paginate` emits ONE JSON array PER PAGE, concatenated, whereas a
|
|
168
|
+
* request without `--paginate` returns page 1 and stops.
|
|
169
|
+
*
|
|
170
|
+
* Emulating that split is the whole point — it is what lets a test place a
|
|
171
|
+
* blocking alert on page 2 and have the assertion fail the moment `--paginate`
|
|
172
|
+
* is dropped from the request. Every invocation appends its argv to a log so
|
|
173
|
+
* the request itself (its query string, its retry count) can be asserted.
|
|
174
|
+
*/
|
|
175
|
+
const GH_STUB = [
|
|
176
|
+
"#!/bin/sh",
|
|
177
|
+
'printf \'%s\\n\' "$*" >>"$STUB_ARGV_LOG"',
|
|
178
|
+
'if [ -n "${STUB_FAIL:-}" ]; then',
|
|
179
|
+
' echo "stubbed gh: request failed" >&2',
|
|
180
|
+
" exit 1",
|
|
181
|
+
"fi",
|
|
182
|
+
"paginate=0",
|
|
183
|
+
'for arg in "$@"; do',
|
|
184
|
+
' if [ "$arg" = "--paginate" ]; then paginate=1; fi',
|
|
185
|
+
"done",
|
|
186
|
+
'cat "$STUB_DIR/page1.json"',
|
|
187
|
+
'if [ "$paginate" = "1" ]; then',
|
|
188
|
+
' for extra in "$STUB_DIR"/page2.json "$STUB_DIR"/page3.json; do',
|
|
189
|
+
' if [ -f "$extra" ]; then cat "$extra"; fi',
|
|
190
|
+
" done",
|
|
191
|
+
"fi",
|
|
192
|
+
"",
|
|
193
|
+
].join("\n");
|
|
194
|
+
|
|
195
|
+
/** One code scanning alert as the REST API shapes it. */
|
|
196
|
+
const alert = (severity, ruleId = "js/redos", path = "scripts/a.mjs", line = 7) => ({
|
|
197
|
+
rule: severity === null ? { id: ruleId } : { id: ruleId, security_severity_level: severity },
|
|
198
|
+
most_recent_instance: { location: { path, start_line: line } },
|
|
199
|
+
html_url: `https://github.com/o/r/security/code-scanning/${line}`,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Execute the extracted gate body.
|
|
204
|
+
*
|
|
205
|
+
* @param {object} opts
|
|
206
|
+
* @param {Array} opts.pages One entry per API page. An array is
|
|
207
|
+
* serialised as the page body; a string is
|
|
208
|
+
* written verbatim (for malformed responses).
|
|
209
|
+
* @param {string} opts.threshold The `fail-on-alert-severity` input value.
|
|
210
|
+
* @param {boolean} opts.ghFails Make every `gh` invocation exit non-zero.
|
|
211
|
+
*/
|
|
212
|
+
function runGate({ pages = [[]], threshold = "high", ghFails = false } = {}) {
|
|
213
|
+
const dir = mkdtempSync(join(tmpdir(), "codeql-gate-"));
|
|
214
|
+
try {
|
|
215
|
+
const argvLog = join(dir, "argv.log");
|
|
216
|
+
writeFileSync(argvLog, "");
|
|
217
|
+
pages.forEach((page, i) => {
|
|
218
|
+
const body = typeof page === "string" ? page : `${JSON.stringify(page)}\n`;
|
|
219
|
+
writeFileSync(join(dir, `page${i + 1}.json`), body);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const gh = join(dir, "gh");
|
|
223
|
+
writeFileSync(gh, GH_STUB);
|
|
224
|
+
chmodSync(gh, 0o755);
|
|
225
|
+
// The retry loop sleeps 10s between attempts; a no-op `sleep` ahead of it
|
|
226
|
+
// on PATH keeps the six-attempt fail-closed case under a millisecond.
|
|
227
|
+
const sleepStub = join(dir, "sleep");
|
|
228
|
+
writeFileSync(sleepStub, "#!/bin/sh\nexit 0\n");
|
|
229
|
+
chmodSync(sleepStub, 0o755);
|
|
230
|
+
|
|
231
|
+
const summary = join(dir, "step-summary.md");
|
|
232
|
+
const script = join(dir, "gate.sh");
|
|
233
|
+
writeFileSync(script, gateScript);
|
|
234
|
+
|
|
235
|
+
const env = {
|
|
236
|
+
PATH: `${dir}${delimiter}${process.env.PATH}`,
|
|
237
|
+
GH_TOKEN: "stub-token",
|
|
238
|
+
REPO: "o/r",
|
|
239
|
+
ANALYSIS_REF: "refs/pull/42/merge",
|
|
240
|
+
THRESHOLD: threshold,
|
|
241
|
+
GITHUB_STEP_SUMMARY: summary,
|
|
242
|
+
STUB_DIR: dir,
|
|
243
|
+
STUB_ARGV_LOG: argvLog,
|
|
244
|
+
...(ghFails ? { STUB_FAIL: "1" } : {}),
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// `bash --noprofile --norc -eo pipefail <script>` is exactly how GitHub
|
|
248
|
+
// invokes a `shell: bash` step, and it is the part most likely to break a
|
|
249
|
+
// naive `$(...)` capture.
|
|
250
|
+
const argv = ["--noprofile", "--norc", "-eo", "pipefail", script];
|
|
251
|
+
const opts = { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env };
|
|
252
|
+
|
|
253
|
+
let status = 0;
|
|
254
|
+
let output;
|
|
255
|
+
try {
|
|
256
|
+
output = execFileSync("bash", argv, opts);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
status = e.status ?? 1;
|
|
259
|
+
output = `${e.stdout ?? ""}${e.stderr ?? ""}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
status,
|
|
264
|
+
output,
|
|
265
|
+
summary: existsSync(summary) ? readFileSync(summary, "utf8") : "",
|
|
266
|
+
requests: readFileSync(argvLog, "utf8").split("\n").filter(Boolean),
|
|
267
|
+
};
|
|
268
|
+
} finally {
|
|
269
|
+
rmSync(dir, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// AC-1 / AC-2 / AC-3 — the gate's shell, executed
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
test("AC-2: an alert above the threshold fails the job and names it", () => {
|
|
278
|
+
const { status, output, summary } = runGate({ pages: [[alert("critical", "js/code-injection")]] });
|
|
279
|
+
|
|
280
|
+
assert.equal(status, 1, "a critical alert above the `high` threshold must fail the job");
|
|
281
|
+
assert.match(output, /1 open code scanning alert\(s\) at or above high/);
|
|
282
|
+
// The operator has to be able to act on this from the log alone.
|
|
283
|
+
assert.match(output, /js\/code-injection/);
|
|
284
|
+
assert.match(output, /scripts\/a\.mjs:7/);
|
|
285
|
+
assert.match(summary, /### ❌ Code scanning gate failed/);
|
|
286
|
+
assert.match(summary, /Fix or dismiss them — this job gates the merge\./);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("AC-3: an alert exactly AT the threshold fails — the comparator is inclusive", () => {
|
|
290
|
+
// Kills a `>` mutation. `high` is the threshold this repository ships, so an
|
|
291
|
+
// exclusive comparator would wave through the single most likely alert.
|
|
292
|
+
const { status, output } = runGate({ pages: [[alert("high")]], threshold: "high" });
|
|
293
|
+
assert.equal(status, 1);
|
|
294
|
+
assert.match(output, /1 open code scanning alert\(s\) at or above high/);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("AC-2/AC-3: an alert below the threshold passes", () => {
|
|
298
|
+
// Kills an inverted (`<=`) comparator, which would block on everything the
|
|
299
|
+
// gate is supposed to let through.
|
|
300
|
+
for (const severity of ["medium", "low"]) {
|
|
301
|
+
const { status, output } = runGate({ pages: [[alert(severity)]], threshold: "high" });
|
|
302
|
+
assert.equal(status, 0, `a ${severity} alert must not fail a \`high\` gate`);
|
|
303
|
+
assert.match(output, /✅ No open code scanning alerts at or above high/);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("AC-3: the threshold is read from the input, not hardcoded", () => {
|
|
308
|
+
// A `high` alert blocks at `high` (above) and passes at `critical` (below),
|
|
309
|
+
// so the rank comparison genuinely consults THRESHOLD on both sides.
|
|
310
|
+
assert.equal(runGate({ pages: [[alert("high")]], threshold: "high" }).status, 1);
|
|
311
|
+
assert.equal(runGate({ pages: [[alert("high")]], threshold: "critical" }).status, 0);
|
|
312
|
+
assert.equal(runGate({ pages: [[alert("low")]], threshold: "low" }).status, 1);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("AC-2: an empty alert list passes", () => {
|
|
316
|
+
const { status, output, summary } = runGate({ pages: [[]] });
|
|
317
|
+
assert.equal(status, 0);
|
|
318
|
+
assert.match(output, /✅ No open code scanning alerts at or above high/);
|
|
319
|
+
assert.equal(summary, "", "a clean scan writes no failure summary");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("AC-2: a severity the scale does not cover fails closed", () => {
|
|
323
|
+
// A severity that is PRESENT but unrankable cannot be proved below the
|
|
324
|
+
// threshold. Ranking it 0 and passing — the behaviour before Story #380 —
|
|
325
|
+
// is a fail-open in a step whose entire purpose is failing closed, and it is
|
|
326
|
+
// exactly how a newly introduced GitHub severity level would slip past.
|
|
327
|
+
const { status, output } = runGate({ pages: [[alert("severe", "js/future-query")]] });
|
|
328
|
+
assert.equal(status, 1);
|
|
329
|
+
assert.match(output, /security severity is not one of low\|medium\|high\|critical/);
|
|
330
|
+
assert.match(output, /js\/future-query/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("an ABSENT severity is not a finding — other tools share this alert surface", () => {
|
|
334
|
+
// secret-scan-push.yml uploads gitleaks SARIF to the same code scanning
|
|
335
|
+
// surface on public repos, and those alerts carry no security severity at
|
|
336
|
+
// all. Failing closed on them would red every pull request, so the
|
|
337
|
+
// unrankable check deliberately fires on present-but-unknown only.
|
|
338
|
+
const { status, output } = runGate({
|
|
339
|
+
pages: [[alert(null, "gitleaks.generic-api-key"), alert("low")]],
|
|
340
|
+
});
|
|
341
|
+
assert.equal(status, 0);
|
|
342
|
+
assert.match(output, /✅ No open code scanning alerts at or above high/);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("AC-2: a response that is not parseable JSON fails closed with a named error", () => {
|
|
346
|
+
const { status, output } = runGate({ pages: ["<html>502 Bad Gateway</html>\n"] });
|
|
347
|
+
assert.equal(status, 1);
|
|
348
|
+
assert.match(output, /Could not parse the code scanning alerts response for refs\/pull\/42\/merge/);
|
|
349
|
+
assert.match(output, /fails closed/);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("AC-2: an invalid `fail-on-alert-severity` fails the job rather than gating on nothing", () => {
|
|
353
|
+
// A typo must not silently degrade to "rank 0, nothing is ever above it".
|
|
354
|
+
for (const threshold of ["", "banana", "High", "none"]) {
|
|
355
|
+
const { status, output } = runGate({ pages: [[alert("critical")]], threshold });
|
|
356
|
+
assert.equal(status, 1, `threshold '${threshold}' must fail`);
|
|
357
|
+
assert.match(
|
|
358
|
+
output,
|
|
359
|
+
/fail-on-alert-severity must be one of low\|medium\|high\|critical/,
|
|
360
|
+
`threshold '${threshold}' must say why`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("AC-2: `gh` failing every attempt fails closed after the bounded retry", () => {
|
|
366
|
+
const { status, output, requests } = runGate({ ghFails: true });
|
|
367
|
+
assert.equal(status, 1);
|
|
368
|
+
assert.match(output, /Could not read code scanning alerts for refs\/pull\/42\/merge/);
|
|
369
|
+
assert.match(output, /did not conclusively report clean, so this gate fails closed/);
|
|
370
|
+
assert.equal(requests.length, 6, "the retry is bounded at six attempts, then fails — it never converts a persistent failure into a pass");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("a transient failure that later succeeds does not fail the job", () => {
|
|
374
|
+
// The complement of the case above: the retry exists for propagation lag, so
|
|
375
|
+
// it must still be able to conclude. Proven by the six-attempt budget being
|
|
376
|
+
// spent only when every attempt fails.
|
|
377
|
+
const { status, requests } = runGate({ pages: [[alert("medium")]] });
|
|
378
|
+
assert.equal(status, 0);
|
|
379
|
+
assert.equal(requests.length, 1, "a readable API is read once");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// AC-5 — the alerts read is paginated
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
test("AC-5: the alerts request is paginated and asks for the largest page", () => {
|
|
387
|
+
const { requests } = runGate({ pages: [[]] });
|
|
388
|
+
assert.equal(requests.length, 1);
|
|
389
|
+
const [request] = requests;
|
|
390
|
+
assert.match(request, /(^|\s)--paginate(\s|$)/, "the read must follow every page of alerts");
|
|
391
|
+
assert.match(request, /per_page=100/, "and ask for the largest page, to need fewer of them");
|
|
392
|
+
assert.match(request, /state=open/);
|
|
393
|
+
assert.match(request, /ref=refs\/pull\/42\/merge/, "the gate reads the ANALYZED ref, not the default branch");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("AC-5: a blocking alert past the first page still fails the job", () => {
|
|
397
|
+
// The bound, asserted behaviourally. Before Story #380 the read took a
|
|
398
|
+
// single unpaginated page, so a ref with more than 100 open alerts and every
|
|
399
|
+
// `high` past page 1 produced a silent PASS. Dropping `--paginate` from the
|
|
400
|
+
// request makes this test red, because the stub then answers page 1 only.
|
|
401
|
+
const { status, output } = runGate({
|
|
402
|
+
pages: [[alert("low"), alert("medium")], [alert("high", "js/redos", "scripts/b.mjs", 42)]],
|
|
403
|
+
});
|
|
404
|
+
assert.equal(status, 1, "an alert on page 2 must block exactly as one on page 1 does");
|
|
405
|
+
assert.match(output, /js\/redos/);
|
|
406
|
+
assert.match(output, /scripts\/b\.mjs:42/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("AC-5: every page is ranked, and the count spans all of them", () => {
|
|
410
|
+
const { status, output } = runGate({
|
|
411
|
+
pages: [[alert("critical", "js/a")], [alert("high", "js/b")], [alert("low", "js/c")]],
|
|
412
|
+
});
|
|
413
|
+
assert.equal(status, 1);
|
|
414
|
+
assert.match(output, /2 open code scanning alert\(s\) at or above high/);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// 1. WIRING
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
test("ci.yml calls codeql.yml as a job", () => {
|
|
422
|
+
const block = extractJobBlock(read(CI), SCAN_JOB);
|
|
423
|
+
assert.match(
|
|
424
|
+
block,
|
|
425
|
+
/^ {4}uses:\s*\.\/\.github\/workflows\/codeql\.yml\s*$/m,
|
|
426
|
+
`\`${SCAN_JOB}\` must call ./.github/workflows/codeql.yml — the dogfood ` +
|
|
427
|
+
"self-call is what puts the scan inside this repo's own run"
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("the required aggregator depends on the code-scanning job", () => {
|
|
432
|
+
const needs = extractNeeds(extractJobBlock(read(CI), AGGREGATOR));
|
|
433
|
+
assert.ok(
|
|
434
|
+
needs.includes(SCAN_JOB),
|
|
435
|
+
`\`${AGGREGATOR}\` must list \`${SCAN_JOB}\` in \`needs:\` — that single ` +
|
|
436
|
+
`line is the whole gate. Found: ${needs.join(", ") || "(none)"}`
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("the code-scanning job fails on a high-severity alert, not merely on a crash", () => {
|
|
441
|
+
const block = extractJobBlock(read(CI), SCAN_JOB);
|
|
442
|
+
// `github/codeql-action/analyze` uploads its SARIF and exits 0 whatever it
|
|
443
|
+
// found — a scan that just introduced a critical alert is a SUCCESSFUL job.
|
|
444
|
+
// Without a threshold this gate would assert "the scan ran", not "the scan
|
|
445
|
+
// came back clean", and the alert signal GitHub does red is a CHECK RUN,
|
|
446
|
+
// which no aggregator can `needs:`.
|
|
447
|
+
const m = block.match(/^ {6}fail-on-alert-severity:\s*(\S+)\s*$/m);
|
|
448
|
+
assert.ok(
|
|
449
|
+
m,
|
|
450
|
+
`\`${SCAN_JOB}\` must pass \`fail-on-alert-severity\` — otherwise a pull ` +
|
|
451
|
+
"request that introduces a high-severity alert still reaches a green " +
|
|
452
|
+
"aggregator, which is the exact regression this gate exists to stop"
|
|
453
|
+
);
|
|
454
|
+
assert.ok(
|
|
455
|
+
["high", "critical"].includes(m[1]),
|
|
456
|
+
`\`fail-on-alert-severity\` must be \`high\` or \`critical\` (got \`${m[1]}\`)`
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("codeql.yml declares the gate as an opt-in workflow_call input", () => {
|
|
461
|
+
const codeql = read(CODEQL);
|
|
462
|
+
assert.match(
|
|
463
|
+
codeql,
|
|
464
|
+
/^ {6}fail-on-alert-severity:$/m,
|
|
465
|
+
"codeql.yml must declare the `fail-on-alert-severity` workflow_call input"
|
|
466
|
+
);
|
|
467
|
+
assert.match(
|
|
468
|
+
codeql,
|
|
469
|
+
/^ {8}default:\s*''\s*$/m,
|
|
470
|
+
"`fail-on-alert-severity` must default to empty — the gate is opt-in, so " +
|
|
471
|
+
"an existing caller and the schedule run keep upload-and-report behaviour"
|
|
472
|
+
);
|
|
473
|
+
assert.match(
|
|
474
|
+
stepByName(codeql, GATE_STEP),
|
|
475
|
+
/^\s+if: inputs\.fail-on-alert-severity != ''\s*$/m,
|
|
476
|
+
"the gate step must stay conditional on the input, or the schedule run — " +
|
|
477
|
+
"where `inputs` is empty — would gate on a threshold it was never given"
|
|
478
|
+
);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("the code-scanning job grants the scopes codeql.yml declares", () => {
|
|
482
|
+
const block = extractJobBlock(read(CI), SCAN_JOB);
|
|
483
|
+
// GitHub validates a called workflow's declared permissions against the
|
|
484
|
+
// caller's grant at DISPATCH time. A missing scope fails the entire call
|
|
485
|
+
// with startup_failure before any job runs (the Story #292 lesson).
|
|
486
|
+
for (const scope of ["contents: read", "security-events: write", "actions: read"]) {
|
|
487
|
+
assert.ok(
|
|
488
|
+
block.includes(scope),
|
|
489
|
+
`\`${SCAN_JOB}\` must grant \`${scope}\` — codeql.yml declares it, and a ` +
|
|
490
|
+
"caller grant narrower than the called workflow's declaration is a " +
|
|
491
|
+
"startup_failure, not a skipped step"
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// 2. FAIL TOWARD BLOCKING
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
test("the code-scanning job cannot reach the `skipped` conclusion", () => {
|
|
501
|
+
const keys = jobKeys(extractJobBlock(read(CI), SCAN_JOB));
|
|
502
|
+
// The aggregator passes `success` OR `skipped`, and its run script is
|
|
503
|
+
// byte-identical-mirrored with pr-quality.yml — it cannot special-case one
|
|
504
|
+
// job. So the guarantee has to be structural: `skipped` is reachable only
|
|
505
|
+
// via an `if:` condition or a skipped/failed dependency.
|
|
506
|
+
assert.ok(
|
|
507
|
+
!keys.has("if"),
|
|
508
|
+
`\`${SCAN_JOB}\` must not declare \`if:\` — a conditional job can resolve ` +
|
|
509
|
+
"`skipped`, which the aggregator treats as a pass, making the gate " +
|
|
510
|
+
"silently advisory again"
|
|
511
|
+
);
|
|
512
|
+
assert.ok(
|
|
513
|
+
!keys.has("needs"),
|
|
514
|
+
`\`${SCAN_JOB}\` must not declare \`needs:\` — a job whose dependency is ` +
|
|
515
|
+
"skipped or failed is itself skipped, which the aggregator passes"
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("AC-4: codeql.yml's `analyze` job cannot reach the `skipped` conclusion either", () => {
|
|
520
|
+
// The caller-side pin above is only half the guarantee. `code-scanning` is a
|
|
521
|
+
// `uses:` job, so its result is the CALLED workflow's result — and a called
|
|
522
|
+
// workflow whose every job skipped reports `skipped`, which the aggregator
|
|
523
|
+
// passes. An `if:` on `analyze` therefore reverts Story #366 from inside
|
|
524
|
+
// codeql.yml, without touching ci.yml at all.
|
|
525
|
+
const keys = jobKeys(extractJobBlock(read(CODEQL), ANALYZE_JOB));
|
|
526
|
+
assert.ok(
|
|
527
|
+
!keys.has("if"),
|
|
528
|
+
`codeql.yml's \`${ANALYZE_JOB}\` job must not declare \`if:\` — the ` +
|
|
529
|
+
"aggregator reads a wholly-skipped called workflow as a pass, so a " +
|
|
530
|
+
"condition here makes the merge gate silently advisory again. Gate an " +
|
|
531
|
+
"individual STEP instead (the alert step's `if:` is at step level)"
|
|
532
|
+
);
|
|
533
|
+
assert.ok(
|
|
534
|
+
!keys.has("needs"),
|
|
535
|
+
`codeql.yml's \`${ANALYZE_JOB}\` job must not declare \`needs:\` — a job ` +
|
|
536
|
+
"whose dependency skips or fails is itself skipped, with the same effect"
|
|
537
|
+
);
|
|
538
|
+
assert.ok(keys.has("steps"), "the extraction must have found the real job block");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// 3. ONE SCAN PATH
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
test("codeql.yml does not also trigger itself on push or pull_request", () => {
|
|
546
|
+
const triggers = extractTriggers(read(CODEQL));
|
|
547
|
+
for (const trigger of ["push", "pull_request"]) {
|
|
548
|
+
assert.ok(
|
|
549
|
+
!triggers.has(trigger),
|
|
550
|
+
`codeql.yml must not declare \`${trigger}:\` — ci.yml's gating call ` +
|
|
551
|
+
"already analyzes that commit, and a second run under the same " +
|
|
552
|
+
"`category` overwrites the first SARIF upload for a check that gates " +
|
|
553
|
+
"nothing"
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("ci.yml's own triggers cover the commits CodeQL must analyze", () => {
|
|
559
|
+
const triggers = extractTriggers(read(CI));
|
|
560
|
+
// pull_request is the gate itself; push to main is the default-branch
|
|
561
|
+
// baseline CodeQL diffs PR alerts against. Losing either would make the
|
|
562
|
+
// gate green on a repo it never scanned.
|
|
563
|
+
assert.ok(triggers.has("pull_request"), "ci.yml must trigger on pull_request");
|
|
564
|
+
assert.ok(triggers.has("push"), "ci.yml must trigger on push to main");
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("the weekly schedule sweep survives on codeql.yml", () => {
|
|
568
|
+
// ci.yml has no `schedule:` trigger, so removing it here would drop the
|
|
569
|
+
// periodic re-scan that catches alerts from newly published queries.
|
|
570
|
+
assert.ok(
|
|
571
|
+
extractTriggers(read(CODEQL)).has("schedule"),
|
|
572
|
+
"codeql.yml must keep its `schedule:` sweep — ci.yml does not run on cron"
|
|
573
|
+
);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
// 4. CONSUMER BLAST RADIUS ZERO
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
test("codeql.yml keeps its workflow_call contract", () => {
|
|
581
|
+
assert.ok(
|
|
582
|
+
extractTriggers(read(CODEQL)).has("workflow_call"),
|
|
583
|
+
"codeql.yml must stay `workflow_call`-consumable — a GHAS consumer pins it"
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("no reusable workflow a consumer calls gained a CodeQL job", () => {
|
|
588
|
+
// The fleet's private repos (athportal, domio, swarm-os) have code scanning
|
|
589
|
+
// disabled; a CodeQL job there fails closed on a 403. The vendored Semgrep
|
|
590
|
+
// tier remains their blocking SAST.
|
|
591
|
+
const prQuality = read(PR_QUALITY);
|
|
592
|
+
assert.ok(
|
|
593
|
+
!/uses:.*codeql\.yml/.test(prQuality),
|
|
594
|
+
"pr-quality.yml must not call codeql.yml — this gate is repository-local"
|
|
595
|
+
);
|
|
596
|
+
assert.ok(
|
|
597
|
+
!/github\/codeql-action\/(init|autobuild|analyze)/.test(prQuality),
|
|
598
|
+
"pr-quality.yml must not run CodeQL analysis steps — a consumer without " +
|
|
599
|
+
"Advanced Security would gain a newly-failing job"
|
|
600
|
+
);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("the required status-context set is unchanged", () => {
|
|
604
|
+
const contract = JSON.parse(read(CONTRACT));
|
|
605
|
+
assert.deepEqual(
|
|
606
|
+
contract.requiredStatusChecks,
|
|
607
|
+
[AGGREGATOR],
|
|
608
|
+
"gating through the aggregator exists precisely so the required-context " +
|
|
609
|
+
"set stays a single entry — adding CodeQL's check name here would fail " +
|
|
610
|
+
"check-required-contexts.mjs, which matches declared entries against " +
|
|
611
|
+
"job identifiers"
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// AC-6 — the gate has a documented way out
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
test("AC-6: the rollback runbook names all three recovery moves, in order", () => {
|
|
620
|
+
const runbook = read(ROLLBACK);
|
|
621
|
+
const section = runbook.slice(runbook.indexOf("## 7."));
|
|
622
|
+
assert.ok(section.length > 0, "rollback.md must carry a code-scanning gate section");
|
|
623
|
+
|
|
624
|
+
const moves = [
|
|
625
|
+
/\(a\).*dismiss/is,
|
|
626
|
+
/\(b\).*fail-on-alert-severity/is,
|
|
627
|
+
/\(c\).*break.glass/is,
|
|
628
|
+
];
|
|
629
|
+
let cursor = 0;
|
|
630
|
+
for (const [i, move] of moves.entries()) {
|
|
631
|
+
const rest = section.slice(cursor);
|
|
632
|
+
const m = rest.match(move);
|
|
633
|
+
assert.ok(m, `recovery move ${"abc"[i]} must be documented`);
|
|
634
|
+
cursor += m.index + 1;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// The break-glass move has to say WHO, or it is not a runbook entry.
|
|
638
|
+
assert.match(
|
|
639
|
+
section,
|
|
640
|
+
/@dsj1984/,
|
|
641
|
+
"the break-glass move must name who may perform it — this repository's " +
|
|
642
|
+
"ruleset carries an EMPTY bypass list, so no role bypasses it implicitly"
|
|
643
|
+
);
|
|
644
|
+
assert.match(
|
|
645
|
+
section,
|
|
646
|
+
/ci-required/,
|
|
647
|
+
"the runbook must name the required context the gate reaches through"
|
|
648
|
+
);
|
|
649
|
+
});
|