diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2a357..d500e7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,25 @@ hardening pass done ahead of open-sourcing — is preserved in - **Go toolchain bumped to 1.26.9** for GO-2026-6617 (HTTP/2 server crash from an HPACK encoder race in `net/http`, fixed in 1.26.9). +### Added +- **Redis AUTH detection: four embedded rules covering the leak shapes + upstream has no answer for.** A bare Redis password has no token prefix + and no key=value shape — the two forms gitleaks-class rules detect — so + the plain AUTH command line as error logs show it, the node-redis + structured error form (args array and JSON), redis-cli `-a`/`--pass` + invocations, and RESP protocol dumps (raw CRLF, escaped, space-flattened) + all passed through unredacted. Rules are ported rule-for-rule from + [webflow/secrets-scrubber](https://github.com/webflow/secrets-scrubber) + (Santiago Moreno), whose project was built around a real Redis key leak + into an AI session; RE2 forbids its JS lookaheads, so its + reference-value heuristic became a per-rule CEL filter. Every rule + carries a minimum-length floor (16 for plain AUTH, 8 elsewhere) — a + sub-8-char finding trips Splice's over-redact backstop against + unrelated lines carrying the same short form. Port deviation: the RESP + rule requires the `*N` array header (all of secrets-scrubber's RESP + fixtures carry it) so prose dollar-amounts near the word auth do not + redact. + ### Fixed - **Hooks drain stdin before exiting.** A paused hook (or one with an off switch set) returned without reading its input, so a bridge writing a diff --git a/THREATMODEL.md b/THREATMODEL.md index dc0a430..f65c305 100644 --- a/THREATMODEL.md +++ b/THREATMODEL.md @@ -213,6 +213,16 @@ the approach, not bugs: shaped to evade the ruleset are not in scope — ctxcop assumes the agent and tools are not actively cooperating in exfiltration. +- **Bare Redis AUTH residuals.** The embedded redis-auth rules cover + the common leak shapes (plain AUTH lines, node-redis structured + errors, RESP dumps, redis-cli flags; ported from + webflow/secrets-scrubber) but keep their floors and framing + requirements: shorter passwords, RESP dumps with an empty + (`$0`-bulk) ACL username, and any RESP command other than AUTH pass + through. Floors exist because a sub-8-char finding trips Splice's + over-redact backstop against unrelated lines carrying the same + short form. + ## Out of scope These are documented non-goals, listed so it's clear what *not* to diff --git a/docs/detection.md b/docs/detection.md index 67f9a01..ba9d86d 100644 --- a/docs/detection.md +++ b/docs/detection.md @@ -9,7 +9,7 @@ what every hook scans. built-in recursive codec decoding (base64/hex/percent/unicode) so secrets that were encoded before they reached the tool output are still caught. -2. **ctxcop embedded pack** — eight high-confidence single-purpose +2. **ctxcop embedded pack** — twelve high-confidence single-purpose patterns shipped in the binary. Same shape as gitleaks rules but tuned for the CLI-output threat model: where gitleaks requires a `\b` boundary (so credentials glued to surrounding text by tools @@ -27,6 +27,19 @@ what every hook scans. | `ctxcop-gitlab-pat` | `glpat-` | | `ctxcop-slack-token` | `xox[bpoars]-` | | `ctxcop-stripe-secret-key` | `(sk\|rk)_(live\|test)_` | + | `ctxcop-redis-auth-command` | bare `AUTH [user] ` command lines (16+, upper/digit gate) | + | `ctxcop-redis-auth-array` | `AUTH` in quoted args arrays / JSON (node-redis error form) | + | `ctxcop-redis-auth-resp` | `AUTH` in RESP protocol dumps (raw / escaped / flattened CRLF) | + | `ctxcop-redis-auth-cli` | `redis-cli` `-a` / `--pass` flag arguments | + + The redis-auth rules close a structural gap in the upstream ruleset: + a bare Redis password has no token prefix and no key=value shape, the + two forms gitleaks-class rules detect. Ported from + [webflow/secrets-scrubber](https://github.com/webflow/secrets-scrubber) + (rule-for-rule from its four redis-auth rules; RE2 forbids its JS + lookaheads, so its reference-value heuristic became the per-rule CEL + filter). Redaction span is the whole match: the AUTH verb, RESP + framing, and ACL username vanish with the password. 3. **`~/.ctxcop/*.toml`** — every `.toml` file in the user rules dir, loaded in alphabetical order. Drop org-specific credential patterns diff --git a/internal/rules/embedded/ctxcop.toml b/internal/rules/embedded/ctxcop.toml index eff0275..923a472 100644 --- a/internal/rules/embedded/ctxcop.toml +++ b/internal/rules/embedded/ctxcop.toml @@ -192,3 +192,88 @@ id = "sourcegraph-access-token" description = "Sourcegraph access token (prefixed forms only; bare 40-hex dropped — collides with Git SHAs)" regex = '''(?i)\b(\b(sgp_(?:[a-fA-F0-9]{16}|local)_[a-fA-F0-9]{40}|sgp_[a-fA-F0-9]{40})\b)(?:\\?['"\x60]|[\s;]|\\[nr]|$)''' keywords = ["sgp_"] + +# Redis authentication credentials. Ported from webflow/secrets-scrubber +# (built around a real Redis key leak into an AI session), where betterleaks +# upstream has a structural gap: a bare Redis password carries no +# self-identifying token prefix and no key=value assignment structure, the +# two shapes gitleaks-class rules detect. Four leak shapes passed through +# unredacted upstream + embedded as of v0.1.1: the plain AUTH command line +# as error logs and pasted history show it, the node-redis structured error +# form (args array and JSON rendering), redis-cli invocations, and RESP +# protocol dumps in all three encodings they appear in logs (raw CRLF, +# escaped CRLF, space-flattened). +# +# Adaptations from the secrets-scrubber JS originals to RE2 + CEL-filter +# semantics: +# 1. Reference-value exclusion ($VAR refs, process.env chains, template +# placeholders must pass) moves from a replace-callback heuristic into +# a per-rule CEL filter on the captured secret. +# 2. The plain-AUTH "value must contain an uppercase letter or digit" +# gate becomes the `[a-z]*[A-Z0-9]` prefix, preserving the secret-span +# start. +# 3. RESP bulk values exclude whitespace and backslash instead of the JS +# tempered-dot lookahead (RE2 has none). Real passwords contain neither. +# 4. The RESP rule additionally requires the `*N` array header every real +# RESP command dump carries — the JS original does not anchor on it, +# but without it a dollar-amount next to the literal word "auth" in +# prose would redact an innocent trailing word. All of +# secrets-scrubber's RESP fixtures carry the header, so recall on the +# leak shapes is unchanged. +# +# Span semantics: these rules redact the whole match (AUTH verb, framing, +# and ACL username vanish with the password), matching the bearer-token +# rule's house style. The db-connection-uri rule keeps the host only +# because its regex deliberately ends at `@`. +# +# Every rule carries a minimum-length floor: a sub-8-char finding trips +# Splice's over-redact backstop against lines sharing its tiny secret form +# (a stray `/` swept every slash-bearing line during development). The +# plain-AUTH rule keeps secrets-scrubber's 16+ floor; array, RESP, and cli +# rules use 8. +[[rules]] +id = "ctxcop-redis-auth-command" +description = "Redis AUTH command line: optional ACL user then a 16+ char password (upper or digit required)" +regex = '''(?m)(?:^|[^A-Za-z0-9_])AUTH[ \t]+(?:[A-Za-z0-9_.-]{1,64}[ \t]+)?([a-z]*[A-Z0-9][^\s'"`]{15,})''' +keywords = ["auth"] +filter = ''' +matchesAny(finding["secret"], [ + "^[$<{%\\[]", + "^(?i)(?:process|env|os|self|config|secrets?|var|vars)\\." +]) +''' + +[[rules]] +id = "ctxcop-redis-auth-array" +description = "Redis AUTH in structured args / JSON: quoted AUTH, optional user, 8+ char quoted password" +regex = '''['"]AUTH['"]\s*,\s*(?:['"][^'"]{1,64}['"]\s*,\s*)?['"]([^'"]{8,})['"]''' +keywords = ["auth"] +filter = ''' +matchesAny(finding["secret"], [ + "^[$<{%\\[]", + "^(?i)(?:process|env|os|self|config|secrets?|var|vars)\\." +]) +''' + +[[rules]] +id = "ctxcop-redis-auth-resp" +description = "Redis AUTH in RESP protocol dumps (raw/escaped/flattened CRLF): password bulk string after the AUTH command bulk" +regex = '''(?i)\*[0-9]+(?:\\r\\n|\r\n|\n|\r| )\$4(?:\\r\\n|\r\n|\n|\r| )auth(?:\\r\\n|\r\n|\n|\r| )(?:(?:\$[0-9]+(?:\\r\\n|\r\n|\n|\r| ))?[^\s\\]+(?:\\r\\n|\r\n|\n|\r| ))?\$[0-9]+(?:\\r\\n|\r\n|\n|\r| )([^\s\\]+)''' +keywords = ["auth"] +filter = ''' +matchesAny(finding["secret"], [ + "^[$<{%\\[]" +]) +''' + +[[rules]] +id = "ctxcop-redis-auth-cli" +description = "redis-cli password flag arguments (the -a/--pass flags)" +regex = '''\bredis-cli\b[^\n|;&]*?[ \t](?:-a|--pass)[ \t]+['"]?([^\s'"]{8,})''' +keywords = ["redis-cli"] +filter = ''' +matchesAny(finding["secret"], [ + "^[$<{%\\[]", + "^(?i)(?:process|env|os|self|config|secrets?|var|vars)\\." +]) +''' diff --git a/internal/rules/redis_auth_rules_test.go b/internal/rules/redis_auth_rules_test.go new file mode 100644 index 0000000..376c370 --- /dev/null +++ b/internal/rules/redis_auth_rules_test.go @@ -0,0 +1,152 @@ +package rules + +import "testing" + +// Redis AUTH coverage (ctxcop-redis-auth-* rules). Ported from +// webflow/secrets-scrubber, which was built around a real Redis key leak +// into an AI session. Positive shapes are the leak forms; negative shapes +// are secrets-scrubber's own false-positive suite plus the gates added in +// the port. Every literal is split via join() so no contiguous +// credential-shaped string sits in this file. +func TestRedisAuthRulesMatchAndAreGated(t *testing.T) { + token := join("Zk3", "mQ9vLp2XwR7tY", "bN4cH8dF") // 24-char fake + user := "svc" + + cases := []struct { + name string + content string + want string // rule ID that must fire + notWant string // rule ID that must NOT fire + }{ + // Plain AUTH command line. + { + name: "plain AUTH command in an error line", + content: join("AbortError: AUTH ", token, " failed"), + want: "ctxcop-redis-auth-command", + }, + { + name: "AUTH with ACL username", + content: join("AUTH ", user, " ", token), + want: "ctxcop-redis-auth-command", + }, + { + // secrets-scrubber's prose gate: a status phrase after the + // AUTH verb is not a password. + name: "prose after AUTH verb is left alone", + content: "AUTH rotation-complete", + notWant: "ctxcop-redis-auth-command", + }, + { + name: "env-var reference value is left alone", + content: "AUTH $REDIS_PASS", + notWant: "ctxcop-redis-auth-command", + }, + { + name: "process.env reference value is left alone", + content: "password: process.env.REDIS_PASS", + notWant: "ctxcop-redis-auth-command", + }, + // Structured args / JSON (node-redis error form). + { + name: "node-redis args array without user", + content: join("args: [ 'AUTH', '", token, "' ]"), + want: "ctxcop-redis-auth-array", + }, + { + name: "node-redis JSON array with user", + content: join(`["AUTH","`, user, `","`, token, `"]`), + want: "ctxcop-redis-auth-array", + }, + { + name: "pretty-printed JSON with newline between elements", + content: join("[\"AUTH\",\n \"", token, "\"]"), + want: "ctxcop-redis-auth-array", + }, + { + name: "array value under 8-char floor is left alone", + content: `["AUTH","svc","short"]`, + notWant: "ctxcop-redis-auth-array", + }, + { + name: "array process.env value is left alone", + content: `["AUTH","svc","process.env.REDIS_PASS"]`, + notWant: "ctxcop-redis-auth-array", + }, + // RESP protocol dumps. + { + name: "RESP dump, raw CRLF, no user", + content: join("*2\r\n$4\r\nAUTH\r\n$24\r\n", token, "\r\n"), + want: "ctxcop-redis-auth-resp", + }, + { + name: "RESP dump, raw CRLF, with ACL user", + content: join("*3\r\n$4\r\nAUTH\r\n$3\r\n", user, "\r\n$24\r\n", token, "\r\n"), + want: "ctxcop-redis-auth-resp", + }, + { + name: "RESP dump, escaped CRLF text form", + content: join("*2\\r\\n$4\\r\\nAUTH\\r\\n$24\\r\\n", token, "\\r\\n"), + want: "ctxcop-redis-auth-resp", + }, + { + name: "RESP dump, space-flattened, lowercase command", + content: join("*2 $4 auth $24 ", token, " done"), + want: "ctxcop-redis-auth-resp", + }, + { + // Other RESP commands must stay untouched. + name: "RESP GET dump is left alone", + content: "*2\r\n$3\r\nGET\r\n$24\r\nsome:cache:key:abc12345\r\n", + notWant: "ctxcop-redis-auth-resp", + }, + { + // Port deviation from secrets-scrubber: the RESP rule requires + // the *N array header, so headerless prose dollar-amounts near + // the word auth do not redact. + name: "flattened AUTH without array header is left alone", + content: join("$4 auth $24 ", token, " done"), + notWant: "ctxcop-redis-auth-resp", + }, + // redis-cli flags. + { + name: "redis-cli -a with other flags", + content: join("redis-cli -h host --tls -a ", token, " ping"), + want: "ctxcop-redis-auth-cli", + }, + { + name: "redis-cli --pass quoted", + content: join("redis-cli --pass '", token, "' info"), + want: "ctxcop-redis-auth-cli", + }, + { + name: "redis-cli -a bare", + content: join("redis-cli -a ", token), + want: "ctxcop-redis-auth-cli", + }, + { + name: "redis-cli env-var reference is left alone", + content: "redis-cli -a $REDIS_PASS ping", + notWant: "ctxcop-redis-auth-cli", + }, + { + // Floor guard: a sub-8-char "secret" trips Splice's + // over-redact backstop against unrelated lines carrying the + // same short form. + name: "redis-cli 7-char value under floor is left alone", + content: "redis-cli --pass short12 ping", + notWant: "ctxcop-redis-auth-cli", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fired := firedRules(t, tc.content) + if tc.want != "" && !fired[tc.want] { + t.Fatalf("expected %s to fire; fired=%v", tc.want, keys(fired)) + } + if tc.notWant != "" && fired[tc.notWant] { + t.Fatalf("expected %s NOT to fire; fired=%v", tc.notWant, keys(fired)) + } + }) + } +}