塔 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。
307 lines
13 KiB
Go
307 lines
13 KiB
Go
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)
|
||
}
|
||
}
|