Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions THREATMODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion docs/detection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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] <password>` 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
Expand Down
85 changes: 85 additions & 0 deletions internal/rules/embedded/ctxcop.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)\\."
])
'''
152 changes: 152 additions & 0 deletions internal/rules/redis_auth_rules_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
})
}
}
Loading