diff --git a/bin/gstack-config b/bin/gstack-config index 15c7300c5..ce48aa21e 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -75,7 +75,14 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # Set to true once the privacy gate has asked the user. # # Flip back to false to be re-prompted. # -# ─── Plan-tune hooks ───────────────────────────────────────────────── +# ─── Redaction + Plan-tune hooks ─────────────────────────────────────── +# redact_prepush_hook: prompt # prompt | yes | no — ask to install the local +# # gstack-redact pre-push guard on a real TTY, +# # skip otherwise. Override per-run with +# # ./setup --redact-prepush-hook / +# # --no-redact-prepush-hook, or env +# # GSTACK_REDACT_PREPUSH_HOOK. +# # plan_tune_hooks: prompt # Controls whether ./setup installs the plan-tune # # Claude Code hooks (PostToolUse capture + # # PreToolUse preference enforcement). @@ -132,7 +139,7 @@ lookup_default() { plan_tune_hooks) echo "prompt" ;; # prompt | yes | no — controls ./setup plan-tune hook install redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection - redact_prepush_hook) echo "false" ;; + redact_prepush_hook) echo "prompt" ;; # Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline: # brain_trust_policy@ — unset on fresh install; setup-gbrain # writes 'personal' for local engines, @@ -306,9 +313,16 @@ case "${1:-}" in echo "Warning: redact_repo_visibility '$VALUE' not recognized. Valid values: public, private, unknown. Using unknown." >&2 VALUE="unknown" fi - if [ "$KEY" = "redact_prepush_hook" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then - echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2 - VALUE="false" + if [ "$KEY" = "redact_prepush_hook" ]; then + case "$VALUE" in + true|yes|y|1|on|install) VALUE="yes" ;; + false|no|n|0|off|skip) VALUE="no" ;; + prompt) VALUE="prompt" ;; + *) + echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2 + VALUE="prompt" + ;; + esac fi if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2 @@ -350,7 +364,8 @@ case "${1:-}" in for KEY in proactive routing_declined telemetry auto_upgrade update_check \ skill_prefix checkpoint_mode checkpoint_push explain_level \ codex_reviews gstack_contributor skip_eng_review workspace_root \ - artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do + artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \ + redact_prepush_hook; do VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) SOURCE="default" if [ -n "$VALUE" ]; then @@ -366,7 +381,8 @@ case "${1:-}" in for KEY in proactive routing_declined telemetry auto_upgrade update_check \ skill_prefix checkpoint_mode checkpoint_push explain_level \ codex_reviews gstack_contributor skip_eng_review workspace_root \ - artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do + artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \ + redact_prepush_hook; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush index fd1b05fb9..932cd5ea7 100755 --- a/bin/gstack-redact-prepush +++ b/bin/gstack-redact-prepush @@ -35,14 +35,26 @@ const ZERO = /^0+$/; // The canonical empty-tree object; diffing against it yields all content as added. const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +class GitScanError extends Error { + constructor( + readonly args: string[], + readonly status: number | null, + readonly stderr: string, + ) { + super(`git ${args.join(" ")} failed`); + } +} + /** * Permissive git for legitimately-fallible PROBES (symbolic-ref, rev-parse, * merge-base) where a non-zero exit is normal control flow. The DIFF call * must NOT use this — see gitStrict (#1946 fail-closed). */ -function git(args: string[]): string { +function git(args: string[], opts: { allowFailure?: boolean } = {}): string { const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); - return r.status === 0 ? (r.stdout ?? "") : ""; + if (r.status === 0) return r.stdout ?? ""; + if (opts.allowFailure) return ""; + throw new GitScanError(args, r.status, r.stderr ?? ""); } /** @@ -72,10 +84,10 @@ function objectExists(sha: string): boolean { function defaultRemoteBranch(): string { // origin/HEAD → origin/main, fall back to main/master. - const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"]).trim(); + const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true }).trim(); if (sym) return sym.replace("refs/remotes/", ""); for (const b of ["origin/main", "origin/master"]) { - if (git(["rev-parse", "--verify", b]).trim()) return b; + if (git(["rev-parse", "--verify", b], { allowFailure: true }).trim()) return b; } return "origin/main"; } @@ -87,14 +99,14 @@ function addedLinesFor(localSha: string, remoteSha: string): string { // New branch: prefer what's unique to localSha vs the remote default branch. // With no merge-base (e.g. no remote yet), diff against the empty tree so ALL // branch content is scanned as added — fail-safe (scans more, never less). - const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim(); + const base = git(["merge-base", localSha, defaultRemoteBranch()], { allowFailure: true }).trim(); range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`; } else if (!objectExists(remoteSha)) { // Remote tip object absent locally (shallow clone, force-push without a // prior fetch, CI checkout): remote..local can't resolve. Fall back to // the merge-base/empty-tree path — scans MORE, never less — instead of // hard-blocking a legitimate push (adversarial review finding 8). - const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim(); + const base = git(["merge-base", localSha, defaultRemoteBranch()], { allowFailure: true }).trim(); range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`; } else { // Existing branch (incl. force-push): net new content remote..local. @@ -154,10 +166,16 @@ function main() { // Fail CLOSED (#1946): if we can't compute the pushed diff we can't // scan it, and unscanned-but-allowed is the failure mode this hook // exists to prevent. + const detail = + err instanceof GitScanError + ? `git ${err.args.join(" ")} exited ${err.status ?? "unknown"}${err.stderr.trim() ? `: ${err.stderr.trim()}` : ""}` + : err instanceof Error + ? err.message.split("\n")[0] + : String(err); process.stderr.write( - "\n⛔ gstack-redact-prepush BLOCKED the push — could not compute the pushed diff, " + - "so it cannot be scanned for credentials.\n" + - ` (${err instanceof Error ? err.message.split("\n")[0] : String(err)})\n` + + "\n⛔ gstack-redact-prepush BLOCKED the push — could not scan pushed diff safely: " + + "could not compute the pushed diff, so it cannot be scanned for credentials.\n" + + ` (${detail.slice(0, 500)})\n` + "Bypass if you're sure: GSTACK_REDACT_PREPUSH=skip git push (or git push --no-verify)\n", ); process.exit(1); diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 76b81f3d2..69c3e0eff 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -108,6 +108,10 @@ export function shannonEntropy(s: string): number { return h; } +function looksHighEntropySecret(span: string): boolean { + return span.length >= 20 && /[A-Za-z]/.test(span) && /\d/.test(span) && shannonEntropy(span) >= 3.0; +} + /** True when an IPv4 string is a public address (not RFC1918/loopback/etc). */ export function isPublicIPv4(ip: string): boolean { const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); @@ -243,14 +247,14 @@ export const PATTERNS: RedactPattern[] = [ tier: "HIGH", category: "secret", description: "npm granular access token", - regex: /\b(npm_[A-Za-z0-9]{36})\b/, + regex: /\b(npm_[A-Za-z0-9_\-]{30,})\b/, }, { id: "digitalocean.token", tier: "HIGH", category: "secret", description: "DigitalOcean personal access token", - regex: /\b(dop_v1_[a-f0-9]{64})\b/, + regex: /\b(dop_v1_[A-Za-z0-9]{64,})\b/, }, { id: "gcp.service_account", @@ -318,6 +322,13 @@ export const PATTERNS: RedactPattern[] = [ description: "Discord webhook URL", regex: /(https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api\/webhooks\/[0-9]{17,20}\/[A-Za-z0-9_\-]{60,})/, }, + { + id: "gcp.service_account_json", + tier: "HIGH", + category: "secret", + description: "Google Cloud service-account JSON with embedded private key", + regex: /(\{[\s\S]{0,20000}"type"\s*:\s*"service_account"[\s\S]{0,20000}"private_key"\s*:\s*"-----BEGIN PRIVATE KEY-----[\s\S]{0,20000}\})/, + }, { id: "twilio.auth_token", tier: "HIGH", @@ -387,7 +398,7 @@ export const PATTERNS: RedactPattern[] = [ tier: "MEDIUM", category: "secret", description: "Env-style SECRET assignment with high-entropy value", - regex: /^[ \t]*(?:export[ \t]+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)[ \t]*=[ \t]*['"]?([^\s'"]{8,})['"]?/, + regex: /^[ \t]*(?:export[ \t]+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)[A-Z0-9_]*[ \t]*(?:=|:)[ \t]*['"]?([^\s'"]{8,})['"]?/i, // Only fire on high-entropy values — kills `FOO_KEY=changeme` FPs. validate: (span) => !isPlaceholderSpan(span) && diff --git a/setup b/setup index 275236cd3..146c0c767 100755 --- a/setup +++ b/setup @@ -83,6 +83,7 @@ SKILL_PREFIX_FLAG=0 TEAM_MODE=0 NO_TEAM_MODE=0 PLAN_TUNE_HOOKS_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = explicit +REDACT_PREPUSH_HOOK_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = explicit while [ $# -gt 0 ]; do case "$1" in --host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;; @@ -95,6 +96,9 @@ while [ $# -gt 0 ]; do --plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="yes"; shift ;; --no-plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="no"; shift ;; --plan-tune-hooks=*) PLAN_TUNE_HOOKS_MODE="${1#--plan-tune-hooks=}"; shift ;; + --redact-prepush-hook) REDACT_PREPUSH_HOOK_MODE="yes"; shift ;; + --no-redact-prepush-hook) REDACT_PREPUSH_HOOK_MODE="no"; shift ;; + --redact-prepush-hook=*) REDACT_PREPUSH_HOOK_MODE="${1#--redact-prepush-hook=}"; shift ;; -q|--quiet) QUIET=1; shift ;; *) shift ;; esac @@ -1508,6 +1512,7 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \ fi fi + # Also tear down plan-tune hooks on --no-team (matches the existing pattern). if [ "$NO_TEAM_MODE" -eq 1 ] && [ -x "$SETTINGS_HOOK" ]; then "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral 2>/dev/null || true diff --git a/test/gstack-config-redact-keys.test.ts b/test/gstack-config-redact-keys.test.ts index 9290d478d..5a9167983 100644 --- a/test/gstack-config-redact-keys.test.ts +++ b/test/gstack-config-redact-keys.test.ts @@ -31,8 +31,8 @@ describe("redact config keys", () => { test("redact_repo_visibility default is empty (falls through to detection)", () => { expect(cfg(["get", "redact_repo_visibility"]).out).toBe(""); }); - test("redact_prepush_hook default is false", () => { - expect(cfg(["get", "redact_prepush_hook"]).out).toBe("false"); + test("redact_prepush_hook default is prompt", () => { + expect(cfg(["get", "redact_prepush_hook"]).out).toBe("prompt"); }); test("set + get round-trips a valid visibility", () => { cfg(["set", "redact_repo_visibility", "private"]); @@ -43,9 +43,13 @@ describe("redact config keys", () => { expect(r.err).toContain("not recognized"); expect(cfg(["get", "redact_repo_visibility"]).out).toBe("unknown"); }); - test("invalid prepush flag is rejected to false", () => { + test("invalid prepush flag is rejected to prompt", () => { cfg(["set", "redact_prepush_hook", "maybe"]); - expect(cfg(["get", "redact_prepush_hook"]).out).toBe("false"); + expect(cfg(["get", "redact_prepush_hook"]).out).toBe("prompt"); + }); + test("redact_prepush_hook appears in list/defaults", () => { + expect(cfg(["list"]).out).toContain("redact_prepush_hook:"); + expect(cfg(["defaults"]).out).toContain("redact_prepush_hook:"); }); test("no block_private key (HIGH blocks both visibilities unconditionally)", () => { // The default for an unknown key is empty string — there is no such key. diff --git a/test/redact-engine.test.ts b/test/redact-engine.test.ts index b52c630d5..c7c2b222f 100644 --- a/test/redact-engine.test.ts +++ b/test/redact-engine.test.ts @@ -42,6 +42,19 @@ describe("HIGH credential patterns", () => { ["slack.token", "xox" + "b-1234567890-abcdefghijklmnop"], ["slack.webhook", "https://hooks.slack.com/services/T00000000/B11111111/" + "a".repeat(24)], ["discord.webhook", "https://discord.com/api/webhooks/123456789012345678/" + "a".repeat(60)], + ["gitlab.token", "glpat-" + "a".repeat(20)], + ["huggingface.token", "hf_" + "a".repeat(34)], + ["npm.token", "npm_" + "a".repeat(36)], + ["digitalocean.token", "dop_v1_" + "a".repeat(64)], + [ + "gcp.service_account_json", + `{ + "type": "service_account", + "private_key_id": "${"a".repeat(40)}", + "private_key": "-----BEGIN PRIVATE KEY-----\\n${"A".repeat(64)}\\n-----END PRIVATE KEY-----\\n", + "client_email": "svc@project.iam.gserviceaccount.com" +}`, + ], ["pem.private_key", "-----BEGIN RSA PRIVATE KEY-----"], // #1946 coverage-gap additions ["gitlab.token", "remote: glpat-" + "Ab12Cd34Ef56Gh78Ij90"], @@ -99,6 +112,16 @@ describe("HIGH credential patterns", () => { expect(ids(`random ${tok} here`)).not.toContain("twilio.auth_token"); }); + test("generic Bearer token needs authorization context and enough entropy", () => { + const token = "Ab3_xY9.KlmN0pQr-StUvWxYz456789"; + const lowercaseToken = "abc123def456abc123def456abc123def456"; + expect(ids("Authorization: Bearer " + token)).toContain("auth.bearer"); + expect(ids("Bearer " + token)).not.toContain("auth.bearer"); + expect(ids("Authorization: Bearer " + lowercaseToken)).toContain("auth.bearer"); + expect(ids("docs say Bearer here")).not.toContain("auth.bearer"); + expect(ids("Authorization: Bearer " + "a".repeat(32))).not.toContain("auth.bearer"); + }); + test("db.url_with_password flags real password, skips placeholder/env-var", () => { expect(ids("postgres://user:s3cretP@ss@db.example.com/app")).toContain("db.url_with_password"); expect(ids("postgres://user:${DB_PASSWORD}@host/app")).not.toContain("db.url_with_password"); @@ -130,7 +153,10 @@ describe("MEDIUM demoted credential-shaped patterns (TENSION-1)", () => { }); test("env.kv fires on high-entropy, skips placeholder", () => { expect(ids("API_TOKEN=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv"); + expect(ids("api_key: 8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv"); + expect(ids('apiToken: "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ"')).toContain("env.kv"); expect(ids("API_KEY=changeme")).not.toContain("env.kv"); + expect(ids("api_key: changeme")).not.toContain("env.kv"); expect(ids("API_KEY=${MY_VAR}")).not.toContain("env.kv"); }); diff --git a/test/redact-prepush-hook.test.ts b/test/redact-prepush-hook.test.ts index cf8598523..62be6a0cd 100644 --- a/test/redact-prepush-hook.test.ts +++ b/test/redact-prepush-hook.test.ts @@ -82,6 +82,25 @@ describe("pre-push hook gating", () => { expect(code).toBe(0); expect(stderr).toContain("MEDIUM"); }); + + test("common token prefixes in pushed diff block", () => { + const base = git(["rev-parse", "HEAD"]); + const head = commit("tokens.txt", "gitlab glpat-" + "a".repeat(20) + "\n", "add gitlab token"); + const { code, stderr } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`); + expect(code).toBe(1); + expect(stderr).toContain("gitlab.token"); + }); +}); + +describe("fail-closed git failures", () => { + test("invalid local sha blocks instead of silently allowing an unscanned push", () => { + const base = git(["rev-parse", "HEAD"]); + const { code, stderr } = runHook( + `refs/heads/main deadbeefdeadbeefdeadbeefdeadbeefdeadbeef refs/heads/main ${base}\n`, + ); + expect(code).toBe(1); + expect(stderr).toContain("could not scan pushed diff safely"); + }); }); describe("diff direction + special refs", () => { diff --git a/test/setup-plan-tune-hooks-noninteractive.test.ts b/test/setup-plan-tune-hooks-noninteractive.test.ts index 9a0f03ded..bbff5746f 100644 --- a/test/setup-plan-tune-hooks-noninteractive.test.ts +++ b/test/setup-plan-tune-hooks-noninteractive.test.ts @@ -62,6 +62,29 @@ describe('setup: plan-tune hooks are non-interactive-safe', () => { }); }); +describe('setup: redaction pre-push hook install is consent-based and non-interactive-safe', () => { + test('exposes --redact-prepush-hook / --no-redact-prepush-hook / =value flags', () => { + expect(setupSrc).toContain('--redact-prepush-hook)'); + expect(setupSrc).toContain('--no-redact-prepush-hook)'); + expect(setupSrc).toContain('--redact-prepush-hook=*)'); + }); + + test('redaction hook decision falls through env then saved config', () => { + expect(setupSrc).toContain('GSTACK_REDACT_PREPUSH_HOOK'); + expect(setupSrc).toContain('get redact_prepush_hook'); + }); + + test('redaction hook prompt is time-bounded and TTY-gated', () => { + expect(setupSrc).toMatch(/read -t (?:\d+|"?\$\{?\w+\}?"?) -r REDACT_PREPUSH_INSTALL_REPLY <\/dev\/tty/); + expect(setupSrc).toMatch(/\[ "\$QUIET" -ne 1 \] && \[ -t 0 \] && \[ -t 1 \]/); + }); + + test('redaction hook git check and install target the source gstack repo', () => { + expect(setupSrc).toContain('( cd "$SOURCE_GSTACK_DIR" && git rev-parse --git-dir'); + expect(setupSrc).toContain('( cd "$SOURCE_GSTACK_DIR" && bun "$REDACT_BIN" install-prepush-hook )'); + }); +}); + describe('dev-setup: never silently mutates global settings.json', () => { const DEV_SETUP = path.join(ROOT, 'bin', 'dev-setup'); const devSetupSrc = fs.readFileSync(DEV_SETUP, 'utf-8');