Files
gstack/test/redact-prepush-target.test.ts
T
Garry Tan b9706f3635 v1.88.1.0 fix: harden credential boundaries and owned state (#2942)
* fix(settings): preserve symlinked settings targets

Resolve the selected target for locking, mutation, backup, and rollback; refuse target changes and preserve private modes. Addresses #2830.

* fix(redact): bind masking to original detected spans

Inspired by #2929's anchored-span diagnosis; independently implemented using normalization offsets. Addresses #2930 and the relocation portion of #2912 without changing detection sensitivity.

* fix(evals): exclude operator credentials from prefix admission

Adapts the credential-suffix screen proposed in #2636, with real launched-child regression coverage and deliberate provider-auth exceptions.

* fix(artifacts): retain custom allowlist rules on reinitialization

Preserve the exact user-owned suffix and publish only a successfully assembled replacement. Independently implements the repair reported in #2907.

* test(cso): verify exact masked reads and unmaskable payload refusal

* fix(cso): preserve exact filesystem identities through lease recovery

Preserve 64-bit device/inode identity and nanosecond race checks. Add native NTFS lifecycle coverage for #2927; retain ambiguous legacy-state refusal without claiming Windows PID-reuse recovery is resolved.

* fix(redact): bind pre-push scans to destination and preserve seam context

Uses #2935 (bd07318) as source evidence for push-target range and slice-overlap defects. Independently implemented; no cherry-pick or release metadata adoption.

* test(ci): gate native agent ownership and settings links on macOS

* fix(browse): bind agent lifetimes and cleanup to owned generations

Uses #2931 by Chris Hutton / Claude Fable 5.1 as attributed design input; independently implemented without broad sweeps or copied code. Keep uncertain children and locks rather than deleting foreign state.

* test(ci): include concurrent shutdown controls in the native macOS gate

* v1.88.1.0 fix: harden credential boundaries and owned state

* fix(redact): preserve target provenance and scan boundary semantics

* test(artifacts): read managed rules from atomic allowlist assembly

* fix: preserve native exit observations and fixture prerequisites

* fix: preserve UTF-16 offsets through redaction normalization
2026-09-23 08:54:53 -04:00

394 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { scan } from "../lib/redact-engine";
const REDACT = path.resolve(import.meta.dir, "../bin/gstack-redact");
const roots: string[] = [];
const key = ["AKIA", "1234567890ABCDEF"].join("");
function git(cwd: string, ...args: string[]): string {
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 });
if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr}`);
return result.stdout.trim();
}
function fixture(initialSecret = true, sha256 = false): { repo: string; origin: string; publish: string; head: string } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-target-"));
roots.push(root);
const repo = path.join(root, "repo");
const origin = path.join(root, "origin.git");
const publish = path.join(root, "publish.git");
fs.mkdirSync(repo);
const format = sha256 ? ["--object-format=sha256"] : [];
git(root, "init", "--bare", "-q", "-b", "main", ...format, origin);
git(root, "init", "--bare", "-q", "-b", "main", ...format, publish);
git(repo, "init", "-q", "-b", "main", ...format);
git(repo, "config", "user.name", "Fixture");
git(repo, "config", "user.email", "fixture@example.test");
fs.writeFileSync(path.join(repo, "config.txt"), initialSecret ? `key ${key}\n` : "clean\n");
git(repo, "add", "config.txt");
git(repo, "commit", "-qm", "seed");
const head = git(repo, "rev-parse", "HEAD");
git(repo, "remote", "add", "origin", origin);
git(repo, "push", "-q", "-u", "origin", "main");
git(repo, "remote", "add", "publish", publish);
const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(install.status).toBe(0);
return { repo, origin, publish, head };
}
function seedIndependentRemote(repo: string, publish: string): string {
const producer = path.join(path.dirname(repo), "producer");
fs.mkdirSync(producer);
git(producer, "init", "-q", "-b", "main");
git(producer, "config", "user.name", "Fixture");
git(producer, "config", "user.email", "fixture@example.test");
fs.writeFileSync(path.join(producer, "clean.txt"), "different history\n");
git(producer, "add", "clean.txt");
git(producer, "commit", "-qm", "remote tip");
git(producer, "remote", "add", "publish", publish);
git(producer, "push", "-q", "publish", "main");
return git(publish, "rev-parse", "refs/heads/main");
}
afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
describe("installed pre-push guard uses the actual destination", () => {
test("origin tracking at HEAD cannot excuse the same credential on first push to publish", () => {
const { repo, publish, head } = fixture();
expect(git(repo, "rev-parse", "origin/main")).toBe(head);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
});
test("a configured remote's different push URL cannot reuse its fetch tracking tip", () => {
const { repo, publish } = fixture();
git(repo, "remote", "set-url", "--push", "origin", publish);
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
});
for (const [name, symbolicHead] of [
["a local branch cannot shadow the target's qualified remote-tracking base", true],
["the main fallback cannot resolve to a colliding local branch", false],
] as const) {
test(name, () => {
const { repo, publish, head: base } = fixture(false);
fs.writeFileSync(path.join(repo, "leak.txt"), `key ${key}\n`);
git(repo, "add", "leak.txt");
git(repo, "commit", "-qm", "secret after target base");
const tip = git(repo, "rev-parse", "HEAD");
git(repo, "update-ref", "refs/remotes/publish/main", base);
if (symbolicHead) git(repo, "symbolic-ref", "refs/remotes/publish/HEAD", "refs/remotes/publish/main");
git(repo, "update-ref", "refs/heads/publish/main", tip);
expect(git(repo, "rev-parse", "publish/main")).toBe(tip);
expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(base);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
});
}
test("the second fetch URL is not represented by the first URL's tracking ref", () => {
const { repo, origin, publish } = fixture();
const second = path.join(path.dirname(repo), "second.git");
git(path.dirname(repo), "init", "--bare", "-q", "-b", "main", second);
git(publish, "fetch", "-q", origin, "main:refs/heads/main");
git(repo, "fetch", "-q", "publish");
expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(git(repo, "rev-parse", "HEAD"));
git(repo, "remote", "set-url", "--add", "publish", second);
expect(git(repo, "remote", "get-url", "publish")).toBe(publish);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(second, "for-each-ref", "--format=%(refname)")).toBe("");
});
test("direct URL push cannot borrow origin's tracking history", () => {
const { repo, publish } = fixture();
const push = spawnSync("git", ["push", publish, "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe("");
});
test("an advertised remote tip absent from local objects cannot justify a guessed base", () => {
const { repo, publish } = fixture();
const remoteTip = seedIndependentRemote(repo, publish);
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0);
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(publish, "rev-parse", "refs/heads/main")).toBe(remoteTip);
});
test("an absent advertised tip permits a clean forced update after conservative scanning", () => {
const { repo, publish, head } = fixture(false);
const remoteTip = seedIndependentRemote(repo, publish);
expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0);
const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(git(publish, "rev-parse", "refs/heads/main")).toBe(head);
});
test("new SHA-256 branch scans the correct empty tree and allows a clean push", () => {
const { repo, publish } = fixture(false, true);
const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(git(publish, "rev-parse", "refs/heads/main")).toHaveLength(64);
fs.writeFileSync(path.join(repo, "leak.txt"), `key ${key}\n`);
git(repo, "add", "leak.txt");
git(repo, "commit", "-qm", "new secret");
const blocked = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(blocked.status).toBe(1);
expect(blocked.stderr).toContain("aws.access_key");
});
test("one leaky ref blocks a multi-ref push without updating its clean neighbor", () => {
const { repo, origin, head } = fixture(false);
git(repo, "checkout", "-q", "-b", "leak");
fs.writeFileSync(path.join(repo, "leak.txt"), `key ${key}\n`);
git(repo, "add", "leak.txt");
git(repo, "commit", "-qm", "leaky branch");
git(repo, "checkout", "-q", "main");
fs.writeFileSync(path.join(repo, "clean.txt"), "safe update\n");
git(repo, "add", "clean.txt");
git(repo, "commit", "-qm", "clean branch");
const push = spawnSync("git", ["push", "origin", "main", "leak"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
expect(git(origin, "for-each-ref", "--format=%(refname)", "refs/heads/leak")).toBe("");
});
test("a deleted ref alongside a clean update is permitted", () => {
const { repo, origin } = fixture(false);
git(repo, "checkout", "-q", "-b", "disposable");
fs.writeFileSync(path.join(repo, "disposable.txt"), "safe\n");
git(repo, "add", "disposable.txt");
git(repo, "commit", "-qm", "temporary branch");
git(repo, "push", "-q", "origin", "disposable");
git(repo, "checkout", "-q", "main");
fs.writeFileSync(path.join(repo, "clean.txt"), "safe update\n");
git(repo, "add", "clean.txt");
git(repo, "commit", "-qm", "clean update");
const head = git(repo, "rev-parse", "HEAD");
const push = spawnSync("git", ["push", "origin", "main", ":disposable"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
expect(git(origin, "for-each-ref", "--format=%(refname)", "refs/heads/disposable")).toBe("");
});
test("installed wrapper forwards refs and rejects malformed input instead of passing it on", () => {
const { repo, origin, head } = fixture(false);
const hook = git(repo, "rev-parse", "--git-path", "hooks/pre-push");
const result = spawnSync("bash", [hook, "origin", origin], {
cwd: repo, input: "refs/heads/main malformed\n", encoding: "utf8", timeout: 30_000,
});
expect(result.status, result.stderr || result.error?.message).toBe(1);
expect(result.stderr).toContain("could not parse a pre-push ref line");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
});
test("a HIGH proximity finding across line-aligned scan slices cannot pass", () => {
const { repo } = fixture(false);
const value = ["AbCdEfGhIjKlMnOpQrStU", "vWxYz0123456789AbCd"].join("");
const payload = `${"x".repeat(768 * 1024 - 43)}\naws_secret_access_key=\n${value}\n`;
expect(scan(payload).findings.map((finding) => finding.id)).toContain("aws.secret_key");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "near seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.secret_key");
});
test("an unlabeled high-entropy value near a seam stays clean", () => {
const { repo } = fixture(false);
const value = ["AbCdEfGhIjKlMnOpQrStU", "vWxYz0123456789AbCd"].join("");
const payload = `${"x".repeat(768 * 1024 - 43)}\nordinary description\n${value}\n`;
expect(scan(payload).findings.map((finding) => finding.id)).not.toContain("aws.secret_key");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "unlabeled seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("aws.secret_key");
});
test("overlap does not count a single finding twice", () => {
const { repo } = fixture(false);
const payload = `${"x".repeat(768 * 1024 - 75)}\nkey ${key}\n${"x".repeat(100)}\n`;
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "one finding at seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1);
});
test("supplementary NFKC expansion cannot move a seam finding out of its owning core", () => {
const { repo, origin, head } = fixture(false);
const prefix = "\uFA6C".repeat(64);
const credential = `key ${key}\n`;
const padding = "x".repeat(768 * 1024 - Buffer.byteLength(prefix) - credential.length - 1);
const payload = `${prefix}${padding}\n${credential}${"z".repeat(100)}\n`;
expect(scan(payload).findings.map((finding) => finding.id)).toContain("aws.access_key");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "supplementary normalization seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1);
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
});
test("a partial-line overlap cannot manufacture an anchored assignment finding", () => {
const { repo } = fixture(false);
const value = ["8Fk2pQ9vXz4wL7mN", "3rT6yB1cD5eG0hJq"].join("");
const assignment = `API_KEY=${value} `;
const suffix = "z".repeat(16 * 1024 - assignment.length - 6);
const first = "!" + "x".repeat(768 * 1024 - 61 - assignment.length - suffix.length) + assignment + suffix;
const payload = `${first}\nshort\n${"z".repeat(100)}\n`;
expect(scan(payload).findings.map((finding) => finding.id)).not.toContain("env.kv");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "mid-line control");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("MEDIUM finding");
});
test("mid-line carry cannot create a HIGH word boundary around an embedded key", () => {
const { repo, origin, head } = fixture(false);
const first = "x".repeat(768 * 1024 - 1 - 16_384) + key + " ".repeat(16_384 - key.length);
const payload = `${first}\nordinary\n`;
expect(scan(payload).findings.map((finding) => finding.id)).not.toContain("aws.access_key");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "embedded key control");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("aws.access_key");
expect(git(origin, "rev-parse", "refs/heads/main")).not.toBe(head);
});
test("a long Bearer span retains its later Authorization context across the seam", () => {
const { repo, origin } = fixture(false);
const alphabet = "7pFb4ZaCuG8wDsVk2EnHy6Qt9Jr5Lx0M";
const token = Array.from({ length: 20_480 }, (_, i) => alphabet[(i * 7 + Math.floor(i / 31)) % 32]).join("");
const bearerLine = `Bearer ${token}`;
const payload = `${"x".repeat(768 * 1024 - bearerLine.length - 2)}\n${bearerLine}\nAuthorization\n`;
expect(scan(payload).findings.map((finding) => finding.id)).toContain("auth.bearer");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "long bearer advisory");
const tip = git(repo, "rev-parse", "HEAD");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(push.stderr).toContain("MEDIUM finding");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip);
});
test("zero-width padding cannot move normalized proximity context out of the overlap", () => {
const { repo } = fixture(false);
const value = ["AbCdEfGhIjKlMnOpQrStU", "vWxYz0123456789AbCd"].join("");
const payload = `${"x".repeat(768 * 1024 - 43)}\naws_secret_access_key=\n${"\u200b".repeat(35_000)}\n${value}\n`;
expect(scan(payload).findings.map((finding) => finding.id)).toContain("aws.secret_key");
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "invisible seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.secret_key");
});
test("context needing more than the engine byte cap blocks instead of scanning a truncated window", () => {
const { repo, origin, head } = fixture(false);
const value = ["AbCdEfGhIjKlMnOpQrStU", "vWxYz0123456789AbCd"].join("");
const payload = `${"x".repeat(768 * 1024 - 43)}\naws_secret_access_key=\n${"\u200b".repeat(450_000)}\n${value}\n`;
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "unscannable context");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("engine.input_too_large");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
});
for (const [name, label, expected] of [
["HTML entity", "aws&secret_access_key=", true],
["fullwidth Unicode", "aws_secret_access_key=", true],
["broken entity separated by zero-width", "aws&\u200bamp;secret_access_key=", false],
] as const) {
test(`${name} near a seam keeps the detector's exact normalization semantics`, () => {
const { repo } = fixture(false);
const value = ["AbCdEfGhIjKlMnOpQrStU", "vWxYz0123456789AbCd"].join("");
const payload = `${"x".repeat(768 * 1024 - 43)}\n${label}\n${value}\n`;
expect(scan(payload).findings.some((finding) => finding.id === "aws.secret_key")).toBe(expected);
fs.writeFileSync(path.join(repo, "payload.txt"), payload);
git(repo, "add", "payload.txt");
git(repo, "commit", "-qm", "normalization seam");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(expected ? 1 : 0);
expect(push.stderr.includes("aws.secret_key")).toBe(expected);
});
}
test("a large clean diff and a sub-cap long line pass, while an over-cap line fails closed", () => {
const { repo, origin, head } = fixture(false);
fs.writeFileSync(path.join(repo, "large.txt"), "ordinary content\n".repeat(160_000));
git(repo, "add", "large.txt");
git(repo, "commit", "-qm", "large clean diff");
let push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
expect(push.status).toBe(0);
fs.writeFileSync(path.join(repo, "long.txt"), "x".repeat(900_000) + "\n");
git(repo, "add", "long.txt");
git(repo, "commit", "-qm", "long clean line");
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
expect(push.status).toBe(0);
const priorTip = git(origin, "rev-parse", "refs/heads/main");
expect(priorTip).not.toBe(head);
fs.writeFileSync(path.join(repo, "too-long.txt"), "x".repeat(1_100_000) + "\n");
git(repo, "add", "too-long.txt");
git(repo, "commit", "-qm", "over cap line");
push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("engine.input_too_large");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(priorTip);
});
test("a detectable credential at the end of a long single line is still blocked", () => {
const { repo, origin, head } = fixture(false);
fs.writeFileSync(path.join(repo, "long.txt"), `${"x".repeat(900_000)} key ${key}\n`);
git(repo, "add", "long.txt");
git(repo, "commit", "-qm", "long line with key");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(1);
expect(push.stderr).toContain("aws.access_key");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head);
});
test("neighboring individually scannable long clean lines do not force an oversize failure", () => {
const { repo, origin } = fixture(false);
fs.writeFileSync(path.join(repo, "long.txt"), `${"x".repeat(900_000)}\n${"y".repeat(900_000)}\n`);
git(repo, "add", "long.txt");
git(repo, "commit", "-qm", "neighboring long lines");
const tip = git(repo, "rev-parse", "HEAD");
const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 });
expect(push.status).toBe(0);
expect(push.stderr).not.toContain("engine.input_too_large");
expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip);
});
});