塔 2026-10-26 复审裁定:复用 trace.RedactText 让面板日志页每条 msg 都被切到 80 rune,是实质性 UX 回归(日志页就是排障面)。修法不是网关侧另写脱敏,而是 在 trace 包给上限加一个参数。 - trace/redact.go:新增导出 `RedactTextLimited(s, limit)`,语义 = 同一套凭据形状 清洗 + 按调用方给的上限截断;`RedactText` 改为它的 `maxSummaryRunes`(80) 调用形态,既有调用方零改动,清洗逻辑仍只有一份(redactSecrets)。 limit <= 0 = 不截断,留给体积已自己界定的调用方。 - observability_logs.go:新增常量 `maxLogMsgRunes = 4096`(含注释说明为什么不是 80、也不是无上限:单条日志可能是 %v 的 MiB 级对象,1000 条会撑爆 JSON), adminLogOf 改用 `RedactTextLimited(e.Msg, maxLogMsgRunes)`。 - span props / 摘要侧不动,仍是 80(一个要摘要,一个要全文,两边不该被统一)。 测试(均验过变异打红): - TestAdminObservabilityLogs_LongLineKeepsFullText:547 rune 的日志行(行首凭据 + 行尾哨兵)⇒ 凭据打码、末尾哨兵仍在、rune 数只差「凭据→[redacted]」那一处; 变异(改回 RedactText 80)⇒ 红:拿到 81 rune、末尾哨兵消失。 - TestAdminObservabilityTrace_PropsStayAtSummaryBound:200 rune 的 args_summary ⇒ 81 rune(80 + 省略号);变异(把 maxSummaryRunes 提到 4096)⇒ 红。 验收:go build ./... / go vet ./... 全绿;go test -count=1 -race ./internal/services/gateway/... ./internal/runtime/trace/... 全绿。未 push。
176 lines
6.5 KiB
Go
176 lines
6.5 KiB
Go
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
|
||
}
|
||
}
|