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