mandrel-platform 1.2.0 → 1.4.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.
@@ -36,8 +36,11 @@ import {
36
36
  collectPinnedRefs,
37
37
  resolveManifest,
38
38
  manifestsMatch,
39
+ diffSubpathAtSha,
40
+ companionsFor,
39
41
  runCheck,
40
42
  runCli,
43
+ COMPANION_SUBPATHS,
41
44
  } from "./check-first-party-pin-freshness.mjs";
42
45
 
43
46
  const OWNER = "test-owner/test-repo";
@@ -255,6 +258,299 @@ test("clean: two call sites pinning the same fresh SHA both pass", () => {
255
258
  assert.equal(result.scanned, 2);
256
259
  });
257
260
 
261
+ // ---------------------------------------------------------------------------
262
+ // Story #379 — the comparison is the whole action DIRECTORY, not the manifest
263
+ //
264
+ // A composite action whose behaviour lives in a sibling script is the majority
265
+ // of this repo's action surface (`osv-scan`, `osv-track-issue`), and a
266
+ // manifest-only comparison is structurally unable to see a change there. That
267
+ // blind spot shipped live: Story #365 rewrote `osv-scan/osv-report-gate.mjs`
268
+ // (+189/-12) without touching `action.yml`, so both call sites read as fresh
269
+ // while executing the old gate.
270
+ // ---------------------------------------------------------------------------
271
+
272
+ const SIBLING_MANIFEST = [
273
+ "name: demo",
274
+ "description: fixture composite action whose behaviour lives in a sibling script",
275
+ "runs:",
276
+ " using: composite",
277
+ " steps:",
278
+ " - shell: bash",
279
+ " run: node ./gate.mjs",
280
+ "",
281
+ ].join("\n");
282
+
283
+ /**
284
+ * Create a repo whose action manifest NEVER changes — only the sibling
285
+ * `gate.mjs` does. The `before` commit therefore carries a byte-identical
286
+ * `action.yml` alongside a stale `gate.mjs`.
287
+ */
288
+ function makeSiblingScriptRepo(label) {
289
+ const root = mkdtempSync(join(tmpdir(), `pinfresh-${label}-`));
290
+ git(root, "init", "-b", "main");
291
+ git(root, "config", "user.email", "fixture@example.invalid");
292
+ git(root, "config", "user.name", "Pin Freshness Fixture");
293
+ git(root, "config", "commit.gpgsign", "false");
294
+ git(root, "config", "core.hooksPath", join(root, ".no-hooks"));
295
+
296
+ put(root, `${SUBPATH}/action.yml`, SIBLING_MANIFEST);
297
+ put(root, `${SUBPATH}/gate.mjs`, "process.exit(0);\n");
298
+ git(root, "add", "-A");
299
+ git(root, "commit", "-m", "initial action");
300
+ const before = git(root, "rev-parse", "HEAD");
301
+
302
+ // Behaviour change, manifest untouched.
303
+ put(root, `${SUBPATH}/gate.mjs`, "process.exit(process.env.FAIL ? 1 : 0);\n");
304
+ git(root, "add", "-A");
305
+ git(root, "commit", "-m", "harden the gate");
306
+ const after = git(root, "rev-parse", "HEAD");
307
+
308
+ return { root, before, after };
309
+ }
310
+
311
+ /** `git show <sha>:<path>` without the trimming the `git` helper applies. */
312
+ function showRaw(root, sha, relPath) {
313
+ return execFileSync("git", ["show", `${sha}:${relPath}`], { cwd: root, encoding: "utf8" });
314
+ }
315
+
316
+ test("stale: a sibling script that differs at the pinned SHA is stale even when action.yml is byte-identical", () => {
317
+ const { root, before } = makeSiblingScriptRepo("sibling-stale");
318
+ track(root);
319
+ put(root, ".github/workflows/fixture.yml", workflow(before));
320
+
321
+ // The premise, asserted rather than assumed: a manifest-only comparison
322
+ // would have called this pin fresh.
323
+ assert.equal(
324
+ showRaw(root, before, `${SUBPATH}/action.yml`),
325
+ readFileSync(join(root, SUBPATH, "action.yml"), "utf8"),
326
+ "premise: action.yml is byte-identical at the pinned SHA"
327
+ );
328
+
329
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
330
+
331
+ assert.equal(result.ok, false);
332
+ assert.equal(result.stale.length, 1);
333
+ assert.equal(result.unreachable.length, 0);
334
+ assert.match(result.stale[0].reason, /gate\.mjs/, "the report names the drifting sibling");
335
+
336
+ const { code, stderr } = capture(["--cwd", root, "--first-party-owner", OWNER]);
337
+ assert.equal(code, 1);
338
+ assert.match(stderr, /gate\.mjs/);
339
+ });
340
+
341
+ test("clean: a pin carrying the current sibling script exits 0", () => {
342
+ const { root, after } = makeSiblingScriptRepo("sibling-clean");
343
+ track(root);
344
+ put(root, ".github/workflows/fixture.yml", workflow(after));
345
+
346
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
347
+ assert.equal(result.ok, true);
348
+ });
349
+
350
+ test("stale: a file added to the action directory since the pinned SHA is stale", () => {
351
+ const { root, after } = makeSiblingScriptRepo("sibling-added");
352
+ track(root);
353
+ put(root, `${SUBPATH}/helper.mjs`, "export const help = () => 1;\n");
354
+ git(root, "add", "-A");
355
+ put(root, ".github/workflows/fixture.yml", workflow(after));
356
+
357
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
358
+
359
+ assert.equal(result.ok, false);
360
+ assert.match(result.stale[0].reason, /helper\.mjs/);
361
+ });
362
+
363
+ test("stale: a file removed from the action directory since the pinned SHA is stale", () => {
364
+ const { root, after } = makeSiblingScriptRepo("sibling-removed");
365
+ track(root);
366
+ git(root, "rm", "-q", `${SUBPATH}/gate.mjs`);
367
+ put(root, ".github/workflows/fixture.yml", workflow(after));
368
+
369
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER });
370
+
371
+ assert.equal(result.ok, false);
372
+ assert.match(result.stale[0].reason, /gate\.mjs/);
373
+ });
374
+
375
+ test("diffSubpathAtSha: a tracked file missing from disk is drift, not a silent skip", () => {
376
+ const path = `${SUBPATH}/gone.mjs`;
377
+ const fake = { lsTree: () => [path], lsFiles: () => [path], show: () => "body\n" };
378
+
379
+ const drift = diffSubpathAtSha(fake, "/no-such-root", "0".repeat(40), SUBPATH);
380
+
381
+ assert.deepEqual(drift, [{ path, kind: "unreadable" }]);
382
+ });
383
+
384
+ test("diffSubpathAtSha: a blob git cannot resolve at the pinned SHA is drift, not a pass", () => {
385
+ const { root } = makeSiblingScriptRepo("sibling-unresolvable");
386
+ track(root);
387
+ const path = `${SUBPATH}/gate.mjs`;
388
+ const fake = { lsTree: () => [path], lsFiles: () => [path], show: () => null };
389
+
390
+ const drift = diffSubpathAtSha(fake, root, "0".repeat(40), SUBPATH);
391
+
392
+ assert.deepEqual(drift, [{ path, kind: "differs" }]);
393
+ });
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Story #389 — a subpath's COMPANIONS are compared alongside it
397
+ //
398
+ // The subpaths this checker knows are exactly the ones named on `uses:` lines,
399
+ // so the moment an action's behaviour moves into a sibling DIRECTORY the
400
+ // Story #379 blind spot reopens one level up. `osv-track-issue` is now a thin
401
+ // preset that executes `../track-issue/track-issue.mjs`, and nothing `uses:`
402
+ // the generic action — so without a companion map, a rewrite of that shared
403
+ // core leaves every preset call site reading fresh while the pinned SHA runs
404
+ // the old state machine.
405
+ // ---------------------------------------------------------------------------
406
+
407
+ const PRESET_SUBPATH = ".github/actions/osv-track-issue";
408
+ const CORE_SUBPATH = ".github/actions/track-issue";
409
+ const PRESET_COMPANIONS = { [PRESET_SUBPATH]: [CORE_SUBPATH] };
410
+
411
+ /** A workflow pinning `subpath@sha`, one step per call site. */
412
+ function workflowFor(subpath, sha) {
413
+ return [
414
+ "name: fixture",
415
+ "on:",
416
+ " push:",
417
+ " branches: [main]",
418
+ "jobs:",
419
+ " demo:",
420
+ " runs-on: ubuntu-latest",
421
+ " steps:",
422
+ ` - uses: ${OWNER}/${subpath}@${sha}`,
423
+ "",
424
+ ].join("\n");
425
+ }
426
+
427
+ /**
428
+ * A repo shaped like the post-#389 split: a preset directory whose manifest
429
+ * runs a script in a sibling core directory that no `uses:` line names. Both
430
+ * are committed; the caller then edits the CORE in the working tree only.
431
+ */
432
+ function makePresetRepo(label) {
433
+ const root = mkdtempSync(join(tmpdir(), `pinfresh-${label}-`));
434
+ git(root, "init", "-b", "main");
435
+ git(root, "config", "user.email", "fixture@example.invalid");
436
+ git(root, "config", "user.name", "Pin Freshness Fixture");
437
+ git(root, "config", "commit.gpgsign", "false");
438
+ git(root, "config", "core.hooksPath", join(root, ".no-hooks"));
439
+
440
+ put(root, `${CORE_SUBPATH}/action.yml`, SIBLING_MANIFEST);
441
+ put(root, `${CORE_SUBPATH}/track-issue.mjs`, "export const verdict = () => 'noop';\n");
442
+ put(root, `${PRESET_SUBPATH}/action.yml`, SIBLING_MANIFEST);
443
+ put(root, `${PRESET_SUBPATH}/osv-track-issue.mjs`, "import '../track-issue/track-issue.mjs';\n");
444
+ git(root, "add", "-A");
445
+ git(root, "commit", "-m", "split the tracker into a core and a preset");
446
+ const after = git(root, "rev-parse", "HEAD");
447
+
448
+ return { root, after };
449
+ }
450
+
451
+ test("stale: an edit confined to the shared core marks every preset call site stale", () => {
452
+ const { root, after } = makePresetRepo("companion-stale");
453
+ track(root);
454
+ // Two call sites, both pinning the preset — neither names the core.
455
+ put(root, ".github/workflows/one.yml", workflowFor(PRESET_SUBPATH, after));
456
+ put(root, ".github/workflows/two.yml", workflowFor(PRESET_SUBPATH, after));
457
+
458
+ // The edit is confined to the core, and nothing under the preset changes.
459
+ put(root, `${CORE_SUBPATH}/track-issue.mjs`, "export const verdict = () => 'create';\n");
460
+
461
+ // The premise, asserted rather than assumed: without the companion map this
462
+ // fixture reads perfectly fresh — which is the blind spot, not a pass.
463
+ const blind = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: {} });
464
+ assert.equal(blind.ok, true, "premise: a preset-only comparison sees no drift");
465
+
466
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: PRESET_COMPANIONS });
467
+
468
+ assert.equal(result.ok, false);
469
+ assert.equal(result.scanned, 2);
470
+ assert.equal(result.stale.length, 2, "every call site of the preset is stale, not just one");
471
+ assert.equal(result.unreachable.length, 0);
472
+ for (const f of result.stale) {
473
+ assert.equal(f.subpath, PRESET_SUBPATH);
474
+ assert.match(f.reason, /track-issue\.mjs/, "the report names the drifting shared file");
475
+ assert.match(f.reason, /shared code it executes/, "and explains why a sibling tree is in scope");
476
+ }
477
+ });
478
+
479
+ test("stale: a file added to the shared core since the pinned SHA is drift", () => {
480
+ const { root, after } = makePresetRepo("companion-added");
481
+ track(root);
482
+ put(root, ".github/workflows/one.yml", workflowFor(PRESET_SUBPATH, after));
483
+ put(root, `${CORE_SUBPATH}/helper.mjs`, "export const help = () => 1;\n");
484
+ git(root, "add", "-A");
485
+
486
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: PRESET_COMPANIONS });
487
+
488
+ assert.equal(result.ok, false);
489
+ assert.match(result.stale[0].reason, /helper\.mjs/);
490
+ });
491
+
492
+ test("clean: a pin carrying the current core AND preset exits 0", () => {
493
+ const { root, after } = makePresetRepo("companion-clean");
494
+ track(root);
495
+ put(root, ".github/workflows/one.yml", workflowFor(PRESET_SUBPATH, after));
496
+
497
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: PRESET_COMPANIONS });
498
+ assert.equal(result.ok, true);
499
+ });
500
+
501
+ test("a subpath with no companions is compared exactly as before", () => {
502
+ const { root, after } = makePresetRepo("companion-unmapped");
503
+ track(root);
504
+ // Pin the CORE directly — it has no companions of its own, so a preset edit
505
+ // must not leak into its comparison.
506
+ put(root, ".github/workflows/one.yml", workflowFor(CORE_SUBPATH, after));
507
+ put(root, `${PRESET_SUBPATH}/osv-track-issue.mjs`, "// rewritten preset\n");
508
+
509
+ const result = runCheck({ cwd: root, firstPartyOwner: OWNER, companions: PRESET_COMPANIONS });
510
+ assert.equal(result.ok, true, "companions are directional, not symmetric");
511
+ });
512
+
513
+ test("diffSubpathAtSha: companions are unioned into both sides of the comparison", () => {
514
+ const presetPath = `${PRESET_SUBPATH}/action.yml`;
515
+ const corePath = `${CORE_SUBPATH}/action.yml`;
516
+ const presetBody = readFileSync(join(REPO_ROOT, presetPath), "utf8");
517
+ // The pinned side knows only the preset; the working side also has the core.
518
+ const fake = {
519
+ lsTree: (_sha, root) => (root === PRESET_SUBPATH ? [presetPath] : []),
520
+ lsFiles: (root) => (root === PRESET_SUBPATH ? [presetPath] : [corePath]),
521
+ show: () => presetBody,
522
+ };
523
+
524
+ const without = diffSubpathAtSha(fake, REPO_ROOT, "0".repeat(40), PRESET_SUBPATH);
525
+ assert.deepEqual(without, [], "the preset tree alone is identical at both ends");
526
+
527
+ const withCore = diffSubpathAtSha(fake, REPO_ROOT, "0".repeat(40), PRESET_SUBPATH, [CORE_SUBPATH]);
528
+ assert.deepEqual(withCore, [{ path: corePath, kind: "added" }]);
529
+ });
530
+
531
+ test("companionsFor: unmapped subpaths stand alone, and trailing slashes resolve", () => {
532
+ assert.deepEqual(companionsFor(".github/actions/setup-toolchain"), []);
533
+ assert.deepEqual(companionsFor(PRESET_SUBPATH, PRESET_COMPANIONS), [CORE_SUBPATH]);
534
+ assert.deepEqual(companionsFor(`${PRESET_SUBPATH}/`, PRESET_COMPANIONS), [CORE_SUBPATH]);
535
+ });
536
+
537
+ test("COMPANION_SUBPATHS: every mapped subpath and companion exists in this repo", () => {
538
+ // A map entry pointing at a path that no longer exists is a silently-dead
539
+ // guard — the pin would read fresh again with nothing flagging it.
540
+ for (const [subpath, companions] of Object.entries(COMPANION_SUBPATHS)) {
541
+ assert.ok(
542
+ resolveManifest(REPO_ROOT, subpath),
543
+ `${subpath} is mapped but resolves to no manifest`
544
+ );
545
+ for (const companion of companions) {
546
+ assert.ok(
547
+ resolveManifest(REPO_ROOT, companion),
548
+ `${subpath} names companion ${companion}, which resolves to no manifest`
549
+ );
550
+ }
551
+ }
552
+ });
553
+
258
554
  // ---------------------------------------------------------------------------
259
555
  // AC-7 — third-party / local / docker references are never classified
260
556
  // ---------------------------------------------------------------------------
@@ -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
+ });