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 RedactTextLimited(s, maxSummaryRunes) } // RedactTextLimited 是 RedactText 的**带上限**形态:同一套凭据形状清洗,截断长度 // 由调用方给。它存在的理由是两个读面要的不是同一种东西 —— // // - **出图 / 摘要面**(span props、事件正文、工具结果摘要)要的是摘要 ⇒ // `maxSummaryRunes`(80)足够,且保证一屏可读。这些调用方继续用 `RedactText`; // - **日志面**(`GET /admin/observability/logs` 的一行)要的是**可排障的全文** ⇒ // 80 会把长错误行拦腰截断,等于丢掉诊断信息。 // // 但它绝不接受「无上限散用」:一条日志可能是 `%v` 出来的 MiB 级对象,原样塞进 // JSON 响应会撑爆面板的帧与响应体。因此上限**必须由调用方显式给出**并写清理由 // (网关侧见 `maxLogMsgRunes`)。 // // limit <= 0 表示**不截断**(只打码),留给「体积已由自己界定」的调用方。 // // 🔴 清洗逻辑仍然只有一份(redactSecrets):上限是参数,不是第二条脱敏路径 —— // 另写一套必然漂移,而漂移的方向总是「其中一面开始原样吐凭据」。 func RedactTextLimited(s string, limit int) string { return truncate(redactSecrets(s), limit) } // 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 } }