返回 DeepSeek-Reasonix
redact.go
根目录 / internal / secrets / redact.go
1 package secrets
2
3 import (
4 "os"
5 "regexp"
6 "strings"
7 "sync"
8 "sync/atomic"
9
10 "reasonix/internal/provider"
11 )
12
13 var (
14 // secretKeyNamePattern matches environment-variable / key names that are
15 // likely to carry credentials. Bare "pwd" is intentionally excluded: it
16 // only counts with a leading separator (DB_PWD, MYSQL-PWD), so the POSIX
17 // PWD / OLDPWD working-directory variables never match.
18 secretKeyNamePattern = regexp.MustCompile(`(?i)((^|[_-])(api[_-]?key|access[_-]?key|private[_-]?key|secret|token|password|passwd)([_-]|$)|[_-]pwd([_-]|$))`)
19 // cookieHeaderPattern captures Cookie/Set-Cookie header values so every
20 // name=value pair gets its value masked; attribute flags without a value
21 // (HttpOnly, Secure) pass through untouched.
22 cookieHeaderPattern = regexp.MustCompile(`(?i)\b((?:set-)?cookie)(\s*[:=]\s*)([^=;\s]+=[^;\s]*(?:;\s*[^=;\s]+(?:=[^;\s]*)?)*)`)
23 cookiePairPattern = regexp.MustCompile(`([^=;\s]+)=([^;\s]*)`)
24 bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+([A-Za-z0-9._~+/=-]{16,})`)
25 openAIKeyPattern = regexp.MustCompile(`\b((?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{12,})\b`)
26 githubTokenPattern = regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b`)
27 slackTokenPattern = regexp.MustCompile(`\b(xox[baprs]-[A-Za-z0-9-]{16,})\b`)
28 awsAccessKeyPattern = regexp.MustCompile(`\b(AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16})\b`)
29 jwtPattern = regexp.MustCompile(`\b(eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b`)
30 // Match through the final @ before a path/whitespace so raw @ characters
31 // inside userinfo cannot leave a password suffix visible.
32 urlUserInfoPattern = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+.-]*://)([^/\s]+)@`)
33
34 // maskedCredentialPattern collapses partially masked credentials and any
35 // visible prefix/suffix around the stars ("****ae54", "sk-ab****").
36 maskedCredentialPattern = regexp.MustCompile(`[A-Za-z0-9._-]*\*{2,}[A-Za-z0-9._-]*`)
37 // credentialContextPattern catches prose forms such as
38 // "api key: relaykey..." that are not KEY=value pairs.
39 credentialContextPattern = regexp.MustCompile(`(?i)\b(api[ _-]?key|access[ _-]?key|secret|token|authorization|bearer|credential)s?\b(['"]?\s*[:=]?\s*['"]?)([A-Za-z0-9._~+/-]{12,})`)
40 // credentialTokenPattern is a conservative fallback for opaque key-shaped
41 // runs. Single-case, digit-free identifiers remain readable.
42 credentialTokenPattern = regexp.MustCompile(`[A-Za-z0-9_-]{16,}`)
43 digitPattern = regexp.MustCompile(`[0-9]`)
44 )
45
46 const redactedValue = "[redacted]"
47
48 // Runtime toggles for the opt-in protection layers, set once by the
49 // composition root from the user-global [secrets] config section. Package
50 // globals are safe here because [secrets] cannot be overridden per-project:
51 // every concurrent workspace in one process shares the same user setting.
52 var (
53 filterSubprocessEnvEnabled atomic.Bool
54 protectSensitiveFilesEnabled atomic.Bool
55 credentialEnvKeys = struct {
56 sync.RWMutex
57 keys map[string]struct{}
58 }{keys: map[string]struct{}{}}
59 )
60
61 // SetFilterSubprocessEnv enables or disables stripping credential-like
62 // variables from tool subprocess environments ([secrets]
63 // filter_subprocess_env).
64 func SetFilterSubprocessEnv(enabled bool) { filterSubprocessEnvEnabled.Store(enabled) }
65
66 // FilterSubprocessEnv reports whether credential-like variables are stripped
67 // from tool subprocess environments. Callers that would launch a command in an
68 // environment they cannot filter (a host-owned terminal, say) must check this
69 // and keep execution local.
70 func FilterSubprocessEnv() bool { return filterSubprocessEnvEnabled.Load() }
71
72 // SetProtectSensitiveFiles enables or disables the built-in credential-path
73 // read denylist for read/list/search tools ([secrets] protect_sensitive_files).
74 func SetProtectSensitiveFiles(enabled bool) { protectSensitiveFilesEnabled.Store(enabled) }
75
76 // ProtectSensitiveFiles reports whether the built-in credential-path read
77 // denylist is active.
78 func ProtectSensitiveFiles() bool { return protectSensitiveFilesEnabled.Load() }
79
80 // RegisterCredentialEnvKeys permanently marks names whose values came from
81 // Reasonix's credential store. Registration is a process-lifetime union so two
82 // concurrent workspaces with different custom providers cannot make each
83 // other's saved keys visible to tools. Explicit per-tool/plugin env config may
84 // still add a value back after ProcessEnv has produced the safe base env.
85 func RegisterCredentialEnvKeys(keys []string) {
86 credentialEnvKeys.Lock()
87 defer credentialEnvKeys.Unlock()
88 for _, key := range keys {
89 if key = credentialEnvKey(key); key != "" {
90 credentialEnvKeys.keys[key] = struct{}{}
91 }
92 }
93 }
94
95 func credentialEnvKey(key string) string {
96 return strings.ToUpper(strings.TrimSpace(key))
97 }
98
99 func registeredCredentialEnvKey(key string) bool {
100 credentialEnvKeys.RLock()
101 defer credentialEnvKeys.RUnlock()
102 _, ok := credentialEnvKeys.keys[credentialEnvKey(key)]
103 return ok
104 }
105
106 // EnvKeySensitive reports whether an environment variable name is likely to
107 // carry credentials. It intentionally keys off the name, not the value, so child
108 // processes do not inherit saved provider secrets when filtering is enabled.
109 func EnvKeySensitive(key string) bool {
110 key = strings.TrimSpace(key)
111 if key == "" {
112 return false
113 }
114 return secretKeyNamePattern.MatchString(key)
115 }
116
117 // FilterEnv removes sensitive KEY=value assignments from an environment vector.
118 func FilterEnv(env []string) []string {
119 out := env[:0]
120 for _, item := range env {
121 key, _, ok := strings.Cut(item, "=")
122 if !ok || EnvKeySensitive(key) || registeredCredentialEnvKey(key) {
123 continue
124 }
125 out = append(out, item)
126 }
127 return out
128 }
129
130 func filterRegisteredCredentialEnv(env []string) []string {
131 out := env[:0]
132 for _, item := range env {
133 key, _, ok := strings.Cut(item, "=")
134 if !ok || registeredCredentialEnvKey(key) {
135 continue
136 }
137 out = append(out, item)
138 }
139 return out
140 }
141
142 // ProcessEnv returns the environment for shell/tool subprocesses. Values loaded
143 // from Reasonix's credential store are always removed. Other credential-like
144 // inherited variables are removed only when the user opted into [secrets]
145 // filter_subprocess_env, preserving existing gh/git/npm workflows by default.
146 func ProcessEnv() []string {
147 if !filterSubprocessEnvEnabled.Load() {
148 return filterRegisteredCredentialEnv(os.Environ())
149 }
150 return FilterEnv(os.Environ())
151 }
152
153 // Redact masks credential-like values for explicit diagnostic, export, and
154 // cleanup paths. Normal model content, tool output, session transcripts, and
155 // background-job artifacts deliberately bypass this helper to retain v0.53's
156 // byte-preserving behavior.
157 func Redact(s string) string {
158 if s == "" {
159 return s
160 }
161 s = urlUserInfoPattern.ReplaceAllString(s, "$1"+redactedValue+"@")
162 s = redactKeyValues(s)
163 s = cookieHeaderPattern.ReplaceAllStringFunc(s, func(match string) string {
164 parts := cookieHeaderPattern.FindStringSubmatch(match)
165 if len(parts) != 4 {
166 return redactedValue
167 }
168 return parts[1] + parts[2] + cookiePairPattern.ReplaceAllString(parts[3], "$1="+redactedValue)
169 })
170 s = bearerTokenPattern.ReplaceAllStringFunc(s, func(match string) string {
171 token := strings.TrimSpace(strings.TrimPrefix(match, "Bearer"))
172 if len(token) == len(match) {
173 return "Bearer " + redactedValue
174 }
175 return "Bearer " + mask(token)
176 })
177 for _, rx := range []*regexp.Regexp{openAIKeyPattern, githubTokenPattern, slackTokenPattern, awsAccessKeyPattern, jwtPattern} {
178 s = rx.ReplaceAllStringFunc(s, mask)
179 }
180 return s
181 }
182
183 // RedactCredentials applies the stronger credential scrub used at external
184 // error and logging boundaries. In addition to known key shapes, it removes
185 // partially masked credentials, prose-form credentials, and opaque tokens that
186 // carry a digit or mixed case.
187 func RedactCredentials(s string) string {
188 if s == "" {
189 return s
190 }
191 s = Redact(s)
192 s = credentialContextPattern.ReplaceAllString(s, "${1}${2}****")
193 s = maskedCredentialPattern.ReplaceAllString(s, "****")
194 return credentialTokenPattern.ReplaceAllStringFunc(s, func(token string) string {
195 mixedCase := strings.ToLower(token) != token && strings.ToUpper(token) != token
196 if digitPattern.MatchString(token) || mixedCase {
197 return "****"
198 }
199 return token
200 })
201 }
202
203 // RedactError returns an error string safe for an external log or diagnostic
204 // boundary. A nil error produces an empty string.
205 func RedactError(err error) string {
206 if err == nil {
207 return ""
208 }
209 return RedactCredentials(err.Error())
210 }
211
212 func redactKeyValues(s string) string {
213 var out strings.Builder
214 last := 0
215 for sep := 0; sep < len(s); sep++ {
216 if s[sep] != ':' && s[sep] != '=' {
217 continue
218 }
219 keyEnd := sep
220 for keyEnd > 0 && asciiSpace(s[keyEnd-1]) {
221 keyEnd--
222 }
223 if keyEnd > 0 && (s[keyEnd-1] == '\'' || s[keyEnd-1] == '"') {
224 keyEnd--
225 }
226 keyStart := keyEnd
227 for keyStart > 0 && credentialKeyByte(s[keyStart-1]) {
228 keyStart--
229 }
230 key := s[keyStart:keyEnd]
231 if !credentialTextKeySensitive(key) {
232 continue
233 }
234
235 valueStart := sep + 1
236 for valueStart < len(s) && asciiSpace(s[valueStart]) {
237 valueStart++
238 }
239 if valueStart < len(s) && (s[valueStart] == '\'' || s[valueStart] == '"') {
240 valueStart++
241 }
242 schemeStart := valueStart
243 for valueStart < len(s) && credentialKeyByte(s[valueStart]) {
244 valueStart++
245 }
246 if valueStart < len(s) && asciiSpace(s[valueStart]) && authorizationScheme(s[schemeStart:valueStart]) {
247 for valueStart < len(s) && asciiSpace(s[valueStart]) {
248 valueStart++
249 }
250 if valueStart < len(s) && (s[valueStart] == '\'' || s[valueStart] == '"') {
251 valueStart++
252 }
253 } else {
254 valueStart = schemeStart
255 }
256
257 valueEnd := valueStart
258 for valueEnd < len(s) && !asciiSpace(s[valueEnd]) && s[valueEnd] != '\'' && s[valueEnd] != '"' && s[valueEnd] != ',' && s[valueEnd] != ';' {
259 valueEnd++
260 }
261 if valueEnd == valueStart {
262 continue
263 }
264 if last == 0 {
265 out.Grow(len(s))
266 }
267 out.WriteString(s[last:valueStart])
268 value := s[valueStart:valueEnd]
269 if authorizationKey(key) {
270 out.WriteString(redactedValue)
271 } else if value == "****" || value == redactedValue {
272 out.WriteString(value)
273 } else {
274 out.WriteString(mask(value))
275 }
276 last = valueEnd
277 sep = valueEnd - 1
278 }
279 if last == 0 {
280 return s
281 }
282 out.WriteString(s[last:])
283 return out.String()
284 }
285
286 func credentialKeyByte(b byte) bool {
287 return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '-' || b == '.'
288 }
289
290 func asciiSpace(b byte) bool {
291 return b == ' ' || b == '\t' || b == '\n' || b == '\r' || b == '\f'
292 }
293
294 func authorizationKey(key string) bool {
295 upper := strings.ToUpper(key)
296 return upper == "AUTHORIZATION" || strings.HasSuffix(upper, "-AUTHORIZATION") || strings.HasSuffix(upper, "_AUTHORIZATION") || strings.HasSuffix(upper, ".AUTHORIZATION")
297 }
298
299 func credentialTextKeySensitive(key string) bool {
300 upper := strings.ToUpper(key)
301 compact := strings.NewReplacer("_", "", "-", "").Replace(upper)
302 return authorizationKey(key) ||
303 strings.Contains(compact, "APIKEY") ||
304 strings.Contains(compact, "ACCESSKEY") ||
305 strings.Contains(compact, "PRIVATEKEY") ||
306 strings.Contains(upper, "SECRET") ||
307 strings.Contains(upper, "TOKEN") ||
308 strings.Contains(upper, "PASSWORD") ||
309 strings.Contains(upper, "PASSWD") ||
310 strings.Contains(upper, "_PWD") ||
311 strings.Contains(upper, "-PWD")
312 }
313
314 func authorizationScheme(s string) bool {
315 switch strings.ToLower(s) {
316 case "bearer", "basic", "digest", "negotiate", "ntlm", "token", "bot", "apikey":
317 return true
318 default:
319 return false
320 }
321 }
322
323 func mask(value string) string {
324 value = strings.TrimSpace(value)
325 if value == "" {
326 return redactedValue
327 }
328 if len(value) <= 12 {
329 return redactedValue
330 }
331 head := 4
332 tail := 4
333 if strings.HasPrefix(value, "sk-") || strings.HasPrefix(value, "rk-") {
334 head = 6
335 }
336 if len(value) <= head+tail {
337 return redactedValue
338 }
339 return value[:head] + strings.Repeat("*", len(value)-head-tail) + value[len(value)-tail:]
340 }
341
342 // RedactMessage returns a storage-safe copy of m with textual secret surfaces
343 // masked. Images are left untouched because they are opaque data URLs.
344 // ToolCalls and MemoryCitations are cloned before masking: m is passed by
345 // value but its slices share backing arrays with the caller, and the save
346 // path hands in live session messages — writing through would silently mutate
347 // the model-visible history mid-conversation and churn the prompt cache.
348 func RedactMessage(m provider.Message) provider.Message {
349 m.Content = Redact(m.Content)
350 m.ReasoningContent = Redact(m.ReasoningContent)
351 m.Original = Redact(m.Original)
352 if len(m.ToolCalls) > 0 {
353 calls := make([]provider.ToolCall, len(m.ToolCalls))
354 copy(calls, m.ToolCalls)
355 for i := range calls {
356 calls[i].Arguments = Redact(calls[i].Arguments)
357 calls[i].Diff = Redact(calls[i].Diff)
358 }
359 m.ToolCalls = calls
360 }
361 if len(m.MemoryCitations) > 0 {
362 cites := make([]provider.MemoryCitation, len(m.MemoryCitations))
363 copy(cites, m.MemoryCitations)
364 for i := range cites {
365 cites[i].Note = Redact(cites[i].Note)
366 }
367 m.MemoryCitations = cites
368 }
369 return m
370 }
371
372 // RedactMessages returns a redacted copy of msgs. The input slice and its
373 // messages are never mutated.
374 func RedactMessages(msgs []provider.Message) []provider.Message {
375 out := make([]provider.Message, len(msgs))
376 for i, m := range msgs {
377 out[i] = RedactMessage(m)
378 }
379 return out
380 }
381
381 lines GO