package trace import ( "regexp" "strings" ) // Redaction for everything the flow graph publishes (design/19 §3 红线: 不泄露敏感 —— // 工具参数/内容脱敏;design/11 密钥不落日志). The graph is relayed to the admin panel // and every connected client, so every string that reaches it — span props and // span log events alike — is scrubbed and truncated here. Deciding to redact at // publish time (rather than only at record time) means a runtime that records a // raw tool result still cannot leak it. // redactedMarker replaces anything that looks like a credential. const redactedMarker = "[redacted]" // secretPatterns match credential shapes that must never reach the dashboard: an // API-key style assignment (plain or JSON), a standalone provider key, a bearer // token, and a JWT. They are deliberately shallow (shape, not semantics): a false // positive only over-redacts a dashboard string, while a false negative publishes a // live credential. var secretPatterns = []*regexp.Regexp{ regexp.MustCompile(`(?i)\b[a-z0-9_]*(?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)[a-z0-9_]*"?\s*[:=]\s*"?[^\s"',;}\]]{6,}`), regexp.MustCompile(`\bsk-[A-Za-z0-9_\-]{8,}`), regexp.MustCompile(`(?i)\bbearer\s+[A-Za-z0-9_\-\.=]{10,}`), regexp.MustCompile(`\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+`), } // secretKeyNames are prop names whose value is a credential whatever its shape, so // a structured payload that names its secret ({api_key: "..."}) is redacted even // when the value itself looks like ordinary text. Keys are compared after // lower-casing and normalizing -/. to _. var secretKeyNames = map[string]bool{ "api_key": true, "apikey": true, "api_token": true, "access_token": true, "auth_token": true, "authorization": true, "token": true, "secret": true, "client_secret": true, "password": true, "passwd": true, "pwd": true, "credential": true, "credentials": true, "private_key": true, "session_key": true, "raw_api_key": true, "x_api_key": true, "bearer": true, } // secretKeySuffixes catch prefixed variants such as openai_api_key. var secretKeySuffixes = []string{"_api_key", "_access_token", "_auth_token", "_secret", "_token"} // IsSecretKey 报告某个 map 键是否命名了一个凭据。它是「密钥类键」的唯一判据: // 出图前的清洗与参数摘要的生产者(loop/spanlog.go,裁决 11.4.3「密钥类参数 // 一律不显示值」)共用它,因此生产侧与最后一道防线不可能判得不一致。 func IsSecretKey(key string) bool { k := strings.NewReplacer("-", "_", ".", "_").Replace(strings.ToLower(strings.TrimSpace(key))) if secretKeyNames[k] { return true } for _, suffix := range secretKeySuffixes { if strings.HasSuffix(k, suffix) { return true } } return false } // RedactText scrubs credential-looking substrings from s and truncates the result // to maxSummaryRunes, so a published string can never carry a secret or an // unbounded blob. func RedactText(s string) string { return truncate(redactSecrets(s), maxSummaryRunes) } // RedactEvent returns ev with every string value — including those nested in maps // and slices, and including any value under a credential-named key — scrubbed and // truncated. Timestamps, types and non-string scalars pass through unchanged. func RedactEvent(ev Event) Event { out := Event{Time: ev.Time, Type: ev.Type} if ev.Props == nil { return out } out.Props = redactProps(ev.Props) return out } // redactProps scrubs a property map, applying the key-name rule at the top level // as well as the shape rule to each value. func redactProps(props map[string]any) map[string]any { out := make(map[string]any, len(props)) for k, v := range props { if IsSecretKey(k) { out[k] = redactedMarker continue } out[k] = redactValue(v) } return out } // redactEvents returns a redacted copy of events for publication. A nil or empty // slice yields nil so a node carries no empty log array. func redactEvents(events []Event) []Event { if len(events) == 0 { return nil } out := make([]Event, len(events)) for i, ev := range events { out[i] = RedactEvent(ev) } return out } // redactSecrets replaces every credential-shaped substring with the marker. func redactSecrets(s string) string { if s == "" { return s } for _, re := range secretPatterns { s = re.ReplaceAllString(s, redactedMarker) } return s } // redactValue scrubs one prop/event value: a string is redacted and truncated, a // map or slice is copied and redacted recursively (with credential-named keys // replaced outright), and any other value (number, bool, time) passes through // unchanged. func redactValue(v any) any { switch x := v.(type) { case string: return RedactText(x) case map[string]any: return redactProps(x) case []any: out := make([]any, len(x)) for i, item := range x { out[i] = redactValue(item) } return out case []string: out := make([]any, len(x)) for i, item := range x { out[i] = RedactText(item) } return out case map[string]string: // 参数摘要以 map[string]string 生产时也必须逐键清洗, // 否则密钥会绕过键名规则原样出图(裁决 11.4.3 红线)。 out := make(map[string]any, len(x)) for k, v := range x { if IsSecretKey(k) { out[k] = redactedMarker continue } out[k] = RedactText(v) } return out default: return v } }