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