Files
AskaEth 3157abcf3c fix(gateway): 日志页脱敏改用「可带上限」变体(4096),摘要侧仍是 80
塔 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。
2026-10-26 13:58:02 +08:00

176 lines
6.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}