mirror of https://github.com/garrytan/gstack.git
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:
parent
13b6c5c87b
commit
2fd506a4e0
|
|
@ -16,6 +16,12 @@ process.stdin.once("end", () => {
|
||||||
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
|
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
|
||||||
.map((line) => line.slice(1))
|
.map((line) => line.slice(1))
|
||||||
.join("\n");
|
.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);
|
child.stdin.end(additions);
|
||||||
});
|
});
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
|
|
@ -23,7 +29,16 @@ child.stdout.setEncoding("utf8");
|
||||||
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||||
child.once("error", (error) => { throw error; });
|
child.once("error", (error) => { throw error; });
|
||||||
child.once("close", (code) => {
|
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 high = Number(report.counts?.HIGH ?? 0);
|
||||||
const medium = Number(report.counts?.MEDIUM ?? 0);
|
const medium = Number(report.counts?.MEDIUM ?? 0);
|
||||||
console.log(`credential scan: ${high} high, ${medium} advisory`);
|
console.log(`credential scan: ${high} high, ${medium} advisory`);
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ jobs:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
- uses: actions/dependency-review-action@v4
|
- uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||||
with:
|
with:
|
||||||
fail-on-severity: high
|
fail-on-severity: high
|
||||||
fail-on-scopes: runtime, development
|
fail-on-scopes: runtime, development
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,16 @@
|
||||||
|
|
||||||
import { describe, test, expect } from "bun:test";
|
import { describe, test, expect } from "bun:test";
|
||||||
import { spawnSync } from "child_process";
|
import { spawnSync } from "child_process";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..");
|
const ROOT = join(import.meta.dir, "..");
|
||||||
const SCRIPT = join(ROOT, ".github", "scripts", "gate-secret-scan.mjs");
|
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], {
|
const res = spawnSync("node", [SCRIPT], {
|
||||||
cwd: ROOT,
|
cwd,
|
||||||
input: diff,
|
input: diff,
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
|
|
@ -56,3 +58,61 @@ describe("gate-secret-scan.mjs exit contract", () => {
|
||||||
expect(r.out).toMatch(/\d+ advisory/);
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue