package gateway // 本文件钉住**可观测面板读侧的脱敏**(M42 #3 = M38 #1 的读侧放大面 / M39 遗留)。 // // 写侧(stderr 的 JSONL)在 M39 已经有了 `trace.SanitizeRecord` / `trace.RedactText`; // 但面板这两条读路径当时仍原样搬运: // // - 日志页 `GET /api/v1/admin/observability/logs`:`buf.Snapshot` → 原样出图; // - trace 页 `GET /api/v1/admin/observability/trace`:环形缓冲持有**原始** props, // 原样出图。 // // 两条都在这里补上「读侧也走同一个 helper」——**不另写一套脱敏**:一套脱敏两处 // 实现必然漂移,而漂移的方向总是「其中一面开始原样吐凭据」。 // // 🔴 同时保住**排障字段**:日志的行数/级别/时间戳/来源,trace 的 status/耗时/时间/ // 名字都必须原样透出。脱敏只动「内容面」(msg 正文、props 值),不动结构 —— // 否则面板就变成不可诊断的空壳(这条是塔的明确要求)。 // // 每条用例都能被一条变异打红:把读侧的 `trace.RedactText` / `trace.SanitizeRecord` // 摘掉,哨兵与凭据就会原样出现在响应体里。 import ( "encoding/json" "io" "net/http" "strings" "testing" "time" "unicode/utf8" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // obsLeakKey 是 `sk-` 形状的假凭据:trace 包的形状规则(`\bsk-[A-Za-z0-9_\-]{8,}`) // 认它,因此它出现在响应体里就一定意味着读侧没过脱敏。 const obsLeakKey = "sk-live0000000000000000ABCD" // obsLeakPrefix 是断言用的前缀(比整串更严格:打码后不该留下任何一段)。 const obsLeakPrefix = "sk-live0000" // obsLeakBody 是哨兵正文:它没有凭据形状,只能靠「白名单/正文键」判据拦下 —— // 因此它抓的是「正文原样出图」,与 obsLeakKey 抓的「凭据原样出图」互不替代。 const obsLeakBody = "SENTINEL_BODY_PAYLOAD_42" // obsLogServer 构建一个注入了真实 logger 环形缓冲的网关。 func obsLogServer(t *testing.T) (base, tok string) { t.Helper() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) ring := logger.NewRingBuffer(16) log := logger.New(logger.Options{Level: "debug", Format: "text", Out: io.Discard, Ring: ring}) log.Info("boot ok") // 最新在前:错误放最后,因此它是 Data[0]。 logger.WithSource(log, "gateway/model").Error("upstream rejected " + obsLeakKey + " with 401") srv.SetLogBuffer(ring) return ts.URL, signAny(t, jwt, "acct", auth.RoleOwner) } // TestAdminObservabilityLogs_RedactsCredentialInMsg 钉住日志页读侧打码: // 凭据形状被换成 `[redacted]`,而同一条 msg 的非敏感部分、以及 level/source/time // 这些排障字段原样保留(一行的可诊断性不能被脱敏吃掉)。 func TestAdminObservabilityLogs_RedactsCredentialInMsg(t *testing.T) { t.Parallel() base, tok := obsLogServer(t) resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } if strings.Contains(string(body), obsLeakPrefix) { t.Fatalf("日志页回显了凭据(%q):%s", obsLeakPrefix, body) } var env logEnvelope if err := json.Unmarshal(body, &env); err != nil { t.Fatalf("decode: %v (body=%s)", err, body) } if len(env.Data) != 2 { t.Fatalf("日志条数 = %d, want 2(脱敏不许改变条目数)", len(env.Data)) } got := env.Data[0] if !strings.Contains(got.Msg, "[redacted]") { t.Fatalf("凭据位置应换成 [redacted],msg = %q", got.Msg) } // 排障字段与非敏感正文必须保住。 if got.Level != "error" || got.Source != "gateway/model" { t.Fatalf("level/source 被改动:%+v", got) } if _, err := time.Parse(time.RFC3339, got.Time); err != nil { t.Fatalf("time 不是 RFC3339:%q (%v)", got.Time, err) } if !strings.Contains(got.Msg, "upstream rejected") || !strings.Contains(got.Msg, "with 401") { t.Fatalf("msg 的非敏感部分被脱敏吃掉:%q", got.Msg) } if env.Data[1].Msg != "boot ok" || env.Data[1].Level != "info" { t.Fatalf("无关条目被改动:%+v", env.Data[1]) } } // obsTraceServer 构建一个网关并把一条**含正文与凭据**的 span 送进 trace 环形 // 缓冲(模拟生产:ObserveSpan 收的是 tracer 的原始记录)。 func obsTraceServer(t *testing.T) (base, tok string) { t.Helper() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) started := time.Date(2026, 10, 26, 3, 4, 5, 0, time.UTC) srv.ObserveSpan(trace.SpanRecord{ ID: "span-42", Name: "tool.execution", Kind: trace.SpanKindTool, Status: trace.SpanStatusOK, StartedAt: started, EndedAt: started.Add(25 * time.Millisecond), Duration: 25 * time.Millisecond, Props: map[string]any{ // 白名单键:保留,但值里的凭据必须打码。 "tool": "web_search", "args_summary": "query=天气 " + obsLeakKey, // 表外正文键:折成 omitted 里的 Digest(留指纹、去正文)。 "messages": obsLeakBody, "result": obsLeakBody, }, Events: []trace.Event{{ Time: started.Add(time.Millisecond), Type: "tool.done", Props: map[string]any{"text": obsLeakBody, "ok": true}, }}, }) return ts.URL, signAny(t, jwt, "acct", auth.RoleOwner) } // TestAdminObservabilityTrace_RedactsPropsAndEvents 钉住 trace 页读侧投影: // props 与事件里的正文/凭据都出不来,而 span 的 status/时间/耗时/名字逐字保留。 // // ⚠️ 这条用例同时是「有意改变 admin 响应内容」的证据(见 adminSpanOf 注释): // props 从原始值变成白名单投影 + omitted 摘要,客户端不得依赖未声明的 props。 func TestAdminObservabilityTrace_RedactsPropsAndEvents(t *testing.T) { t.Parallel() base, tok := obsTraceServer(t) resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/trace", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } if strings.Contains(string(body), obsLeakBody) { t.Fatalf("trace 页回显了正文哨兵(%q):%s", obsLeakBody, body) } if strings.Contains(string(body), obsLeakPrefix) { t.Fatalf("trace 页回显了凭据(%q):%s", obsLeakPrefix, body) } var env struct { Data []AdminTraceSpan `json:"data"` } if err := json.Unmarshal(body, &env); err != nil { t.Fatalf("decode: %v (body=%s)", err, body) } if len(env.Data) != 1 { t.Fatalf("span 条数 = %d, want 1", len(env.Data)) } sp := env.Data[0] // 排障字段:status / 时间 / 耗时 / 名字 / kind 一个都不许动。 if sp.ID != "span-42" || sp.Name != "tool.execution" || sp.Kind != "tool_call" || sp.Status != "ok" || sp.DurationNs != int64(25*time.Millisecond) { t.Fatalf("span 的排障字段被改动:%+v", sp) } if sp.StartedAt != "2026-10-26T03:04:05Z" || sp.EndedAt != "2026-10-26T03:04:05Z" { t.Fatalf("时间字段被改动:startedAt=%q endedAt=%q", sp.StartedAt, sp.EndedAt) } // 白名单键保留(面板靠它分组),但值里的凭据已打码。 if tool, _ := sp.Props["tool"].(string); tool != "web_search" { t.Fatalf("白名单 props.tool = %v, want web_search(脱敏不许把排障键也丢掉)", sp.Props["tool"]) } summary, _ := sp.Props["args_summary"].(string) if !strings.Contains(summary, "[redacted]") || strings.Contains(summary, obsLeakPrefix) { t.Fatalf("白名单键的值没打码:args_summary = %q", summary) } // 表外正文键折成 omitted 里的 Digest:留键名(可判断「这次带了什么」),不留正文。 omitted, ok := sp.Props["omitted"].(map[string]any) if !ok { t.Fatalf("表外正文键应折成 props.omitted,实际 props = %+v", sp.Props) } for _, k := range []string{"messages", "result"} { if _, present := omitted[k]; !present { t.Fatalf("omitted 缺键 %q:%+v", k, omitted) } } // 事件正文键折成 Digest,非正文键(ok)保留。 if len(sp.Events) != 1 { t.Fatalf("events = %+v, want 1 条", sp.Events) } if _, leaked := sp.Events[0].Props["text"].(string); leaked { t.Fatalf("事件正文键仍是字符串(未折成 Digest):%+v", sp.Events[0].Props) } if ok, _ := sp.Events[0].Props["ok"].(bool); !ok { t.Fatalf("事件非正文键 ok 应原样保留:%+v", sp.Events[0].Props) } } // obsLogTailMarker 是长日志行**末尾**的哨兵:按 80 rune 截断必然把它切掉,按 // 4096(`maxLogMsgRunes`)必然留下它 —— 它就是「这一行有没有被拦腰截断」的判据。 const obsLogTailMarker = "TAIL_OF_LONG_LOG_LINE" // longLogMsg 造一条 ~500 rune 的日志行:凭据放在**行首**(所以 80 rune 截断也能 // 打码成功,红的是「截断」这一条而不是「没打码」),末尾带哨兵 —— 一旦被切到 80, // 哨兵必然消失,判据因此指向问题本身。 // 「排障上下文一行 」是 8 rune,×60 = 480 rune ⇒ 远超 80 的摘要上限。 func longLogMsg() string { return "upstream rejected " + obsLeakKey + " " + strings.Repeat("排障上下文一行 ", 60) + obsLogTailMarker } // TestAdminObservabilityLogs_LongLineKeepsFullText 钉住日志页的**上限与出图不同** // (M42 r2,塔 2026-10-26 裁定):日志页是排障面,一条 500 rune 的错误行必须完整 // 保留(凭据仍然打码),不能被 `maxSummaryRunes`(80)拦腰截断。 // // 变异:把 `adminLogOf` 改回 `trace.RedactText`(80)⇒ 本用例必红(末尾哨兵消失、 // rune 数掉到 81)。 func TestAdminObservabilityLogs_LongLineKeepsFullText(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) ring := logger.NewRingBuffer(8) log := logger.New(logger.Options{Level: "debug", Format: "text", Out: io.Discard, Ring: ring}) msg := longLogMsg() if utf8.RuneCountInString(msg) <= maxLogMsgRunes/8 { t.Fatalf("夹具只有 %d rune,不足以证明「不被 80 截断」", utf8.RuneCountInString(msg)) } log.Error(msg) srv.SetLogBuffer(ring) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var env logEnvelope if err := json.Unmarshal(body, &env); err != nil { t.Fatalf("decode: %v (body=%s)", err, body) } if len(env.Data) != 1 { t.Fatalf("日志条数 = %d, want 1", len(env.Data)) } got := env.Data[0].Msg if strings.Contains(got, obsLeakPrefix) { t.Fatalf("长行里的凭据没打码(%q):%q", obsLeakPrefix, got) } if !strings.Contains(got, "[redacted]") { t.Fatalf("长行里的凭据位置应换成 [redacted]:%q", got) } if !strings.Contains(got, obsLogTailMarker) { t.Fatalf("日志页把 %d rune 的行截断了(末尾哨兵 %q 消失),拿到 %d rune:…%s", utf8.RuneCountInString(msg), obsLogTailMarker, utf8.RuneCountInString(got), string([]rune(got)[max(0, utf8.RuneCountInString(got)-20):])) } // 长度只允许差「凭据 → [redacted]」那一处替换,不许多掉任何东西。 wantLen := utf8.RuneCountInString(msg) - utf8.RuneCountInString(obsLeakKey) + utf8.RuneCountInString("[redacted]") if n := utf8.RuneCountInString(got); n != wantLen { t.Fatalf("日志行 rune 数 = %d, want %d(只允许打码带来的差额,不许截断)", n, wantLen) } } // TestAdminObservabilityTrace_PropsStayAtSummaryBound 是上一条的**对偶**:日志页 // 的上限调大到 4096 之后,出图这一侧(span props)不许被顺手统一 —— 那里要的是 // 摘要(`maxSummaryRunes` = 80),不是一个更长的全文。 // // 变异:把 `maxSummaryRunes` 提到 4096(或让出图侧改用日志上限)⇒ 本用例必红。 func TestAdminObservabilityTrace_PropsStayAtSummaryBound(t *testing.T) { t.Parallel() srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) started := time.Date(2026, 10, 26, 3, 4, 5, 0, time.UTC) long := strings.Repeat("摘", 200) srv.ObserveSpan(trace.SpanRecord{ ID: "span-bound", Name: "tool.execution", Kind: trace.SpanKindTool, Status: trace.SpanStatusOK, StartedAt: started, EndedAt: started.Add(time.Millisecond), Duration: time.Millisecond, Props: map[string]any{"tool": "web_search", "args_summary": long}, }) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/trace", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var env struct { Data []AdminTraceSpan `json:"data"` } if err := json.Unmarshal(body, &env); err != nil { t.Fatalf("decode: %v (body=%s)", err, body) } if len(env.Data) != 1 { t.Fatalf("span 条数 = %d, want 1", len(env.Data)) } summary, _ := env.Data[0].Props["args_summary"].(string) // 80 是 trace 包的摘要上限(`maxSummaryRunes`,未导出 ⇒ 这里按契约写常量)。 const summaryBound = 80 if n := utf8.RuneCountInString(summary); n != summaryBound+1 { t.Fatalf("args_summary rune 数 = %d, want %d(%d rune 摘要 + 省略号)—— "+ "出图侧不许跟日志页一起放宽到 %d", n, summaryBound+1, summaryBound, maxLogMsgRunes) } if !strings.HasSuffix(summary, "…") { t.Fatalf("截断结果应以省略号收尾:%q", summary) } }