fix(ci): SHA-pin dependency-review; the secret gate fails closed without a report

dependency-review.yml rode mutable refs (@v4 resolves to a BRANCH on
that repo) inside the one workflow whose job is supply-chain hygiene —
now commit-pinned like its siblings, with dependabot keeping the pins
fresh. gate-secret-scan.mjs crashed with an unhandled EPIPE on
oversize diffs (the designed report.oversize branch was unreachable:
the scanner emits no JSON on refusal) — the pipe write now tolerates
early exit and a missing report is an explicit fail-closed exit 1.
Oversize + broken-scanner legs pinned.
This commit is contained in:
Garry Tan 2026-08-14 17:15:48 -07:00
parent 13b6c5c87b
commit 2fd506a4e0
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
3 changed files with 80 additions and 5 deletions

View File

@ -16,6 +16,12 @@ process.stdin.once("end", () => {
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
.map((line) => line.slice(1))
.join("\n");
// The scanner may exit before consuming an oversize payload (it refuses
// stdin over --max-bytes and reports oversize:true). EPIPE here is that
// refusal in flight, not a failure — the report + exit code carry the verdict.
child.stdin.on("error", (error) => {
if (error.code !== "EPIPE") throw error;
});
child.stdin.end(additions);
});
let stdout = "";
@ -23,7 +29,16 @@ child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.once("error", (error) => { throw error; });
child.once("close", (code) => {
const report = JSON.parse(stdout);
let report;
try {
report = JSON.parse(stdout);
} catch {
// No parseable report: the oversize refusal prints only to stderr and
// exits 3, and a crashed scanner emits nothing. Both fail closed.
console.log(`credential scan: 1 high, 0 advisory (scanner emitted no report, exit ${code} — fail-closed)`);
process.exitCode = 1;
return;
}
const high = Number(report.counts?.HIGH ?? 0);
const medium = Number(report.counts?.MEDIUM ?? 0);
console.log(`credential scan: ${high} high, ${medium} advisory`);

View File

@ -24,8 +24,8 @@ jobs:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/dependency-review-action@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
with:
fail-on-severity: high
fail-on-scopes: runtime, development

View File

@ -11,14 +11,16 @@
import { describe, test, expect } from "bun:test";
import { spawnSync } from "child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
const ROOT = join(import.meta.dir, "..");
const SCRIPT = join(ROOT, ".github", "scripts", "gate-secret-scan.mjs");
function scan(diff: string): { code: number; out: string } {
function scan(diff: string, cwd: string = ROOT): { code: number; out: string } {
const res = spawnSync("node", [SCRIPT], {
cwd: ROOT,
cwd,
input: diff,
encoding: "utf-8",
timeout: 60_000,
@ -56,3 +58,61 @@ describe("gate-secret-scan.mjs exit contract", () => {
expect(r.out).toMatch(/\d+ advisory/);
});
});
describe("gate-secret-scan.mjs fail-closed legs", () => {
test("oversize diff (report.oversize) fails the gate", () => {
// The script pins --max-bytes 16000000; bin/gstack-redact refuses to scan
// anything larger and reports oversize:true (fail-closed). The gate must
// exit 1 rather than pass unscanned bytes. ~17MB of added lines guarantees
// the joined additions exceed the cap.
const line = `+${"a".repeat(8190)}\n`;
const r = scan(line.repeat(2100));
expect(r.code).toBe(1);
// Proves the failure came from the parsed report (the engine surfaces
// oversize as a fail-closed HIGH), not from a crashed subprocess.
expect(r.out).toContain("1 high");
}, 60_000);
test("unexpected gstack-redact exit code fails the gate even when the report is clean", () => {
// Stub bin/gstack-redact that emits a CLEAN JSON report but exits 1 —
// not one of the contract codes (0 clean / 2 MEDIUM / 3 HIGH). The gate
// must treat the unexpected exit as failure: a broken scanner reporting
// "all clear" is exactly the fail-open shape this leg guards against.
const dir = mkdtempSync(join(tmpdir(), "gate-secret-scan-stub-"));
try {
mkdirSync(join(dir, "bin"));
writeFileSync(
join(dir, "bin", "gstack-redact"),
[
"#!/usr/bin/env bun",
'let input = "";',
'process.stdin.setEncoding("utf8");',
'process.stdin.on("data", (c) => { input += c; });',
'process.stdin.on("end", () => {',
' console.log(JSON.stringify({ findings: [], counts: { HIGH: 0, MEDIUM: 0, LOW: 0, WARN: 0 }, repoVisibility: "public", oversize: false }));',
" process.exit(1);",
"});",
"",
].join("\n"),
);
const r = scan("+const x = 1;\n", dir);
expect(r.out).toContain("0 high"); // the clean report WAS parsed...
expect(r.code).toBe(1); // ...and the gate still failed on the exit code
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("missing gstack-redact (spawn crash, empty stdout) exits nonzero — never fail-open", () => {
// cwd with no bin/gstack-redact at all: bun exits module-not-found with
// empty stdout. Whatever the exact failure shape, the gate must not
// report success.
const dir = mkdtempSync(join(tmpdir(), "gate-secret-scan-absent-"));
try {
const r = scan("+const x = 1;\n", dir);
expect(r.code).not.toBe(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});