mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
main() recognised exactly two subcommands and let everything else fall through
to the stdin scan. On empty stdin that prints "(no findings)" and exits 0, so:
$ gstack-redact install-prepush-hooks # plural typo
gstack-redact scan — repo UNKNOWN
(no findings)
$ echo $?
0
No hook was installed, and the operator has every reason to believe the
credential guard is armed. A guard that silently no-ops must never exit 0.
Two smaller faults in the same dispatch, both of which lead people here:
- There was no --help handler, so `gstack-redact --help` fell through to the
scanner. Piping a credential to it scanned the secret and exited 3.
- With no piped input and no --from-file, readInput() blocks on readSync(fd 0)
until an EOF that an interactive terminal never sends. That prints nothing
at all, so it reads as a hang rather than as "this is a filter, feed it".
Now: --help/-h/help prints usage and exits 0; an unrecognised positional
prints the offender and exits 1; a TTY with nothing piped in prints usage
instead of blocking. "scan" stays accepted, because the human output header
reads "gstack-redact scan — repo …" and that is what people type.
Usage errors exit 1, deliberately not 2 or 3. Those mean MEDIUM and HIGH
findings and callers gate dispatch on them, so a usage error exiting 2 would
be read as "medium findings — prompt the user". A test pins that.
Tests: 4 written failing first, then fixed. Full suite 7,722 pass / 0 fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
/**
|
|
* Contract tests for bin/gstack-redact — exit codes, JSON shape, flags,
|
|
* auto-redact mode, oversize fail-closed. Spawns the shim via `bun`.
|
|
*/
|
|
import { describe, test, expect } from "bun:test";
|
|
import * as path from "path";
|
|
import * as fs from "fs";
|
|
import * as os from "os";
|
|
|
|
const BIN = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
|
|
|
|
function run(
|
|
args: string[],
|
|
stdin: string,
|
|
): { code: number; stdout: string; stderr: string } {
|
|
const proc = Bun.spawnSync(["bun", BIN, ...args], {
|
|
stdin: Buffer.from(stdin),
|
|
});
|
|
return {
|
|
code: proc.exitCode,
|
|
stdout: proc.stdout.toString(),
|
|
stderr: proc.stderr.toString(),
|
|
};
|
|
}
|
|
|
|
describe("gstack-redact exit codes", () => {
|
|
test("clean → 0", () => {
|
|
expect(run([], "just some prose").code).toBe(0);
|
|
});
|
|
test("HIGH → 3", () => {
|
|
expect(run([], "key AKIA1234567890ABCDEF").code).toBe(3);
|
|
});
|
|
test("MEDIUM only → 2", () => {
|
|
expect(run(["--repo-visibility", "public"], "mail bob@corp.io").code).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("gstack-redact --json", () => {
|
|
test("emits valid JSON with findings + counts", () => {
|
|
const { stdout, code } = run(["--json"], "key AKIA1234567890ABCDEF");
|
|
expect(code).toBe(3);
|
|
const parsed = JSON.parse(stdout);
|
|
expect(parsed.findings[0].id).toBe("aws.access_key");
|
|
expect(parsed.counts.HIGH).toBe(1);
|
|
expect(parsed.repoVisibility).toBe("unknown");
|
|
});
|
|
});
|
|
|
|
describe("gstack-redact --auto-redact", () => {
|
|
test("prints redacted body to stdout, exits 0", () => {
|
|
const { stdout, code } = run(["--auto-redact", "pii.email"], "ping bob@corp.io please");
|
|
expect(code).toBe(0);
|
|
expect(stdout).toContain("<REDACTED-EMAIL>");
|
|
expect(stdout).not.toContain("bob@corp.io");
|
|
});
|
|
});
|
|
|
|
describe("gstack-redact --allowlist", () => {
|
|
test("allowlisted span is suppressed", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "redact-allow-"));
|
|
const allow = path.join(dir, "allow.txt");
|
|
fs.writeFileSync(allow, "AKIA1234567890ABCDEF\n");
|
|
const { code } = run(["--allowlist", allow], "key AKIA1234567890ABCDEF");
|
|
expect(code).toBe(0);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe("gstack-redact --self-email", () => {
|
|
test("own email is not flagged", () => {
|
|
const { code } = run(
|
|
["--repo-visibility", "public", "--self-email", "me@garry.dev"],
|
|
"from me@garry.dev",
|
|
);
|
|
expect(code).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("gstack-redact --from-file", () => {
|
|
test("reads input from a file", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "redact-file-"));
|
|
const f = path.join(dir, "spec.md");
|
|
fs.writeFileSync(f, "leaked ghp_" + "a".repeat(36));
|
|
const proc = Bun.spawnSync(["bun", BIN, "--from-file", f, "--json"]);
|
|
const parsed = JSON.parse(proc.stdout.toString());
|
|
expect(parsed.findings[0].id).toBe("github.pat");
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe("gstack-redact oversize fails closed", () => {
|
|
test("input over --max-bytes blocks (exit 3)", () => {
|
|
const { code, stdout } = run(["--max-bytes", "100"], "a".repeat(500));
|
|
expect(code).toBe(3);
|
|
expect(stdout).toContain("too large");
|
|
});
|
|
});
|
|
|
|
describe("gstack-redact argv dispatch", () => {
|
|
// The bug: main() recognised exactly two subcommands and let everything else
|
|
// fall through to the stdin scan, which reports "no findings" and exits 0.
|
|
// So `install-prepush-hooks` (plural typo) installed no hook and still looked
|
|
// like success — the credential guard silently absent while the operator
|
|
// believes it is armed. A guard that no-ops must never exit 0.
|
|
test("a typo'd install subcommand fails loudly instead of exiting 0", () => {
|
|
const { code, stderr } = run(["install-prepush-hooks"], "");
|
|
expect(code).not.toBe(0);
|
|
expect(stderr).toContain("unknown subcommand");
|
|
});
|
|
|
|
test("an unknown positional never reports a clean scan", () => {
|
|
const { code, stdout } = run(["totally-bogus"], "");
|
|
expect(code).not.toBe(0);
|
|
expect(stdout).not.toContain("HIGH=0");
|
|
});
|
|
|
|
// Usage errors must not collide with the findings codes (2 = MEDIUM,
|
|
// 3 = HIGH); a caller gating on those would read a typo as "findings".
|
|
test("usage errors exit 1, not a findings code", () => {
|
|
expect(run(["totally-bogus"], "").code).toBe(1);
|
|
});
|
|
|
|
test("--help prints usage and exits 0 without scanning", () => {
|
|
const { code, stdout } = run(["--help"], "key AKIA1234567890ABCDEF");
|
|
expect(code).toBe(0);
|
|
expect(stdout).toContain("STDIN");
|
|
expect(stdout).not.toContain("HIGH=1");
|
|
});
|
|
|
|
// "scan" is what the human output header ("gstack-redact scan — repo …")
|
|
// invites people to type, so it stays an accepted alias for the default
|
|
// filter mode. Rejecting it would break that muscle memory for no gain.
|
|
test("the 'scan' alias still scans normally", () => {
|
|
expect(run(["scan"], "key AKIA1234567890ABCDEF").code).toBe(3);
|
|
expect(run(["scan"], "just prose").code).toBe(0);
|
|
});
|
|
|
|
test("flags are still parsed, not mistaken for subcommands", () => {
|
|
expect(run(["--json"], "just prose").code).toBe(0);
|
|
});
|
|
});
|