Files
Cyrene_v2/internal/services/gateway/observability_redact_test.go
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

307 lines
13 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 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)
}
}