mandrel-platform 1.2.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-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 +137 -21
- package/scripts/check-first-party-pin-freshness.test.mjs +135 -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/stryker-base-config.test.mjs +256 -0
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
collectPinnedRefs,
|
|
37
37
|
resolveManifest,
|
|
38
38
|
manifestsMatch,
|
|
39
|
+
diffSubpathAtSha,
|
|
39
40
|
runCheck,
|
|
40
41
|
runCli,
|
|
41
42
|
} from "./check-first-party-pin-freshness.mjs";
|
|
@@ -255,6 +256,140 @@ test("clean: two call sites pinning the same fresh SHA both pass", () => {
|
|
|
255
256
|
assert.equal(result.scanned, 2);
|
|
256
257
|
});
|
|
257
258
|
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Story #379 — the comparison is the whole action DIRECTORY, not the manifest
|
|
261
|
+
//
|
|
262
|
+
// A composite action whose behaviour lives in a sibling script is the majority
|
|
263
|
+
// of this repo's action surface (`osv-scan`, `osv-track-issue`), and a
|
|
264
|
+
// manifest-only comparison is structurally unable to see a change there. That
|
|
265
|
+
// blind spot shipped live: Story #365 rewrote `osv-scan/osv-report-gate.mjs`
|
|
266
|
+
// (+189/-12) without touching `action.yml`, so both call sites read as fresh
|
|
267
|
+
// while executing the old gate.
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
const SIBLING_MANIFEST = [
|
|
271
|
+
"name: demo",
|
|
272
|
+
"description: fixture composite action whose behaviour lives in a sibling script",
|
|
273
|
+
"runs:",
|
|
274
|
+
" using: composite",
|
|
275
|
+
" steps:",
|
|
276
|
+
" - shell: bash",
|
|
277
|
+
" run: node ./gate.mjs",
|
|
278
|
+
"",
|
|
279
|
+
].join("\n");
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Create a repo whose action manifest NEVER changes — only the sibling
|
|
283
|
+
* `gate.mjs` does. The `before` commit therefore carries a byte-identical
|
|
284
|
+
* `action.yml` alongside a stale `gate.mjs`.
|
|
285
|
+
*/
|
|
286
|
+
function makeSiblingScriptRepo(label) {
|
|
287
|
+
const root = mkdtempSync(join(tmpdir(), `pinfresh-${label}-`));
|
|
288
|
+
git(root, "init", "-b", "main");
|
|
289
|
+
git(root, "config", "user.email", "fixture@example.invalid");
|
|
290
|
+
git(root, "config", "user.name", "Pin Freshness Fixture");
|
|
291
|
+
git(root, "config", "commit.gpgsign", "false");
|
|
292
|
+
git(root, "config", "core.hooksPath", join(root, ".no-hooks"));
|
|
293
|
+
|
|
294
|
+
put(root, `${SUBPATH}/action.yml`, SIBLING_MANIFEST);
|
|
295
|
+
put(root, `${SUBPATH}/gate.mjs`, "process.exit(0);\n");
|
|
296
|
+
git(root, "add", "-A");
|
|
297
|
+
git(root, "commit", "-m", "initial action");
|
|
298
|
+
const before = git(root, "rev-parse", "HEAD");
|
|
299
|
+
|
|
300
|
+
// Behaviour change, manifest untouched.
|
|
301
|
+
put(root, `${SUBPATH}/gate.mjs`, "process.exit(process.env.FAIL ? 1 : 0);\n");
|
|
302
|
+
git(root, "add", "-A");
|
|
303
|
+
git(root, "commit", "-m", "harden the gate");
|
|
304
|
+
const after = git(root, "rev-parse", "HEAD");
|
|
305
|
+
|
|
306
|
+
return { root, before, after };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** `git show <sha>:<path>` without the trimming the `git` helper applies. */
|
|
310
|
+
function showRaw(root, sha, relPath) {
|
|
311
|
+
return execFileSync("git", ["show", `${sha}:${relPath}`], { cwd: root, encoding: "utf8" });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
test("stale: a sibling script that differs at the pinned SHA is stale even when action.yml is byte-identical", () => {
|
|
315
|
+
const { root, before } = makeSiblingScriptRepo("sibling-stale");
|
|
316
|
+
track(root);
|
|
317
|
+
put(root, ".github/workflows/fixture.yml", workflow(before));
|
|
318
|
+
|
|
319
|
+
// The premise, asserted rather than assumed: a manifest-only comparison
|
|
320
|
+
// would have called this pin fresh.
|
|
321
|
+
assert.equal(
|
|
322
|
+
showRaw(root, before, `${SUBPATH}/action.yml`),
|
|
323
|
+
readFileSync(join(root, SUBPATH, "action.yml"), "utf8"),
|
|
324
|
+
"premise: action.yml is byte-identical at the pinned SHA"
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
|
|
328
|
+
|
|
329
|
+
assert.equal(result.ok, false);
|
|
330
|
+
assert.equal(result.stale.length, 1);
|
|
331
|
+
assert.equal(result.unreachable.length, 0);
|
|
332
|
+
assert.match(result.stale[0].reason, /gate\.mjs/, "the report names the drifting sibling");
|
|
333
|
+
|
|
334
|
+
const { code, stderr } = capture(["--cwd", root, "--first-party-owner", OWNER]);
|
|
335
|
+
assert.equal(code, 1);
|
|
336
|
+
assert.match(stderr, /gate\.mjs/);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("clean: a pin carrying the current sibling script exits 0", () => {
|
|
340
|
+
const { root, after } = makeSiblingScriptRepo("sibling-clean");
|
|
341
|
+
track(root);
|
|
342
|
+
put(root, ".github/workflows/fixture.yml", workflow(after));
|
|
343
|
+
|
|
344
|
+
const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
|
|
345
|
+
assert.equal(result.ok, true);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("stale: a file added to the action directory since the pinned SHA is stale", () => {
|
|
349
|
+
const { root, after } = makeSiblingScriptRepo("sibling-added");
|
|
350
|
+
track(root);
|
|
351
|
+
put(root, `${SUBPATH}/helper.mjs`, "export const help = () => 1;\n");
|
|
352
|
+
git(root, "add", "-A");
|
|
353
|
+
put(root, ".github/workflows/fixture.yml", workflow(after));
|
|
354
|
+
|
|
355
|
+
const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
|
|
356
|
+
|
|
357
|
+
assert.equal(result.ok, false);
|
|
358
|
+
assert.match(result.stale[0].reason, /helper\.mjs/);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("stale: a file removed from the action directory since the pinned SHA is stale", () => {
|
|
362
|
+
const { root, after } = makeSiblingScriptRepo("sibling-removed");
|
|
363
|
+
track(root);
|
|
364
|
+
git(root, "rm", "-q", `${SUBPATH}/gate.mjs`);
|
|
365
|
+
put(root, ".github/workflows/fixture.yml", workflow(after));
|
|
366
|
+
|
|
367
|
+
const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
|
|
368
|
+
|
|
369
|
+
assert.equal(result.ok, false);
|
|
370
|
+
assert.match(result.stale[0].reason, /gate\.mjs/);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("diffSubpathAtSha: a tracked file missing from disk is drift, not a silent skip", () => {
|
|
374
|
+
const path = `${SUBPATH}/gone.mjs`;
|
|
375
|
+
const fake = { lsTree: () => [path], lsFiles: () => [path], show: () => "body\n" };
|
|
376
|
+
|
|
377
|
+
const drift = diffSubpathAtSha(fake, "/no-such-root", "0".repeat(40), SUBPATH);
|
|
378
|
+
|
|
379
|
+
assert.deepEqual(drift, [{ path, kind: "unreadable" }]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("diffSubpathAtSha: a blob git cannot resolve at the pinned SHA is drift, not a pass", () => {
|
|
383
|
+
const { root } = makeSiblingScriptRepo("sibling-unresolvable");
|
|
384
|
+
track(root);
|
|
385
|
+
const path = `${SUBPATH}/gate.mjs`;
|
|
386
|
+
const fake = { lsTree: () => [path], lsFiles: () => [path], show: () => null };
|
|
387
|
+
|
|
388
|
+
const drift = diffSubpathAtSha(fake, root, "0".repeat(40), SUBPATH);
|
|
389
|
+
|
|
390
|
+
assert.deepEqual(drift, [{ path, kind: "differs" }]);
|
|
391
|
+
});
|
|
392
|
+
|
|
258
393
|
// ---------------------------------------------------------------------------
|
|
259
394
|
// AC-7 — third-party / local / docker references are never classified
|
|
260
395
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-gitleaks-allowlist.test.mjs — the secret-scan tier's escape hatch
|
|
4
|
+
* (Story #365).
|
|
5
|
+
*
|
|
6
|
+
* The failure this closes: the secret scan exposed no seam for suppressing a
|
|
7
|
+
* known false positive, so ordinary prose matching the generic-secret
|
|
8
|
+
* heuristic blocked a docs-only pull request with no option but rewording the
|
|
9
|
+
* source or turning the tier off. A required check needs an escape that is not
|
|
10
|
+
* disabling it.
|
|
11
|
+
*
|
|
12
|
+
* Reading the YAML is not enough: what decides the outcome is a shell branch,
|
|
13
|
+
* and the failure mode is silent — a caller-supplied config that quietly
|
|
14
|
+
* REPLACES the default ruleset would turn one suppression into a tier-wide
|
|
15
|
+
* opt-out that still reports green. So this extracts the real `run:` body and
|
|
16
|
+
* executes it against a stub gitleaks that echoes its argv, the same
|
|
17
|
+
* read-then-execute approach as check-setup-toolchain-store.test.mjs.
|
|
18
|
+
*
|
|
19
|
+
* Run: node --test scripts/check-gitleaks-allowlist.test.mjs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import assert from "node:assert/strict";
|
|
23
|
+
import { test } from "node:test";
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { readFileSync, mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
import { stepByName, runScript } from "./lib/yaml-step.mjs";
|
|
30
|
+
|
|
31
|
+
const ACTION = ".github/actions/gitleaks-scan/action.yml";
|
|
32
|
+
const WORKFLOW = ".github/workflows/pr-quality.yml";
|
|
33
|
+
|
|
34
|
+
const actionText = readFileSync(ACTION, "utf8");
|
|
35
|
+
const workflowText = readFileSync(WORKFLOW, "utf8");
|
|
36
|
+
const scanScript = runScript(stepByName(actionText, "Run gitleaks scan"));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The definition block of a single `workflow_call` input, keyed by name: every
|
|
40
|
+
* line indented under the ` <name>:` key. Asserts rather than returning a
|
|
41
|
+
* sentinel, so a renamed input fails at the point of extraction instead of
|
|
42
|
+
* silently passing an empty block to every downstream matcher.
|
|
43
|
+
*/
|
|
44
|
+
function workflowInput(name) {
|
|
45
|
+
const lines = workflowText.split("\n");
|
|
46
|
+
const start = lines.indexOf(` ${name}:`);
|
|
47
|
+
assert.notEqual(start, -1, `workflow_call input "${name}" is not declared`);
|
|
48
|
+
const body = [];
|
|
49
|
+
for (let i = start + 1; i < lines.length && /^ {8,}\S/.test(lines[i]); i++) {
|
|
50
|
+
body.push(lines[i]);
|
|
51
|
+
}
|
|
52
|
+
return body.join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run the extracted scan body against a stub gitleaks that echoes its argv and
|
|
57
|
+
* exits `leakExit` (non-zero = a finding). Returns `{ status, output }` rather
|
|
58
|
+
* than throwing, because a non-zero exit IS the assertion in the blocking
|
|
59
|
+
* cases.
|
|
60
|
+
*/
|
|
61
|
+
function runScan({ files = {}, leakExit = 0, ...env } = {}) {
|
|
62
|
+
const dir = mkdtempSync(path.join(tmpdir(), "gitleaks-allowlist-"));
|
|
63
|
+
try {
|
|
64
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
65
|
+
writeFileSync(path.join(dir, name), contents);
|
|
66
|
+
}
|
|
67
|
+
const stub = path.join(dir, "gitleaks-stub");
|
|
68
|
+
writeFileSync(stub, `#!/bin/sh\necho "GITLEAKS_ARGV: $*"\nexit ${leakExit}\n`);
|
|
69
|
+
chmodSync(stub, 0o755);
|
|
70
|
+
const script = path.join(dir, "scan.sh");
|
|
71
|
+
writeFileSync(script, scanScript);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const output = execFileSync("bash", [script], {
|
|
75
|
+
cwd: dir,
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
78
|
+
env: {
|
|
79
|
+
PATH: process.env.PATH,
|
|
80
|
+
GITLEAKS_BIN: stub,
|
|
81
|
+
SCAN_MODE: "dir",
|
|
82
|
+
LOG_OPTS: "",
|
|
83
|
+
REDACT: "100",
|
|
84
|
+
VERBOSE: "false",
|
|
85
|
+
REPORT_FORMAT: "",
|
|
86
|
+
REPORT_PATH: "",
|
|
87
|
+
NON_BLOCKING: "false",
|
|
88
|
+
CONFIG_PATH: "",
|
|
89
|
+
ALLOW_RULE_REPLACEMENT: "false",
|
|
90
|
+
...env,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return { status: 0, output };
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return {
|
|
96
|
+
status: err.status ?? 1,
|
|
97
|
+
output: `${err.stdout ?? ""}${err.stderr ?? ""}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(dir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const EXTENDING_CONFIG = ['[extend]', 'useDefault = true', '', '[[rules]]', 'id = "x"'].join("\n");
|
|
106
|
+
|
|
107
|
+
test("no config-path leaves the scan byte-for-byte as it was", () => {
|
|
108
|
+
const { status, output } = runScan();
|
|
109
|
+
assert.equal(status, 0);
|
|
110
|
+
assert.doesNotMatch(output, /--config/);
|
|
111
|
+
assert.match(output, /--redact=100/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("AC-7: a caller-supplied allowlist config is passed to gitleaks as --config", () => {
|
|
115
|
+
const { status, output } = runScan({
|
|
116
|
+
files: { ".gitleaks.toml": EXTENDING_CONFIG },
|
|
117
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
118
|
+
});
|
|
119
|
+
assert.equal(status, 0);
|
|
120
|
+
assert.match(output, /--config \.gitleaks\.toml/);
|
|
121
|
+
// The tier is still on: redaction and the scan itself are unchanged.
|
|
122
|
+
assert.match(output, /--redact=100/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("AC-7: the suppression seam works without disabling the tier or rewording the source", () => {
|
|
126
|
+
// The docs-only PR case: a config that allowlists the one false positive,
|
|
127
|
+
// scanned in the same blocking mode, with the source untouched.
|
|
128
|
+
const { status, output } = runScan({
|
|
129
|
+
files: {
|
|
130
|
+
".gitleaks.toml": [
|
|
131
|
+
"[extend]",
|
|
132
|
+
"useDefault = true",
|
|
133
|
+
"",
|
|
134
|
+
"[[allowlists]]",
|
|
135
|
+
'description = "prose in docs/reusable-workflows.md"',
|
|
136
|
+
'paths = ["docs/reusable-workflows\\\\.md"]',
|
|
137
|
+
].join("\n"),
|
|
138
|
+
},
|
|
139
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
140
|
+
});
|
|
141
|
+
assert.equal(status, 0);
|
|
142
|
+
assert.match(output, /--config \.gitleaks\.toml/);
|
|
143
|
+
assert.doesNotMatch(output, /non-blocking/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("AC-8: with a config in place, any other finding still fails the step", () => {
|
|
147
|
+
// The load-bearing half. An allowlist narrows one rule; it must not turn the
|
|
148
|
+
// gate into a reporter. A leak (stub exit 1) fails the step exactly as it
|
|
149
|
+
// does with no config at all.
|
|
150
|
+
const withConfig = runScan({
|
|
151
|
+
files: { ".gitleaks.toml": EXTENDING_CONFIG },
|
|
152
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
153
|
+
leakExit: 1,
|
|
154
|
+
});
|
|
155
|
+
assert.notEqual(withConfig.status, 0, "a finding must still fail the step");
|
|
156
|
+
|
|
157
|
+
const withoutConfig = runScan({ leakExit: 1 });
|
|
158
|
+
assert.equal(withConfig.status, withoutConfig.status);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("AC-8: a config that replaces the default ruleset is rejected", () => {
|
|
162
|
+
// Without this guard, `--config` is a supported way to delete every rule and
|
|
163
|
+
// still report green — a tier-wide opt-out wearing an allowlist's clothes.
|
|
164
|
+
const { status, output } = runScan({
|
|
165
|
+
files: { ".gitleaks.toml": '[[rules]]\nid = "only-mine"\n' },
|
|
166
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
167
|
+
});
|
|
168
|
+
assert.equal(status, 1);
|
|
169
|
+
assert.match(output, /must extend the default ruleset/);
|
|
170
|
+
assert.match(output, /useDefault = true/);
|
|
171
|
+
assert.doesNotMatch(output, /GITLEAKS_ARGV/, "gitleaks must not run on a rejected config");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("AC-8: useDefault must sit in [extend], not merely appear in the file", () => {
|
|
175
|
+
// The guard's one remaining bypass if it were a bare grep: gitleaks reads
|
|
176
|
+
// useDefault only from the [extend] table, so the same line under [[rules]]
|
|
177
|
+
// is inert — a rule-replacing config that reads as if it extended.
|
|
178
|
+
const { status, output } = runScan({
|
|
179
|
+
files: {
|
|
180
|
+
".gitleaks.toml": ["[[rules]]", 'id = "only-mine"', "useDefault = true"].join("\n"),
|
|
181
|
+
},
|
|
182
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
183
|
+
});
|
|
184
|
+
assert.equal(status, 1);
|
|
185
|
+
assert.match(output, /must extend the default ruleset/);
|
|
186
|
+
assert.doesNotMatch(output, /GITLEAKS_ARGV/, "gitleaks must not run on a rejected config");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("AC-7: [extend] is still honoured when other tables follow it", () => {
|
|
190
|
+
const { status, output } = runScan({
|
|
191
|
+
files: {
|
|
192
|
+
".gitleaks.toml": [
|
|
193
|
+
"[extend]",
|
|
194
|
+
"useDefault = true",
|
|
195
|
+
"",
|
|
196
|
+
"[[allowlists]]",
|
|
197
|
+
'description = "prose"',
|
|
198
|
+
].join("\n"),
|
|
199
|
+
},
|
|
200
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
201
|
+
});
|
|
202
|
+
assert.equal(status, 0);
|
|
203
|
+
assert.match(output, /--config \.gitleaks\.toml/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("a deliberate full rule-set replacement is possible, but only explicitly", () => {
|
|
207
|
+
const { status, output } = runScan({
|
|
208
|
+
files: { ".gitleaks.toml": '[[rules]]\nid = "only-mine"\n' },
|
|
209
|
+
CONFIG_PATH: ".gitleaks.toml",
|
|
210
|
+
ALLOW_RULE_REPLACEMENT: "true",
|
|
211
|
+
});
|
|
212
|
+
assert.equal(status, 0);
|
|
213
|
+
assert.match(output, /--config \.gitleaks\.toml/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a config-path that does not exist is a hard error, not a silently-skipped flag", () => {
|
|
217
|
+
const { status, output } = runScan({ CONFIG_PATH: "nope.toml" });
|
|
218
|
+
assert.equal(status, 1);
|
|
219
|
+
assert.match(output, /does not exist in the checkout/);
|
|
220
|
+
assert.doesNotMatch(output, /GITLEAKS_ARGV/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("AC-3: a repo-root .gitleaks.toml is validated instead of silently auto-discovered", () => {
|
|
224
|
+
// gitleaks reads a repo-root .gitleaks.toml on its own whenever --config is
|
|
225
|
+
// absent. Left alone, that is a second, UNVALIDATED way into the scan — the
|
|
226
|
+
// useDefault guard never runs. The file is adopted as config-path instead.
|
|
227
|
+
const { status, output } = runScan({
|
|
228
|
+
files: { ".gitleaks.toml": EXTENDING_CONFIG },
|
|
229
|
+
});
|
|
230
|
+
assert.equal(status, 0);
|
|
231
|
+
assert.match(output, /--config \.gitleaks\.toml/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("AC-3: a rule-replacing repo-root .gitleaks.toml is rejected, naming config-path", () => {
|
|
235
|
+
// The bypass this closes. Auto-discovery would have applied this config —
|
|
236
|
+
// which deletes every default rule — and reported green.
|
|
237
|
+
const { status, output } = runScan({
|
|
238
|
+
files: { ".gitleaks.toml": '[[rules]]\nid = "only-mine"\n' },
|
|
239
|
+
});
|
|
240
|
+
assert.equal(status, 1);
|
|
241
|
+
assert.match(output, /must extend the default ruleset/);
|
|
242
|
+
assert.match(output, /config-path/);
|
|
243
|
+
assert.doesNotMatch(output, /GITLEAKS_ARGV/, "gitleaks must not run on a rejected config");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Not AC-3 evidence: this passes with the discovery branch reverted, because
|
|
247
|
+
// nothing ever routed a root file when config-path was set. It is a regression
|
|
248
|
+
// guard for one plausible mis-write of that branch — omitting its `[ -z
|
|
249
|
+
// "$CONFIG_PATH" ]` condition — and should not be read as proving AC-3.
|
|
250
|
+
test("an explicit config-path still wins over a repo-root .gitleaks.toml", () => {
|
|
251
|
+
const { status, output } = runScan({
|
|
252
|
+
files: { ".gitleaks.toml": EXTENDING_CONFIG, "custom.toml": EXTENDING_CONFIG },
|
|
253
|
+
CONFIG_PATH: "custom.toml",
|
|
254
|
+
});
|
|
255
|
+
assert.equal(status, 0);
|
|
256
|
+
assert.match(output, /--config custom\.toml/);
|
|
257
|
+
assert.doesNotMatch(output, /--config \.gitleaks\.toml/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("AC-3: a deliberate rule-replacing root config still opts in explicitly", () => {
|
|
261
|
+
const { status, output } = runScan({
|
|
262
|
+
files: { ".gitleaks.toml": '[[rules]]\nid = "only-mine"\n' },
|
|
263
|
+
ALLOW_RULE_REPLACEMENT: "true",
|
|
264
|
+
});
|
|
265
|
+
assert.equal(status, 0);
|
|
266
|
+
assert.match(output, /--config \.gitleaks\.toml/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("AC-1: pr-quality.yml declares both allowlist inputs with unchanged-behaviour defaults", () => {
|
|
270
|
+
// pr-quality.yml is compile-time resolved for every consumer, so a caller
|
|
271
|
+
// that passes neither input must get byte-identical behaviour: '' and
|
|
272
|
+
// 'false' are exactly the composite's own defaults.
|
|
273
|
+
const configPath = workflowInput("secret-scan-config-path");
|
|
274
|
+
assert.match(configPath, /^ {8}type: string$/m);
|
|
275
|
+
assert.match(configPath, /^ {8}default: ''$/m);
|
|
276
|
+
|
|
277
|
+
const allowReplacement = workflowInput("secret-scan-allow-default-rule-replacement");
|
|
278
|
+
assert.match(allowReplacement, /^ {8}type: string$/m);
|
|
279
|
+
assert.match(allowReplacement, /^ {8}default: 'false'$/m);
|
|
280
|
+
|
|
281
|
+
// Cross-repo portability contract (scripts/check-workflow-portability.mjs):
|
|
282
|
+
// no `${{ }}` in a workflow_call input description or default — GitHub
|
|
283
|
+
// resolves those during interface validation, before any context exists.
|
|
284
|
+
assert.doesNotMatch(configPath, /\$\{\{/);
|
|
285
|
+
assert.doesNotMatch(allowReplacement, /\$\{\{/);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("AC-2: both caller inputs reach the gitleaks composite's own inputs", () => {
|
|
289
|
+
// The whole point of the Story: an input a consumer can set has to arrive at
|
|
290
|
+
// the composite, or the documented escape from a false positive is
|
|
291
|
+
// unreachable and `enable-security: false` stays the only exit.
|
|
292
|
+
const step = stepByName(workflowText, "Secret scan (pinned gitleaks, blocking)");
|
|
293
|
+
assert.match(step, /^\s+config-path: \$\{\{ inputs\.secret-scan-config-path \}\}$/m);
|
|
294
|
+
assert.match(
|
|
295
|
+
step,
|
|
296
|
+
/^\s+allow-default-rule-replacement: \$\{\{ inputs\.secret-scan-allow-default-rule-replacement \}\}$/m,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("the action declares both inputs with the non-breaking empty/false defaults", () => {
|
|
301
|
+
// A consumer that passes neither input must be unaffected — the whole reason
|
|
302
|
+
// the defaults are '' and 'false'.
|
|
303
|
+
assert.match(actionText, /^ {2}config-path:$/m);
|
|
304
|
+
assert.match(actionText, /^ {2}allow-default-rule-replacement:$/m);
|
|
305
|
+
// Cross-repo portability contract: no `${{ }}` in input descriptions or
|
|
306
|
+
// defaults (scripts/check-workflow-portability.mjs).
|
|
307
|
+
const inputsBlock = actionText.slice(
|
|
308
|
+
actionText.indexOf("\ninputs:"),
|
|
309
|
+
actionText.indexOf("\nruns:"),
|
|
310
|
+
);
|
|
311
|
+
assert.doesNotMatch(inputsBlock, /\$\{\{/);
|
|
312
|
+
});
|
|
@@ -34,64 +34,19 @@ import { join, resolve, dirname } from "node:path";
|
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
35
35
|
import { tmpdir } from "node:os";
|
|
36
36
|
|
|
37
|
+
import { stepByName, runScript } from "./lib/yaml-step.mjs";
|
|
38
|
+
|
|
37
39
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
38
40
|
const prQuality = readFileSync(join(repoRoot, ".github/workflows/pr-quality.yml"), "utf8");
|
|
39
41
|
const advisoryScan = readFileSync(join(repoRoot, ".github/workflows/advisory-scan.yml"), "utf8");
|
|
40
42
|
const composite = readFileSync(join(repoRoot, ".github/actions/osv-scan/action.yml"), "utf8");
|
|
41
43
|
|
|
42
44
|
// ---------------------------------------------------------------------------
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
+
// Local extraction helpers. Step-block and `run:` extraction are shared via
|
|
46
|
+
// scripts/lib/yaml-step.mjs; the two below read mapping keys and a job block,
|
|
47
|
+
// which no other suite needs.
|
|
45
48
|
// ---------------------------------------------------------------------------
|
|
46
49
|
|
|
47
|
-
function stepByName(text, name) {
|
|
48
|
-
const lines = text.split("\n");
|
|
49
|
-
const nameIdx = lines.findIndex((l) => /^\s+(- )?name:\s/.test(l) && l.includes(name));
|
|
50
|
-
assert.notEqual(nameIdx, -1, `step "${name}" not found`);
|
|
51
|
-
let start = -1;
|
|
52
|
-
for (let i = nameIdx; i >= 0; i--) {
|
|
53
|
-
if (/^\s*-\s/.test(lines[i])) {
|
|
54
|
-
start = i;
|
|
55
|
-
break;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
assert.notEqual(start, -1, `opening bullet for step "${name}" not found`);
|
|
59
|
-
const bulletIndent = lines[start].match(/^(\s*)/)[1].length;
|
|
60
|
-
let end = lines.length;
|
|
61
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
62
|
-
if (/^\s*$/.test(lines[i])) continue;
|
|
63
|
-
const indent = lines[i].match(/^(\s*)/)[1].length;
|
|
64
|
-
if (indent < bulletIndent) {
|
|
65
|
-
end = i;
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
if (indent === bulletIndent && /^\s*-\s/.test(lines[i])) {
|
|
69
|
-
end = i;
|
|
70
|
-
break;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return lines.slice(start, end).join("\n");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** The dedented body of a step's `run: |` block scalar. */
|
|
77
|
-
function runScript(stepBlock) {
|
|
78
|
-
const lines = stepBlock.split("\n");
|
|
79
|
-
const start = lines.findIndex((l) => /^\s+run:\s*\|\s*$/.test(l));
|
|
80
|
-
assert.notEqual(start, -1, "`run: |` block not found");
|
|
81
|
-
const runIndent = lines[start].match(/^(\s*)/)[1].length;
|
|
82
|
-
const body = [];
|
|
83
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
84
|
-
if (/^\s*$/.test(lines[i])) {
|
|
85
|
-
body.push("");
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
const indent = lines[i].match(/^(\s*)/)[1].length;
|
|
89
|
-
if (indent <= runIndent) break;
|
|
90
|
-
body.push(lines[i].slice(runIndent + 2));
|
|
91
|
-
}
|
|
92
|
-
return body.join("\n");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
50
|
/**
|
|
96
51
|
* The body of a mapping key at a given indent, up to the next sibling key or
|
|
97
52
|
* end of input. Terminating on end-of-input matters: the LAST declared input
|