返回 DeepSeek-Reasonix
redact_test.go
根目录 / internal / secrets / redact_test.go
1 package secrets
2
3 import (
4 "errors"
5 "fmt"
6 "strings"
7 "sync"
8 "testing"
9
10 "reasonix/internal/provider"
11 )
12
13 func TestRedactMasksCommonSecretShapes(t *testing.T) {
14 in := strings.Join([]string{
15 "DEEPSEEK_API_KEY=sk-real-secret-value-123456",
16 "Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz",
17 "token xoxb-123456789012-abcdefabcdef",
18 "jwt eyJabc.def.ghi",
19 }, "\n")
20
21 got := Redact(in)
22 for _, leaked := range []string{
23 "sk-real-secret-value-123456",
24 "ghp_abcdefghijklmnopqrstuvwxyz",
25 "xoxb-123456789012-abcdefabcdef",
26 "eyJabc.def.ghi",
27 } {
28 if strings.Contains(got, leaked) {
29 t.Fatalf("secret leaked %q in:\n%s", leaked, got)
30 }
31 }
32 for _, want := range []string{"DEEPSEEK_API_KEY=sk-rea", "Authorization: Bearer [redacted]"} {
33 if !strings.Contains(got, want) {
34 t.Fatalf("redacted output missing %q:\n%s", want, got)
35 }
36 }
37 }
38
39 func TestRedactLongConcurrentTranscriptAvoidsRegexpBacktracking(t *testing.T) {
40 const secret = "sk-real-secret-value-1234567890"
41 var transcript strings.Builder
42 for i := range 2_000 {
43 fmt.Fprintf(&transcript, "message %d payload=%s DEEPSEEK_API_KEY=%s Authorization: Bearer %s\n", i, strings.Repeat("x", i%31), secret, secret)
44 }
45 input := transcript.String()
46
47 const workers = 24
48 const iterations = 20
49 var wg sync.WaitGroup
50 errs := make(chan string, workers)
51 for range workers {
52 wg.Go(func() {
53 for range iterations {
54 got := Redact(input)
55 if strings.Contains(got, secret) {
56 errs <- "long concurrent redaction leaked the test secret"
57 return
58 }
59 if again := Redact(got); again != got {
60 errs <- "long concurrent redaction was not idempotent"
61 return
62 }
63 }
64 })
65 }
66 wg.Wait()
67 close(errs)
68 for err := range errs {
69 t.Error(err)
70 }
71 }
72
73 func TestRedactMasksJSONQuotedKeys(t *testing.T) {
74 in := `http 401: {"access_token":"sk-live-secret","x-api-key":"header-secret","password":"pw-secret"}`
75 got := Redact(in)
76 for _, leaked := range []string{"sk-live-secret", "header-secret", "pw-secret"} {
77 if strings.Contains(got, leaked) {
78 t.Fatalf("JSON credential leaked %q in:\n%s", leaked, got)
79 }
80 }
81 if !strings.Contains(got, `"access_token":"`) || !strings.Contains(got, "http 401") {
82 t.Fatalf("non-secret structure mangled:\n%s", got)
83 }
84 if again := Redact(got); again != got {
85 t.Fatalf("JSON redaction not idempotent:\nonce: %q\ntwice: %q", got, again)
86 }
87 }
88
89 func TestRedactMasksCookieHeaderValues(t *testing.T) {
90 in := "Cookie: session=cookie-secret\nSet-Cookie: sid=abc123def456; Path=/; HttpOnly"
91 got := Redact(in)
92 for _, leaked := range []string{"cookie-secret", "abc123def456"} {
93 if strings.Contains(got, leaked) {
94 t.Fatalf("cookie value leaked %q in:\n%s", leaked, got)
95 }
96 }
97 for _, want := range []string{"Cookie: session=[redacted]", "Set-Cookie: sid=[redacted]", "HttpOnly"} {
98 if !strings.Contains(got, want) {
99 t.Fatalf("redacted output missing %q:\n%s", want, got)
100 }
101 }
102 if again := Redact(got); again != got {
103 t.Fatalf("cookie redaction not idempotent:\nonce: %q\ntwice: %q", got, again)
104 }
105 }
106
107 func TestRedactMasksNonBearerAuthorizationSchemes(t *testing.T) {
108 in := strings.Join([]string{
109 "Authorization: Basic dXNlcjpwYXNzd29yZA==",
110 "Proxy-Authorization: Digest username-hash-abcdef0123456789",
111 "Authorization: dXNlcjpwYXNzd29yZC1yYXc=",
112 }, "\n")
113 got := Redact(in)
114 for _, leaked := range []string{"dXNlcjpwYXNzd29yZA==", "username-hash-abcdef0123456789", "dXNlcjpwYXNzd29yZC1yYXc="} {
115 if strings.Contains(got, leaked) {
116 t.Fatalf("authorization credential leaked %q:\n%s", leaked, got)
117 }
118 }
119 for _, want := range []string{"Authorization: Basic [redacted]", "Digest [redacted]", "Authorization: [redacted]"} {
120 if !strings.Contains(got, want) {
121 t.Fatalf("redacted output missing %q:\n%s", want, got)
122 }
123 }
124 if again := Redact(got); again != got {
125 t.Fatalf("authorization redaction not idempotent:\nonce: %q\ntwice: %q", got, again)
126 }
127 }
128
129 func TestRedactMasksURLUserInfo(t *testing.T) {
130 in := "proxy request failed: https://proxy-user:pa@ss@proxy.example.com:8443/connect"
131 got := Redact(in)
132 for _, leaked := range []string{"proxy-user", "pa", "ss"} {
133 if strings.Contains(got, leaked) {
134 t.Fatalf("URL credential leaked %q in:\n%s", leaked, got)
135 }
136 }
137 for _, want := range []string{"https://[redacted]@proxy.example.com:8443/connect", "proxy request failed"} {
138 if !strings.Contains(got, want) {
139 t.Fatalf("redacted output missing %q:\n%s", want, got)
140 }
141 }
142 if again := Redact(got); again != got {
143 t.Fatalf("URL redaction not idempotent:\nonce: %q\ntwice: %q", got, again)
144 }
145 }
146
147 func TestRedactCredentialsForExternalErrors(t *testing.T) {
148 tests := []struct {
149 name string
150 err error
151 leaked []string
152 want string
153 }{
154 {
155 name: "prose api key",
156 err: errors.New("provider rejected api key: relaykey_abcdefghijklmn"),
157 leaked: []string{"relaykey_abcdefghijklmn"},
158 want: "provider rejected api key:",
159 },
160 {
161 name: "partially masked token",
162 err: errors.New("provider rejected token ****ae54"),
163 leaked: []string{"ae54"},
164 want: "provider rejected token",
165 },
166 {
167 name: "bearer token",
168 err: errors.New("upstream returned Authorization: Bearer abcdef0123456789abcdef"),
169 leaked: []string{"abcdef0123456789abcdef"},
170 want: "upstream returned",
171 },
172 {
173 name: "proxy URL user info",
174 err: errors.New("dial https://proxy-user:pa@ss@proxy.example.com:8443: refused"),
175 leaked: []string{"proxy-user", "pa", "ss"},
176 want: "proxy.example.com:8443",
177 },
178 {
179 name: "key value is idempotent",
180 err: errors.New("provider rejected DEEPSEEK_API_KEY=sk-real-secret-value-123456"),
181 leaked: []string{"sk-real-secret-value-123456"},
182 want: "provider rejected DEEPSEEK_API_KEY=",
183 },
184 {
185 name: "opaque mixed case token",
186 err: errors.New("credential relayKeyAbcdefghijkl rejected"),
187 leaked: []string{"relayKeyAbcdefghijkl"},
188 want: "credential",
189 },
190 }
191 for _, tt := range tests {
192 t.Run(tt.name, func(t *testing.T) {
193 got := RedactError(tt.err)
194 for _, leaked := range tt.leaked {
195 if strings.Contains(got, leaked) {
196 t.Fatalf("credential leaked %q in %q", leaked, got)
197 }
198 }
199 if !strings.Contains(got, tt.want) {
200 t.Fatalf("diagnostic context missing %q in %q", tt.want, got)
201 }
202 if again := RedactCredentials(got); again != got {
203 t.Fatalf("external error redaction not idempotent:\nonce: %q\ntwice: %q", got, again)
204 }
205 })
206 }
207 if got := RedactError(nil); got != "" {
208 t.Fatalf("RedactError(nil) = %q, want empty", got)
209 }
210 }
211
212 func TestRedactIsIdempotent(t *testing.T) {
213 // The session save path re-redacts loaded (already-redacted) transcripts;
214 // digest stability across load/save cycles requires a byte-for-byte no-op.
215 in := strings.Join([]string{
216 "DEEPSEEK_API_KEY=sk-real-secret-value-123456",
217 "Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz",
218 "DB_PWD='hunter2-swordfish'",
219 "plain text with PWD=/home/user/project untouched",
220 }, "\n")
221 once := Redact(in)
222 twice := Redact(once)
223 if once != twice {
224 t.Fatalf("Redact not idempotent:\nonce: %q\ntwice: %q", once, twice)
225 }
226 }
227
228 func TestRedactLeavesWorkingDirectoryPWDAlone(t *testing.T) {
229 in := "PWD=/home/user/project\nOLDPWD=/home/user\nDB_PWD=hunter2-swordfish-123"
230 got := Redact(in)
231 if !strings.Contains(got, "PWD=/home/user/project") {
232 t.Fatalf("POSIX PWD variable was mangled:\n%s", got)
233 }
234 if !strings.Contains(got, "OLDPWD=/home/user") {
235 t.Fatalf("OLDPWD was mangled:\n%s", got)
236 }
237 if strings.Contains(got, "hunter2-swordfish-123") {
238 t.Fatalf("DB_PWD value leaked:\n%s", got)
239 }
240 }
241
242 func TestEnvKeySensitive(t *testing.T) {
243 sensitive := []string{"DEEPSEEK_API_KEY", "GH_TOKEN", "AWS_SECRET_ACCESS_KEY", "DB_PASSWORD", "MYSQL_PWD", "NPM_TOKEN"}
244 for _, key := range sensitive {
245 if !EnvKeySensitive(key) {
246 t.Errorf("EnvKeySensitive(%q) = false, want true", key)
247 }
248 }
249 benign := []string{"PWD", "OLDPWD", "PATH", "HOME", "LANG", "GOPATH", "TERM"}
250 for _, key := range benign {
251 if EnvKeySensitive(key) {
252 t.Errorf("EnvKeySensitive(%q) = true, want false", key)
253 }
254 }
255 }
256
257 func TestFilterEnvDropsSensitiveKeys(t *testing.T) {
258 got := FilterEnv([]string{
259 "PATH=/usr/bin",
260 "DEEPSEEK_API_KEY=sk-real-secret-value-123456",
261 "GH_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz",
262 "PWD=/home/user/project",
263 "HOME=/tmp/home",
264 })
265 joined := strings.Join(got, "\n")
266 if strings.Contains(joined, "DEEPSEEK_API_KEY") || strings.Contains(joined, "GH_TOKEN") {
267 t.Fatalf("sensitive env survived:\n%s", joined)
268 }
269 for _, want := range []string{"PATH=/usr/bin", "HOME=/tmp/home", "PWD=/home/user/project"} {
270 if !strings.Contains(joined, want) {
271 t.Fatalf("non-sensitive env %q dropped:\n%s", want, joined)
272 }
273 }
274 }
275
276 func TestProcessEnvUnfilteredByDefault(t *testing.T) {
277 t.Setenv("REASONIX_TEST_SECRET_TOKEN", "ghp_abcdefghijklmnopqrstuvwxyz")
278 joined := strings.Join(ProcessEnv(), "\n")
279 if !strings.Contains(joined, "REASONIX_TEST_SECRET_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz") {
280 t.Fatalf("ProcessEnv filtered by default; filter_subprocess_env must be opt-in:\n%s", joined)
281 }
282
283 SetFilterSubprocessEnv(true)
284 t.Cleanup(func() { SetFilterSubprocessEnv(false) })
285 joined = strings.Join(ProcessEnv(), "\n")
286 if strings.Contains(joined, "REASONIX_TEST_SECRET_TOKEN") {
287 t.Fatalf("ProcessEnv leaked sensitive key with filtering enabled:\n%s", joined)
288 }
289 }
290
291 func TestProcessEnvAlwaysFiltersRegisteredCredentialKeys(t *testing.T) {
292 const key = "REASONIX_TEST_CUSTOM_PROVIDER_CREDENTIAL"
293 t.Setenv(key, "opaque-provider-value")
294 t.Setenv("REASONIX_TEST_BENIGN_ENV", "visible")
295 RegisterCredentialEnvKeys([]string{key})
296
297 joined := strings.Join(ProcessEnv(), "\n")
298 if strings.Contains(joined, key+"=") || strings.Contains(joined, "opaque-provider-value") {
299 t.Fatalf("registered provider credential survived in subprocess env:\n%s", joined)
300 }
301 if !strings.Contains(joined, "REASONIX_TEST_BENIGN_ENV=visible") {
302 t.Fatalf("ordinary env was removed with opt-in filtering off:\n%s", joined)
303 }
304 }
305
306 func TestRedactMessagesDoesNotMutateInput(t *testing.T) {
307 const secret = "sk-real-secret-value-123456"
308 msgs := []provider.Message{
309 {
310 Role: provider.RoleAssistant,
311 Content: "checking",
312 ToolCalls: []provider.ToolCall{
313 {ID: "call_1", Name: "bash", Arguments: `{"command":"echo DEEPSEEK_API_KEY=` + secret + `"}`},
314 },
315 MemoryCitations: []provider.MemoryCitation{{Note: "token " + secret}},
316 },
317 {Role: provider.RoleTool, ToolCallID: "call_1", Content: "DEEPSEEK_API_KEY=" + secret},
318 }
319
320 out := RedactMessages(msgs)
321
322 // The redacted copy must not carry the raw secret...
323 if strings.Contains(out[0].ToolCalls[0].Arguments, secret) || strings.Contains(out[1].Content, secret) {
324 t.Fatalf("redacted copy leaked secret: %+v", out)
325 }
326 // ...and the input — live session history the model still replays — must
327 // be untouched, including through the shared ToolCalls/MemoryCitations
328 // backing arrays.
329 if !strings.Contains(msgs[0].ToolCalls[0].Arguments, secret) {
330 t.Fatalf("RedactMessages mutated the caller's ToolCalls: %q", msgs[0].ToolCalls[0].Arguments)
331 }
332 if !strings.Contains(msgs[0].MemoryCitations[0].Note, secret) {
333 t.Fatalf("RedactMessages mutated the caller's MemoryCitations: %q", msgs[0].MemoryCitations[0].Note)
334 }
335 if !strings.Contains(msgs[1].Content, secret) {
336 t.Fatalf("RedactMessages mutated the caller's Content: %q", msgs[1].Content)
337 }
338 }
339
339 lines GO