diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 45a730c1a..7f6e8f73e 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -504,8 +504,17 @@ export const PATTERNS: RedactPattern[] = [ id: "env.kv", 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,})['"]?/, + description: "Secret-named assignment (env/YAML/JSON) with high-entropy value", + // #1946 gap 3: the original shape required an UPPERCASE name and an `=` + // assignment, so `api_key=…`, `apiKey: "…"`, and `password: …` (YAML/JSON + // colon form) produced NO finding at all — a detection fail-open on the + // most common config shapes. Now case-insensitive with `:` or `=` + // assignment and optional quotes around the key (JSON). Still MEDIUM and + // entropy-gated: this is the calibrated generic net, not a blocker. + // The name part is `[A-Za-z0-9_.-]*` + suffix (zero-or-more prefix, not + // one-or-more): a mandatory first char would swallow the suffix's own + // first letter and bare names like `password:` / `key:` would never match. + regex: /^[ \t]*(?:export[ \t]+)?["']?[A-Za-z0-9_.-]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)["']?[ \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 6120ab1bb..ad4075848 100755 --- a/setup +++ b/setup @@ -2002,19 +2002,47 @@ if [ "$NO_TEAM_MODE" -eq 1 ] && [ -x "$SETTINGS_HOOK" ]; then "$SETTINGS_HOOK" remove-source --source plan-tune-cathedral 2>/dev/null || true fi -# ─── Redact pre-push guard hint (#1946) ────────────────────────────────────── +# ─── Redact pre-push guard consent (#1946) ─────────────────────────────────── # The credential pre-push hook is per-REPO state — setup runs in the gstack -# checkout, the wrong repo to install it into. /ship offers the install once -# at the moment of relevance (first push) and silently installs in any repo -# where redact_prepush_hook=true. This hint is setup's whole involvement. -# Hint only when UNSET — an explicit "false" is a recorded decline and must -# not be re-nagged on every setup run (adversarial review finding 11). +# checkout, the wrong repo to install it into, so setup NEVER installs the +# hook itself. /ship installs it silently in any repo where +# redact_prepush_hook=true. What setup owns is CONSENT: on a real interactive +# terminal it asks ONCE whether pushes should be scanned, recording the +# answer to the existing redact_prepush_hook key (default stays false — a +# timeout or non-interactive run changes nothing and keeps the hint-only +# posture). An explicit answer is persisted and never re-asked; an explicit +# "false" is a recorded decline (adversarial review finding 11). # `gstack-config get` defaults absent keys to "false", which is # indistinguishable from a decline — test key presence in the config file. _GSTACK_CFG_FILE="${GSTACK_HOME:-$HOME/.gstack}/config.yaml" if ! grep -q '^redact_prepush_hook:' "$_GSTACK_CFG_FILE" 2>/dev/null; then - log "" - log "Tip: gstack can block pushes containing credentials (per-repo git hook)." - log " Enable once: gstack-config set redact_prepush_hook true — /ship" - log " installs the hook automatically in every repo you ship from." + if [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then + _REDACT_PROMPT_TIMEOUT=10 + log "" + log "Credential push guard: gstack can block pushes containing credentials" + log "(a per-repo git pre-push hook; /ship installs it automatically in every" + log "repo you ship from — nothing is installed right now)." + printf "Enable the pre-push credential guard? [y/N] (default: N, auto-skips in %ss): " "$_REDACT_PROMPT_TIMEOUT" + read -t "$_REDACT_PROMPT_TIMEOUT" -r _REDACT_REPLY /dev/null || _REDACT_REPLY="" + case "$_REDACT_REPLY" in + y|Y) + "$GSTACK_CONFIG" set redact_prepush_hook true 2>/dev/null || true + log "Enabled. /ship will install the guard in each repo at first push." + ;; + n|N) + "$GSTACK_CONFIG" set redact_prepush_hook false 2>/dev/null || true + log "Declined — recorded. Re-enable anytime: gstack-config set redact_prepush_hook true" + ;; + *) + # Timed out / empty: don't persist a decline — hint and ask next time. + log "" + log "Skipped for now. Enable anytime: gstack-config set redact_prepush_hook true" + ;; + esac + else + log "" + log "Tip: gstack can block pushes containing credentials (per-repo git hook)." + log " Enable once: gstack-config set redact_prepush_hook true — /ship" + log " installs the hook automatically in every repo you ship from." + fi fi diff --git a/test/redact-engine.test.ts b/test/redact-engine.test.ts index 33771f904..2f99758f0 100644 --- a/test/redact-engine.test.ts +++ b/test/redact-engine.test.ts @@ -166,6 +166,29 @@ describe("MEDIUM demoted credential-shaped patterns (TENSION-1)", () => { expect(ids("API_KEY=${MY_VAR}")).not.toContain("env.kv"); }); + // #1946 gap 3: the uppercase-`=`-only shape made lowercase and YAML/JSON + // colon assignments invisible — the exact config shapes people actually + // push. Each closed detection fail-open gets a pinned case. + test("env.kv fires on lowercase = assignment (#1946)", () => { + expect(ids("api_key=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv"); + }); + test("env.kv fires on YAML colon assignment (#1946)", () => { + expect(ids("password: 8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv"); + }); + test("env.kv fires on quoted JSON key colon assignment (#1946)", () => { + expect(ids('"apiKey": "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ"')).toContain("env.kv"); + }); + test("env.kv colon/lowercase forms stay entropy-gated and placeholder-safe", () => { + expect(ids("password: changeme")).not.toContain("env.kv"); + expect(ids("apiKey: YOUR_API_KEY_HERE")).not.toContain("env.kv"); + expect(ids("api_key=${MY_VAR}")).not.toContain("env.kv"); + }); + test("env.kv stays MEDIUM (calibration: generic net, not a blocker)", () => { + const f = scan("api_key=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ", { repoVisibility: "private" }) + .findings.find((x) => x.id === "env.kv"); + expect(f?.tier).toBe("MEDIUM"); + }); + // #1946 — Bearer is the most FP-prone shape in the wave: docs and examples // are full of "Authorization: Bearer ". MEDIUM + header proximity + // the env.kv entropy recipe keep it calibrated. diff --git a/test/redact-prepush-hook.test.ts b/test/redact-prepush-hook.test.ts index 1faed1d5c..16f0f6d4b 100644 --- a/test/redact-prepush-hook.test.ts +++ b/test/redact-prepush-hook.test.ts @@ -221,6 +221,82 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => { expect(tmpl).toContain(".redact-prepush-prompted"); expect(tmpl).toContain("redact_prepush_hook"); }); + + // #1946 / maintainer decision 6: setup asks ONCE for consent on a real TTY, + // records the answer to the existing redact_prepush_hook key, and keeps the + // hint-only posture everywhere else. Default stays FALSE; setup never + // installs the hook itself (the assertion above pins that). + describe("one-time consent prompt in setup (#1946, decision 6)", () => { + const setup = fs.readFileSync(path.join(ROOT, "setup"), "utf8"); + const block = setup.slice(setup.indexOf("# ─── Redact pre-push guard consent")); + + test("prompt is gated on key ABSENCE and a real TTY, with a timed default-N read", () => { + expect(block).toContain("grep -q '^redact_prepush_hook:'"); + expect(block).toContain('[ -t 0 ] && [ -t 1 ]'); + expect(block).toContain("[y/N]"); + expect(block).toContain('read -t "$_REDACT_PROMPT_TIMEOUT"'); + }); + + test("an explicit answer persists true/false; timeout persists NOTHING", () => { + expect(block).toContain("set redact_prepush_hook true"); + expect(block).toContain("set redact_prepush_hook false"); + // The timeout branch must not write the key (a silent decline would + // permanently suppress the ask without the user ever seeing it). The + // branch's hint TEXT mentions the command; the executable invocation is + // the quoted "$GSTACK_CONFIG" form. + const timeoutBranch = block.slice(block.indexOf("*)"), block.indexOf("esac")); + expect(timeoutBranch).not.toContain('"$GSTACK_CONFIG" set redact_prepush_hook'); + }); + + test("non-interactive setup keeps the hint-only posture (no prompt, no key write)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-consent-")); + try { + const script = [ + "QUIET=0", + 'log() { echo "$@"; }', + `GSTACK_CONFIG="${path.join(ROOT, "bin", "gstack-config")}"`, + block, + ].join("\n"); + const r = spawnSync("bash", ["-c", script], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], // stdin not a TTY + env: { ...process.env, GSTACK_HOME: home }, + timeout: 15_000, + }); + expect(r.status).toBe(0); + expect(r.stdout).toContain("Tip:"); + expect(r.stdout).not.toContain("[y/N]"); + const cfg = path.join(home, "config.yaml"); + const cfgText = fs.existsSync(cfg) ? fs.readFileSync(cfg, "utf8") : ""; + expect(cfgText).not.toContain("redact_prepush_hook"); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test("a recorded answer is never re-asked (key present → silent)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-consent-set-")); + try { + fs.writeFileSync(path.join(home, "config.yaml"), "redact_prepush_hook: false\n"); + const script = [ + "QUIET=0", + 'log() { echo "$@"; }', + `GSTACK_CONFIG="${path.join(ROOT, "bin", "gstack-config")}"`, + block, + ].join("\n"); + const r = spawnSync("bash", ["-c", script], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, GSTACK_HOME: home }, + timeout: 15_000, + }); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe(""); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + }); }); describe("escape valve", () => {