mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
The pre-push redact guard BLOCKED this branch's first push: the wave's new scan-range tests carried live-FORMAT fake credentials as literals (3 AWS key shapes + a password-bearing DB URL), and the guard scans pushed diff bytes. Same dogfood moment as the v1.64 wave, same rule: assemble the fixture at runtime so the diff never carries a credential shape, never bypass the guard. Runtime strings stay live-format for the hook under test. The guard works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
162 lines
6.9 KiB
TypeScript
162 lines
6.9 KiB
TypeScript
/**
|
|
* gstack-redact-prepush — WHICH commits get scanned.
|
|
*
|
|
* `remoteSha..localSha` is "everything new on this branch", not "everything new
|
|
* to the remote". Merge origin/main into a feature branch and every commit main
|
|
* gained since the last push becomes an added line: already published, already
|
|
* scanned, not this push's doing. That produces false HIGH findings on other
|
|
* people's merged fixtures, and blows the engine's size cap on busy repos.
|
|
*
|
|
* These tests build real repositories on disk, because the behaviour under test
|
|
* IS the git plumbing — a mocked `git` would test the mock. Each asserts on the
|
|
* added-line text the hook would scan.
|
|
*
|
|
* The direction that matters most is the LAST describe block: narrowing the
|
|
* range must not narrow COVERAGE. A secret in a new commit, or introduced while
|
|
* resolving a merge, still has to be seen.
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
import { spawnSync } from "child_process";
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import { dirname, join } from "path";
|
|
|
|
let dir: string;
|
|
const run = (args: string[], cwd = dir): string => {
|
|
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
if (r.status !== 0) throw new Error(`git ${args.join(" ")}\n${r.stderr}`);
|
|
return r.stdout ?? "";
|
|
};
|
|
const commit = (file: string, body: string, msg: string, cwd = dir) => {
|
|
mkdirSync(dirname(join(cwd, file)), { recursive: true });
|
|
writeFileSync(join(cwd, file), body);
|
|
run(["add", file], cwd);
|
|
run(["commit", "-q", "-m", msg], cwd);
|
|
};
|
|
|
|
/**
|
|
* The range the fixed hook uses: commits reachable from HEAD and from no
|
|
* remote-tracking ref, each diffed alone with --cc.
|
|
*/
|
|
function addedLinesFromNewCommits(cwd: string): string {
|
|
const listed = run(["rev-list", "HEAD", "--not", "--remotes"], cwd).trim();
|
|
if (!listed) return "";
|
|
const out: string[] = [];
|
|
for (const sha of listed.split("\n").filter(Boolean)) {
|
|
out.push(run([
|
|
"show", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv",
|
|
"--cc", "--format=", sha,
|
|
], cwd));
|
|
}
|
|
return out.join("\n");
|
|
}
|
|
|
|
/** The old behaviour, for contrast. */
|
|
function addedLinesFromTwoDot(cwd: string, remoteRef: string): string {
|
|
return run([
|
|
"diff", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv",
|
|
`${remoteRef}..HEAD`,
|
|
], cwd);
|
|
}
|
|
|
|
const addedOnly = (diff: string): string =>
|
|
diff.split("\n")
|
|
.filter((l) => l.startsWith("+") && !l.startsWith("+++"))
|
|
.join("\n");
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), "gstack-prepush-"));
|
|
run(["init", "-q", "-b", "main"]);
|
|
run(["config", "user.email", "t@example.com"]);
|
|
run(["config", "user.name", "T"]);
|
|
commit("README.md", "seed\n", "seed");
|
|
});
|
|
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
|
|
// Live-FORMAT fakes assembled at runtime so the pushed diff of THIS file never
|
|
// contains a credential-shaped literal — the pre-push guard scans diff bytes,
|
|
// and the v1.64 wave's dogfood rule stands: assemble the fixture, never bypass
|
|
// the guard. The runtime strings stay live-format for the hook under test.
|
|
const FAKE_DB_URL = ["postgresql://user:pass", "@db.example.com/x"].join("");
|
|
const FAKE_AWS_SECRETX = ["AKIA", "IOSFODNN7SECRETX"].join("");
|
|
const FAKE_AWS_RESOLV = ["AKIA", "IOSFODNN7RESOLV"].join("");
|
|
const FAKE_AWS_NOREMOT = ["AKIA", "IOSFODNN7NOREMOT"].join("");
|
|
|
|
/** Give the repo an "origin" whose main carries a fixture we did not write. */
|
|
function setUpRemoteWithForeignFixture(): void {
|
|
const remote = mkdtempSync(join(tmpdir(), "gstack-prepush-remote-"));
|
|
run(["init", "-q", "--bare", "-b", "main"], remote);
|
|
run(["remote", "add", "origin", remote]);
|
|
run(["push", "-q", "origin", "main"]);
|
|
// Someone else lands a placeholder connection string on main.
|
|
commit("fixtures/db.ts", `export const URL = "${FAKE_DB_URL}";\n`, "someone else's fixture");
|
|
run(["push", "-q", "origin", "main"]);
|
|
run(["fetch", "-q", "origin"]);
|
|
}
|
|
|
|
describe("a catch-up merge does not re-scan already-published content", () => {
|
|
test("the foreign fixture is absent from the scanned text", () => {
|
|
setUpRemoteWithForeignFixture();
|
|
// Branch from BEFORE that fixture, then merge main in to catch up.
|
|
run(["checkout", "-q", "-b", "feature", "HEAD~1"]);
|
|
commit("mine.ts", "export const mine = 1;\n", "my work");
|
|
run(["merge", "-q", "--no-edit", "main"]);
|
|
|
|
const scanned = addedOnly(addedLinesFromNewCommits(dir));
|
|
expect(scanned).toContain("export const mine = 1;");
|
|
expect(scanned).not.toContain(FAKE_DB_URL);
|
|
});
|
|
|
|
test("the old two-dot range DID re-scan it — this is the bug", () => {
|
|
setUpRemoteWithForeignFixture();
|
|
run(["checkout", "-q", "-b", "feature", "HEAD~1"]);
|
|
commit("mine.ts", "export const mine = 1;\n", "my work");
|
|
run(["merge", "-q", "--no-edit", "main"]);
|
|
|
|
// origin/feature does not exist yet, so the old code diffed against the
|
|
// remote's main — dragging in every catch-up commit.
|
|
const scanned = addedOnly(addedLinesFromTwoDot(dir, "HEAD~2"));
|
|
expect(scanned).toContain(FAKE_DB_URL);
|
|
});
|
|
});
|
|
|
|
describe("narrowing the range does not narrow coverage", () => {
|
|
test("a secret in a new commit is still scanned", () => {
|
|
setUpRemoteWithForeignFixture();
|
|
run(["checkout", "-q", "-b", "feature", "main"]);
|
|
commit("leak.ts", `const k = "${FAKE_AWS_SECRETX}";\n`, "oops");
|
|
|
|
expect(addedOnly(addedLinesFromNewCommits(dir))).toContain(FAKE_AWS_SECRETX);
|
|
});
|
|
|
|
test("a secret introduced while RESOLVING a merge is still scanned", () => {
|
|
// A combined diff shows only content present in no parent — exactly the
|
|
// conflict resolution — so this must not slip through.
|
|
//
|
|
// Note for anyone hardening this later: removing `--cc` from the
|
|
// implementation does NOT fail this test, because `git show` already
|
|
// defaults to a combined diff for merge commits. The explicit flag is
|
|
// self-documenting, not load-bearing, and no test can pin it. What this
|
|
// test does pin is the coverage itself.
|
|
setUpRemoteWithForeignFixture();
|
|
run(["checkout", "-q", "-b", "feature", "HEAD~1"]);
|
|
commit("conflict.txt", "mine\n", "mine");
|
|
run(["checkout", "-q", "main"]);
|
|
commit("conflict.txt", "theirs\n", "theirs");
|
|
run(["push", "-q", "origin", "main"]);
|
|
run(["fetch", "-q", "origin"]);
|
|
run(["checkout", "-q", "feature"]);
|
|
spawnSync("git", ["merge", "--no-edit", "main"], { cwd: dir, encoding: "utf8" }); // conflicts
|
|
writeFileSync(join(dir, "conflict.txt"), `resolved ${FAKE_AWS_RESOLV}\n`);
|
|
run(["add", "conflict.txt"]);
|
|
run(["commit", "-q", "--no-edit"]);
|
|
|
|
expect(addedOnly(addedLinesFromNewCommits(dir))).toContain(FAKE_AWS_RESOLV);
|
|
});
|
|
|
|
test("everything is scanned when no remote exists at all", () => {
|
|
commit("leak.ts", `const k = "${FAKE_AWS_NOREMOT}";\n`, "no remote");
|
|
expect(addedOnly(addedLinesFromNewCommits(dir))).toContain(FAKE_AWS_NOREMOT);
|
|
});
|
|
});
|