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.
- package/README.md +69 -12
- package/config/stryker.base.json +7 -2
- package/package.json +2 -2
- 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 +200 -22
- package/scripts/check-first-party-pin-freshness.test.mjs +296 -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/osv-track-issue.test.mjs +155 -0
- package/scripts/stryker-base-config.test.mjs +256 -0
- package/scripts/track-issue.test.mjs +375 -0
|
@@ -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
|
+
});
|