mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
The marker check returned before the only writer, so once a repo had the hook, no later change to the wrapper could ever reach it. The `printf x` fail-open fix (v1.64.0.0) has still not landed in any repo that received the hook before it, and a wrapper naming a gstack that has since moved stays pointed at a dead path for the same reason. Compare the body against what this version generates: rewrite on drift, stay a no-op when identical. The chained pre-push.local is untouched on both paths. The existing trailing-newline regression test cannot catch this — it installs into a repo with no prior managed hook, the one case that was never broken.
333 lines
13 KiB
TypeScript
Executable File
333 lines
13 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* gstack-redact — scan text for secrets/PII/legal content via the shared engine.
|
|
*
|
|
* Skill-facing CLI over lib/redact-engine.ts. Reads from stdin (default) or
|
|
* --from-file, scans, and prints findings as JSON (--json) or a human table.
|
|
*
|
|
* Exit codes (consumed by skill bash to gate dispatch/file/edit/commit):
|
|
* 0 clean (no HIGH, no MEDIUM)
|
|
* 2 MEDIUM present (no HIGH) — skill runs the per-finding AskUserQuestion
|
|
* 3 HIGH present — skill blocks
|
|
*
|
|
* WARN findings (tool-fence-degraded credentials) never change the exit code.
|
|
*
|
|
* Flags:
|
|
* --json Emit JSON {findings, counts, repoVisibility, oversize}
|
|
* --repo-visibility V public | private | unknown (default unknown=public-strict wording)
|
|
* --from-file PATH Read input from PATH instead of stdin
|
|
* --allowlist PATH Newline-delimited exact spans to suppress
|
|
* --self-email EMAIL Suppress this email (the invoking user's own)
|
|
* --repo-public-emails PATH Newline-delimited repo-public emails to suppress
|
|
* --auto-redact IDS Comma-separated finding ids to auto-redact;
|
|
* prints the redacted body to stdout + diff to stderr.
|
|
* --max-bytes N Override the fail-closed size cap (default 1 MiB).
|
|
*
|
|
* Security note: this is a GUARDRAIL, not airtight enforcement. A determined
|
|
* user can always bypass it (direct gh/git). It catches accidents.
|
|
*/
|
|
import * as fs from "fs";
|
|
import * as path from "path";
|
|
import { spawnSync } from "child_process";
|
|
import {
|
|
scan,
|
|
applyRedactions,
|
|
exitCodeFor,
|
|
type RepoVisibility,
|
|
type ScanOptions,
|
|
type Finding,
|
|
} from "../lib/redact-engine";
|
|
import { mkdirpSync } from "../lib/fs-utils";
|
|
|
|
const MAX_STDIN_BYTES = 16 * 1024 * 1024; // hard ceiling before the engine cap
|
|
|
|
// ── pre-push hook install/uninstall (chains any existing hook) ────────────────
|
|
|
|
const MANAGED_MARKER = "# gstack-redact pre-push (managed)";
|
|
|
|
function hooksPath(): string {
|
|
const r = spawnSync("git", ["rev-parse", "--git-path", "hooks"], { encoding: "utf8" });
|
|
if (r.status !== 0) {
|
|
process.stderr.write("gstack-redact: not in a git repo\n");
|
|
process.exit(1);
|
|
}
|
|
return r.stdout.trim();
|
|
}
|
|
|
|
function installPrepushHook(): void {
|
|
const dir = hooksPath();
|
|
// mkdirpSync, not bare mkdirSync: bun on Windows throws EEXIST from a
|
|
// recursive mkdir when .git/hooks already exists (#2635).
|
|
mkdirpSync(dir);
|
|
const hookPath = path.join(dir, "pre-push");
|
|
const prepushBin = path.join(import.meta.dir, "gstack-redact-prepush");
|
|
|
|
// stdin is single-consume: capture it once, feed both the chained hook and ours.
|
|
// The `printf x` sentinel preserves the trailing newline that `$(cat)` strips.
|
|
// Without it, a chained shell pre-push.local built on `while read` silently
|
|
// drops the final (often only) ref line and exits 0 — the guard reports
|
|
// success having scanned nothing, i.e. it fails OPEN.
|
|
const wrapper = `#!/usr/bin/env bash
|
|
${MANAGED_MARKER}
|
|
set -euo pipefail
|
|
_input="$(cat; printf x)"
|
|
_input="\${_input%x}"
|
|
_local="$(git rev-parse --git-path hooks/pre-push.local)"
|
|
if [ -x "$_local" ]; then
|
|
printf '%s' "$_input" | "$_local" "$@" || exit $?
|
|
fi
|
|
printf '%s' "$_input" | bun "${prepushBin}" "$@"
|
|
`;
|
|
|
|
// If a non-managed hook exists, preserve it as pre-push.local and chain it.
|
|
if (fs.existsSync(hookPath)) {
|
|
const existing = fs.readFileSync(hookPath, "utf8");
|
|
if (existing.includes(MANAGED_MARKER)) {
|
|
// A hook we already own. Returning here unconditionally froze every
|
|
// existing install on whatever wrapper it first received: the `printf x`
|
|
// fail-open fix landed in v1.64.0.0 and still had not reached a single
|
|
// repo that got the hook before it, because the only writer is gated on
|
|
// this branch. A wrapper naming a gstack that has since been moved or
|
|
// removed stays pointed at that dead path for the same reason.
|
|
//
|
|
// Rewrite when the body has drifted from what this version generates;
|
|
// stay a no-op when it has not, so the command is still idempotent. The
|
|
// chained pre-push.local is never touched on either path — it is the
|
|
// user's, not ours.
|
|
if (existing === wrapper) {
|
|
process.stdout.write("gstack-redact: pre-push hook already installed.\n");
|
|
return;
|
|
}
|
|
fs.writeFileSync(hookPath, wrapper, { mode: 0o755 });
|
|
fs.chmodSync(hookPath, 0o755);
|
|
process.stdout.write(
|
|
`gstack-redact: refreshed stale managed pre-push hook at ${hookPath}\n`,
|
|
);
|
|
return;
|
|
}
|
|
const localPath = path.join(dir, "pre-push.local");
|
|
fs.renameSync(hookPath, localPath);
|
|
fs.chmodSync(localPath, 0o755);
|
|
process.stdout.write("gstack-redact: preserved existing hook as pre-push.local (chained).\n");
|
|
}
|
|
|
|
fs.writeFileSync(hookPath, wrapper, { mode: 0o755 });
|
|
fs.chmodSync(hookPath, 0o755);
|
|
process.stdout.write(`gstack-redact: installed pre-push hook at ${hookPath}\n`);
|
|
}
|
|
|
|
function uninstallPrepushHook(): void {
|
|
const dir = hooksPath();
|
|
const hookPath = path.join(dir, "pre-push");
|
|
const localPath = path.join(dir, "pre-push.local");
|
|
if (!fs.existsSync(hookPath) || !fs.readFileSync(hookPath, "utf8").includes(MANAGED_MARKER)) {
|
|
process.stdout.write("gstack-redact: no managed pre-push hook to remove.\n");
|
|
return;
|
|
}
|
|
if (fs.existsSync(localPath)) {
|
|
fs.renameSync(localPath, hookPath); // restore the chained original
|
|
process.stdout.write("gstack-redact: removed managed hook, restored pre-push.local.\n");
|
|
} else {
|
|
fs.unlinkSync(hookPath);
|
|
process.stdout.write("gstack-redact: removed managed pre-push hook.\n");
|
|
}
|
|
}
|
|
|
|
function arg(name: string): string | undefined {
|
|
const i = process.argv.indexOf(name);
|
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
}
|
|
function flag(name: string): boolean {
|
|
return process.argv.includes(name);
|
|
}
|
|
|
|
function readInput(): string {
|
|
const file = arg("--from-file");
|
|
// An explicitly-passed EMPTY path must error, not silently fall through to
|
|
// stdin: skill blocks pass "$FILE" from a $(mktemp) that may have failed,
|
|
// and the stdin fallback then scans nothing while looking green (#2679).
|
|
if (file === "") {
|
|
process.stderr.write(
|
|
"gstack-redact: --from-file requires a non-empty path (did mktemp fail?)\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (file) {
|
|
const st = fs.statSync(file);
|
|
if (st.size > MAX_STDIN_BYTES) {
|
|
// Don't even read it — fail closed at the CLI boundary.
|
|
process.stderr.write(`gstack-redact: input file too large (${st.size} bytes)\n`);
|
|
process.exit(3);
|
|
}
|
|
return fs.readFileSync(file, "utf8");
|
|
}
|
|
// stdin
|
|
const chunks: Buffer[] = [];
|
|
let total = 0;
|
|
const fd = 0;
|
|
const buf = Buffer.alloc(65536);
|
|
while (true) {
|
|
let n = 0;
|
|
try {
|
|
n = fs.readSync(fd, buf, 0, buf.length, null);
|
|
} catch (e: any) {
|
|
if (e.code === "EAGAIN") continue;
|
|
if (e.code === "EOF") break;
|
|
throw e;
|
|
}
|
|
if (n === 0) break;
|
|
total += n;
|
|
if (total > MAX_STDIN_BYTES) {
|
|
process.stderr.write("gstack-redact: stdin too large\n");
|
|
process.exit(3);
|
|
}
|
|
chunks.push(Buffer.from(buf.subarray(0, n)));
|
|
}
|
|
return Buffer.concat(chunks).toString("utf8");
|
|
}
|
|
|
|
function readLines(path: string | undefined): string[] | undefined {
|
|
if (!path || !fs.existsSync(path)) return undefined;
|
|
return fs
|
|
.readFileSync(path, "utf8")
|
|
.split("\n")
|
|
.map((l) => l.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function buildOpts(): ScanOptions {
|
|
const vis = (arg("--repo-visibility") as RepoVisibility) || "unknown";
|
|
const maxBytes = arg("--max-bytes");
|
|
// #1824: validate the RAW string, not the parse result. parseInt("123abc")
|
|
// is 123 and parseInt("foo") is NaN — both silently corrupt the fail-closed
|
|
// oversize guard. Require a clean positive integer or reject before scanning.
|
|
let maxBytesOpt: number | undefined;
|
|
if (maxBytes !== undefined) {
|
|
if (!/^\d+$/.test(maxBytes) || Number(maxBytes) <= 0) {
|
|
process.stderr.write(
|
|
`gstack-redact: --max-bytes must be a positive integer (got "${maxBytes}")\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
maxBytesOpt = Number(maxBytes);
|
|
}
|
|
return {
|
|
repoVisibility: ["public", "private", "unknown"].includes(vis) ? vis : "unknown",
|
|
allowlist: readLines(arg("--allowlist")),
|
|
selfEmail: arg("--self-email"),
|
|
repoPublicEmails: readLines(arg("--repo-public-emails")),
|
|
...(maxBytesOpt !== undefined ? { maxBytes: maxBytesOpt } : {}),
|
|
};
|
|
}
|
|
|
|
function humanTable(findings: Finding[]): string {
|
|
if (!findings.length) return " (no findings)";
|
|
const rows = findings.map(
|
|
(f) =>
|
|
` ${f.severity.padEnd(6)} ${f.id.padEnd(24)} ${String(f.line).padStart(4)}:${String(
|
|
f.col,
|
|
).padEnd(3)} ${f.preview}`,
|
|
);
|
|
return rows.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Usage. Exits 0 when asked for (--help), 1 when the invocation was wrong.
|
|
*
|
|
* Deliberately NOT 2 or 3: those mean MEDIUM and HIGH findings, and callers
|
|
* gate dispatch on them (see the exit-code table at the top). A usage error
|
|
* that exited 2 would be read as "medium findings — prompt the user".
|
|
*/
|
|
function printUsage(code: number): never {
|
|
const out = code === 0 ? process.stdout : process.stderr;
|
|
out.write(
|
|
"gstack-redact — scan text for secrets/PII/legal content.\n" +
|
|
"\n" +
|
|
"Reads the text to scan from STDIN, or from --from-file PATH. It is a\n" +
|
|
"filter: with nothing piped in it has nothing to scan.\n" +
|
|
"\n" +
|
|
" git diff | gstack-redact --repo-visibility private\n" +
|
|
" gstack-redact --from-file notes.md --json\n" +
|
|
"\n" +
|
|
"Subcommands:\n" +
|
|
" install-prepush-hook install the managed git pre-push credential guard\n" +
|
|
" uninstall-prepush-hook remove it\n" +
|
|
"\n" +
|
|
"Flags: --json --repo-visibility V --from-file PATH --allowlist PATH\n" +
|
|
" --self-email EMAIL --repo-public-emails PATH --auto-redact IDS\n" +
|
|
" --max-bytes N\n" +
|
|
"\n" +
|
|
"Exit: 0 clean · 1 usage error · 2 MEDIUM present · 3 HIGH present\n",
|
|
);
|
|
process.exit(code);
|
|
}
|
|
|
|
function main() {
|
|
// Subcommands (positional, not flags).
|
|
const sub = process.argv[2];
|
|
if (sub === "install-prepush-hook") return installPrepushHook();
|
|
if (sub === "uninstall-prepush-hook") return uninstallPrepushHook();
|
|
if (sub === "--help" || sub === "-h" || sub === "help") return printUsage(0);
|
|
|
|
// An unrecognized POSITIONAL is a typo, not input. This used to fall through
|
|
// to the stdin scan, which on empty stdin prints "(no findings)" and exits 0
|
|
// — so `install-prepush-hooks` (plural) installed nothing and still looked
|
|
// like success, leaving the credential guard absent while the operator
|
|
// believed it was armed. A guard that no-ops must never exit 0.
|
|
//
|
|
// "scan" is exempt: the human output header reads "gstack-redact scan —
|
|
// repo …", so people reasonably type it. It stays an alias for the default.
|
|
// Flags start with "-" and are parsed further down, so only bare words land
|
|
// here.
|
|
if (sub !== undefined && sub !== "scan" && !sub.startsWith("-")) {
|
|
process.stderr.write(`gstack-redact: unknown subcommand "${sub}"\n\n`);
|
|
return printUsage(1);
|
|
}
|
|
|
|
const opts = buildOpts();
|
|
|
|
// Nothing piped in and no --from-file: readInput() below blocks on
|
|
// readSync(fd 0) until EOF, which on an interactive terminal never comes.
|
|
// That prints nothing at all and is indistinguishable from a crash or a
|
|
// slow scan. Show usage instead of hanging silently.
|
|
if (!arg("--from-file") && process.stdin.isTTY) return printUsage(1);
|
|
|
|
const input = readInput();
|
|
|
|
// Auto-redact mode: print redacted body to stdout, diff to stderr, exit 0.
|
|
const autoIds = arg("--auto-redact");
|
|
if (autoIds) {
|
|
const { body, diff, skipped } = applyRedactions(input, autoIds.split(","), opts);
|
|
process.stdout.write(body);
|
|
if (diff) process.stderr.write(diff + "\n");
|
|
if (skipped.length) {
|
|
process.stderr.write(
|
|
`\ngstack-redact: ${skipped.length} finding(s) could not be auto-redacted (structural) — edit manually:\n` +
|
|
skipped.map((f) => ` ${f.id} @ ${f.line}:${f.col}`).join("\n") +
|
|
"\n",
|
|
);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
const result = scan(input, opts);
|
|
const code = exitCodeFor(result);
|
|
|
|
if (flag("--json")) {
|
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
} else {
|
|
const vis = result.repoVisibility.toUpperCase();
|
|
process.stdout.write(`gstack-redact scan — repo ${vis}\n`);
|
|
if (result.oversize) {
|
|
process.stdout.write(" BLOCKED — input too large to scan safely (fail-closed)\n");
|
|
} else {
|
|
process.stdout.write(humanTable(result.findings) + "\n");
|
|
const { HIGH, MEDIUM, LOW, WARN } = result.counts;
|
|
process.stdout.write(` HIGH=${HIGH} MEDIUM=${MEDIUM} LOW=${LOW} WARN=${WARN}\n`);
|
|
}
|
|
}
|
|
process.exit(code);
|
|
}
|
|
|
|
main();
|