Files
Cyrene_v2/internal/runtime/trace/write_side_redaction_test.go
T
AskaEth 6d0df6a825 fix(trace): 白名单放行 session —— 归属标识可读,正文边界不动
主人 2026-10-26 裁定:session 是**业务标识**,不是正文。它是排障时把一条 stderr
日志对回某个会话的唯一线索;而 /admin/flow/graph 本来就按会话返回、连 sessionId
都给了,图里多一个 session 不构成新的暴露。因此把 session 加进共享白名单
(safePropKeys,走 redactValue 的普通清洗),不再被写侧折成 Digest。

「不放宽白名单」仍是原则:本次只放行这一个键,正文的边界一个字没动 ——

- trace 侧新增 TestJSONRecorder_KeepsSessionReadableDropsBody,**一条用例同时钉
  两件相反的事**:JSONL 里 `"session":"web#lane-2"` 原文可读;同一 span 里的
  result / text 哨兵正文仍然搜不到,且只在 omitted 里留下体积与指纹。
- loop 侧真循环用例补一句断言:落盘里 `"session":"sess"` 可读,而工具结果哨兵
  同时仍不可见。
- 变异验证:把 session 从白名单摘掉,上述两处都会变红(session 落到 omitted)。

回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
2026-10-26 13:20:45 +08:00

434 lines
17 KiB
Go
Raw 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 (
"bytes"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
)
// 本文件钉住 M40 的**写侧**(stderr JSONL)边界:写下去的每一行都可以被
// `tail`/采集/粘进工单,因此它只允许携带脱敏后的投影 —— 正文、工具结果原文、
// 子代理回复原文、上游响应体原文,一个字节都不许出现。
//
// 🔴 断言一律打在**落盘输出/序列化结果**上(JSONL 字节、slog 输出),
// 不打在 error.Error() 的措辞上:措辞会随实现变,泄漏不会。
// sentinelToolBody 是「工具结果正文」的哨兵:只要它出现在落盘里,就说明写侧
// 又把正文抄了一遍。
const sentinelToolBody = "SENTINEL-TOOL-RESULT-9f3a2b-一段不该落盘的正文"
// writeRecords 把若干 span 记录经 JSONRecorder 写进缓冲区并解码回来。它直接用
// 记录(而不是跑一次真循环)是为了让「写侧投影」这件事在 trace 包内自证;
// 端到端那条链由 loop/orchestration 两个包的测试钉住。
func writeRecords(t *testing.T, recs ...SpanRecord) ([]SpanRecord, string) {
t.Helper()
var buf bytes.Buffer
rec := NewJSONRecorder(&buf)
for _, r := range recs {
if err := rec.WriteSpan(r); err != nil {
t.Fatalf("WriteSpan: %v", err)
}
}
return decodeLines(t, buf.Bytes()), buf.String()
}
// TestJSONRecorder_KeepsSessionReadableDropsBody 是「日志里 session 可读、正文仍
// 不可见」**同一条断言**(主人 2026-10-26 裁定放行 session):
//
// - session 是业务标识、不是正文,进白名单后必须**原文可读**(排障要把一条
// stderr 日志对回某个会话;变成 Digest 就失去用途);
// - 同一 span 里的正文(result / text)仍必须落不到盘上 —— 这一条防的是
// 「顺手放宽了别的东西」:白名单加一个键,正文的边界不能跟着动。
func TestJSONRecorder_KeepsSessionReadableDropsBody(t *testing.T) {
t.Parallel()
const session = "web#lane-2"
rec := spanRec("sess1", "", "tool.fs.read", SpanKindTool, SpanStatusOK, map[string]any{
"session": session,
"result": sentinelToolBody,
"text": sentinelToolBody,
})
recs, raw := writeRecords(t, rec)
if !strings.Contains(raw, `"session":"`+session+`"`) {
t.Fatalf("JSONL 里 session 不可读(它是排障对回会话的唯一线索):%s", raw)
}
if strings.Contains(raw, sentinelToolBody) {
t.Fatalf("放行 session 的同时正文也落盘了:%s", raw)
}
got := byID(recs, "sess1")
if got.Props["session"] != session {
t.Fatalf("session prop = %v, want 原文 %q", got.Props["session"], session)
}
for _, key := range []string{"result", "text"} {
if _, ok := got.Props[key]; ok {
t.Fatalf("表外 prop %q 落盘了:%+v", key, got.Props)
}
if d := omittedEntry(t, got, key); d.SHA256 == "" || d.Bytes == 0 {
t.Fatalf("omitted 里没有 %q 的体积/指纹:%+v", key, d)
}
}
}
// TestJSONRecorder_DropsRawContentAndKeepsDigest 是写侧投影的主钉:
// - 表外的 `result` / `text`(正文形态)绝不落盘;
// - 白名单键仍落盘(图上与日志里要能看见 tool / error 之类的元数据);
// - 被丢弃的键折进 omitted,留下 bytes/runes/sha256 —— 于是「同一次工具调用
// 成功还是失败、输出多大」仍可分辨(这是「脱敏不能把 span 变得没用」的验收点);
// - 字符串值一律过 RedactText(凭据形状打码)。
func TestJSONRecorder_DropsRawContentAndKeepsDigest(t *testing.T) {
t.Parallel()
okRec := spanRec("s1", "", "tool.fs.read", SpanKindTool, SpanStatusOK, map[string]any{
"tool": "fs.read",
"result": sentinelToolBody,
"result_summary": "SENTINEL-TOOL",
"result_digest": DigestOf(sentinelToolBody),
})
badRec := spanRec("s2", "", "tool.fs.read", SpanKindTool, SpanStatusError, map[string]any{
"tool": "fs.read",
"result": strings.Repeat("错", 400),
"error": "read failed: token=abcdef1234567890",
"text": sentinelToolBody,
"subagent": "sa-1",
})
recs, raw := writeRecords(t, okRec, badRec)
if strings.Contains(raw, sentinelToolBody) {
t.Fatalf("写侧 JSONL 里出现了工具结果/子代理回复原文:%s", raw)
}
if strings.Contains(raw, "abcdef1234567890") {
t.Fatalf("写侧 JSONL 里出现了凭据原文(字符串值没走 RedactText):%s", raw)
}
if n := strings.Count(strings.TrimSpace(raw), "\n") + 1; n != 2 {
t.Fatalf("落盘行数 = %d, want 2(一行一个 span): %s", n, raw)
}
okSpan, badSpan := byID(recs, "s1"), byID(recs, "s2")
// 表外键不得以原值出现。
for _, key := range []string{"result", "text"} {
if _, ok := badSpan.Props[key]; ok {
t.Fatalf("表外 prop %q 落盘了:%+v", key, badSpan.Props)
}
}
// 白名单键照旧落盘:tool / result_summary / error / subagent。
if okSpan.Props["tool"] != "fs.read" || okSpan.Props["result_summary"] != "SENTINEL-TOOL" {
t.Fatalf("白名单键没落盘:%+v", okSpan.Props)
}
if _, ok := badSpan.Props["subagent"]; !ok {
t.Fatalf("白名单键 subagent 没落盘:%+v", badSpan.Props)
}
if got, _ := badSpan.Props["error"].(string); !strings.Contains(got, redactedMarker) {
t.Fatalf("error prop = %v,凭据没被打码", badSpan.Props["error"])
}
// 诊断线索:成功/失败可分辨(status + Digest),输出大小可分辨(bytes)。
if okSpan.Status != SpanStatusOK || badSpan.Status != SpanStatusError {
t.Fatalf("status = %q/%q,成功与失败必须仍可分辨", okSpan.Status, badSpan.Status)
}
okDropped := omittedEntry(t, okSpan, "result")
badDropped := omittedEntry(t, badSpan, "result")
if okDropped.Bytes != len(sentinelToolBody) {
t.Fatalf("omitted.result.bytes = %d, want %d(输出大小必须仍可读)", okDropped.Bytes, len(sentinelToolBody))
}
if badDropped.Bytes != len(strings.Repeat("错", 400)) {
t.Fatalf("omitted.result.bytes = %d, want %d", badDropped.Bytes, len(strings.Repeat("错", 400)))
}
if okDropped.SHA256 == "" || okDropped.SHA256 == badDropped.SHA256 {
t.Fatalf("omitted.result.sha256 = %q/%q,want 两份不同内容的指纹不同", okDropped.SHA256, badDropped.SHA256)
}
// Digest 型 prop(生产侧明确声明「这不是正文」)原样落盘,且不再被再摘要一次。
if got, ok := okSpan.Props["result_digest"].(map[string]any); !ok {
t.Fatalf("result_digest 没落盘:%+v", okSpan.Props)
} else if int(got["bytes"].(float64)) != len(sentinelToolBody) {
t.Fatalf("result_digest.bytes = %v, want %d", got["bytes"], len(sentinelToolBody))
}
}
// TestJSONRecorder_SubagentReplyNeverLands 钉住子代理回复这条路径:整段回复
// 曾经以 `text` prop 与 `summary` 事件值的形式原样落盘(M40 修掉的最严重一条)。
// 现在 span 上只有 text_digest,事件上只有 chars/sha256。
func TestJSONRecorder_SubagentReplyNeverLands(t *testing.T) {
t.Parallel()
const reply = "SENTINEL-SUBAGENT-REPLY-77c1-她说的整段话"
rec := spanRec("sa", "", "subagent.sa-1", SpanKindSubagent, SpanStatusOK, map[string]any{
"text": reply,
"text_digest": DigestOf(reply),
})
rec.Events = []Event{{Type: "subagent.done", Props: map[string]any{
"status": "done",
"steps": 2,
"chars": len([]rune(reply)),
"sha256": DigestOf(reply).SHA256,
"text": reply,
}}}
recs, raw := writeRecords(t, rec)
if strings.Contains(raw, reply) {
t.Fatalf("写侧 JSONL 里出现了子代理回复原文:%s", raw)
}
got := byID(recs, "sa")
if _, ok := got.Props["text"]; ok {
t.Fatalf("子代理 span 的 text prop 落盘了:%+v", got.Props)
}
if d, ok := got.Props["text_digest"].(map[string]any); !ok || d["sha256"] == "" {
t.Fatalf("text_digest 缺失:%+v", got.Props)
}
if n := len(got.Events); n != 1 {
t.Fatalf("events = %d, want 1", n)
}
if ev := got.Events[0]; ev.Props["text"] != nil {
// 正文键的值必须已被换成 Digest(键留在原处,值不含正文)。
d, ok := ev.Props["text"].(map[string]any)
if !ok || d["sha256"] == "" {
t.Fatalf("事件里的正文键没折成 Digest:%+v", ev.Props)
}
}
}
// TestUpstreamError_DropsEchoedBody 是共享 helper 的行为钉(M41/M42 的用法模板):
// 一个**回显请求正文**的 422 报文,经 UpstreamError 收敛后,错误文本与日志里都
// 搜不到那段回显,但 status / code / type 仍可辨。
func TestUpstreamError_DropsEchoedBody(t *testing.T) {
t.Parallel()
const echoedInput = "SENTINEL-USER-INPUT-5d2e-用户在聊天框里说的话"
body := []byte(`{"error":{"code":"invalid_request_error","type":"invalid_request",` +
`"message":"Invalid value for 'messages[0].content'."},` +
`"param":"messages[0].content","input":"` + echoedInput + `",` +
`"echo":{"messages":[{"role":"user","content":"` + echoedInput + `"}]}}`)
err := UpstreamError("api.openai.example", 422, "application/json; charset=utf-8", body)
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, nil))
log.Error("upstream call failed", "err", err)
text := err.Error()
for _, surface := range []string{text, buf.String()} {
if strings.Contains(surface, echoedInput) {
t.Fatalf("响应体回显的输入原样出现了:%s", surface)
}
if strings.Contains(surface, "messages[0].content") && strings.Contains(surface, "role") {
t.Fatalf("响应体的非白名单字段被带出来了:%s", surface)
}
}
for _, want := range []string{"status=422", "invalid_request_error", "type=invalid_request", "api.openai.example"} {
if !strings.Contains(text, want) {
t.Fatalf("error = %q,want 含 %q(诊断信息不能一起丢掉)", text, want)
}
}
if !errors.Is(err, ErrUpstream) {
t.Fatalf("errors.Is(err, ErrUpstream) = false;降级链无法按类别判断")
}
info, ok := err.(*UpstreamErrorInfo)
if !ok {
t.Fatalf("err 类型 = %T, want *UpstreamErrorInfo", err)
}
if info.BodyLen != len(body) || info.ContentType != "application/json" || info.BodySHA256 == "" {
t.Fatalf("info = %+v,want bodyLen/contentType/sha256 作为诊断线索", info)
}
// Attrs() 是日志侧的第二出口,必须与 Error() 同源(同样不含正文)。
attrs := info.Attrs()
if joined := strings.TrimSpace(strings.Join(anyToStrings(attrs), " ")); strings.Contains(joined, echoedInput) {
t.Fatalf("Attrs() 带出了回显正文:%s", joined)
}
}
// TestUpstreamError_NonJSONBodyKeepsOnlyMeta 钉住「非 JSON 也绝不回落原文」:
// HTML 错误页 / 代理错误页 / 被截断的报文只留 status 与体积指纹。
func TestUpstreamError_NonJSONBodyKeepsOnlyMeta(t *testing.T) {
t.Parallel()
const echoed = "SENTINEL-PROXY-ECHO-0b71"
body := []byte("<html><body>upstream said: " + echoed + "</body></html>")
err := UpstreamError("", 502, "text/html", body)
if strings.Contains(err.Error(), echoed) {
t.Fatalf("非 JSON 响应体原文进了错误:%s", err.Error())
}
if !strings.Contains(err.Error(), "status=502") || !strings.Contains(err.Error(), "body_bytes=") {
t.Fatalf("error = %q,want status 与 body_bytes 仍在", err.Error())
}
}
// TestUpstreamErrorMessageIsBounded 钉住 message 的上限:上游把一大段东西写进
// error.message 时,进错误的只有前 128 rune。
func TestUpstreamErrorMessageIsBounded(t *testing.T) {
t.Parallel()
body := []byte(`{"error":{"code":"bad","message":"` + strings.Repeat("详", 400) + `"}}`)
err := UpstreamError("p", 400, "application/json", body)
info := err.(*UpstreamErrorInfo)
if n := len([]rune(info.Message)); n > maxUpstreamMessageRunes+1 {
t.Fatalf("message 长度 = %d rune,超过上限 %d", n, maxUpstreamMessageRunes)
}
if !strings.HasPrefix(info.Message, "详") {
t.Fatalf("message = %q,want 保留上游诊断语的前缀", info.Message)
}
}
// TestHostOf_DropsPathQueryAndUserInfo 钉住「日志里的 URL 只有来源」。
func TestHostOf_DropsPathQueryAndUserInfo(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
{"普通 URL", "https://cdn.example.com/a/b?token=SECRET&x=1", "https://cdn.example.com"},
{"带端口", "http://api.example.com:8443/v1/chat", "http://api.example.com:8443"},
{"带 user:pass", "https://user:pass@h/x", "https://[redacted]@h"},
{"相对路径", "/a/b?token=SECRET", ""},
{"畸形串", "http://[::1", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := HostOf(tc.in)
if got != tc.want {
t.Fatalf("HostOf(%q) = %q, want %q", tc.in, got, tc.want)
}
for _, leak := range []string{"SECRET", "pass", "user", "/a/"} {
if strings.Contains(got, leak) {
t.Fatalf("HostOf(%q) = %q 泄漏了 %q", tc.in, got, leak)
}
}
})
}
}
// TestMaskQuery_MasksCredentialParams 钉住「查询串里的凭据值被打码」:OAuth token、
// 预签名 URL 的签名、API key 一律不明文出现,其余参数保持可读。
func TestMaskQuery_MasksCredentialParams(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
wantHas []string
leaks []string
}{
{
name: "access_token 打码",
in: "https://api.example.com/v1/x?access_token=xxxSECRETxxx&page=2",
wantHas: []string{"access_token=***", "page=2"},
leaks: []string{"xxxSECRETxxx"},
},
{
name: "user:pass 整体打码",
in: "https://user:pass@h/x?api_key=k3ySECRET&sig=SIGSECRET",
wantHas: []string{"[redacted]@h", "api_key=***"},
leaks: []string{"pass", "k3ySECRET", "SIGSECRET"},
},
{
name: "片段里的 token 同样打码",
in: "https://h/cb#access_token=FRAGSECRET&state=ok",
wantHas: []string{"access_token=***", "state=ok"},
leaks: []string{"FRAGSECRET"},
},
{
name: "非 URL 不回原串",
in: "token=RAW&x=1",
leaks: []string{"RAW"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := MaskQuery(tc.in)
for _, want := range tc.wantHas {
if !strings.Contains(got, want) {
t.Fatalf("MaskQuery(%q) = %q, want 含 %q", tc.in, got, want)
}
}
for _, leak := range tc.leaks {
if strings.Contains(got, leak) {
t.Fatalf("MaskQuery(%q) = %q 泄漏了 %q", tc.in, got, leak)
}
}
})
}
}
// TestJSONRecorder_DoesNotMutateCallerRecord 钉住投影是「拷贝」而非「就地改」:
// 调用方(例如 gateway 的 trace 环形缓冲出口)仍拿得到完整记录。
func TestJSONRecorder_DoesNotMutateCallerRecord(t *testing.T) {
t.Parallel()
rec := spanRec("m1", "", "tool.x", SpanKindTool, SpanStatusOK, map[string]any{"result": sentinelToolBody})
if _, raw := writeRecords(t, rec); strings.Contains(raw, sentinelToolBody) {
t.Fatalf("写侧泄漏了原文:%s", raw)
}
if got, _ := rec.Props["result"].(string); got != sentinelToolBody {
t.Fatalf("调用方记录被就地改写了:%+v", rec.Props)
}
}
// TestSanitizeRecordKeepsIdentity 钉住「脱敏不动身份」:id/parent/name/kind/status
// 与时间戳原样保留,否则落盘的链路就串不起来了。
func TestSanitizeRecord_KeepsIdentity(t *testing.T) {
t.Parallel()
rec := spanRec("c1", "p1", "llm.step2", SpanKindLLM, SpanStatusError, map[string]any{"prompt": "正文"})
out := SanitizeRecord(rec)
if out.ID != rec.ID || out.ParentID != rec.ParentID || out.Name != rec.Name ||
out.Kind != rec.Kind || out.Status != rec.Status ||
!out.StartedAt.Equal(rec.StartedAt) || !out.EndedAt.Equal(rec.EndedAt) {
t.Fatalf("sanitizeRecord 改动了身份字段:%+v vs %+v", out, rec)
}
if _, ok := out.Props["prompt"]; ok {
t.Fatalf("表外 prop prompt 落盘了:%+v", out.Props)
}
if _, err := json.Marshal(out); err != nil {
t.Fatalf("脱敏后的记录不可编码:%v", err)
}
}
// byID 在解码后的记录里按 id 找一条,找不到即失败。
func byID(recs []SpanRecord, id string) SpanRecord {
for _, r := range recs {
if r.ID == id {
return r
}
}
panic("span record not found: " + id)
}
// omittedEntry 反读某条记录 omitted 表里被丢弃的键的 Digest。
func omittedEntry(t *testing.T, rec SpanRecord, key string) Digest {
t.Helper()
omitted, ok := rec.Props[omittedPropKey].(map[string]any)
if !ok {
t.Fatalf("props 里没有 %q 表:%+v", omittedPropKey, rec.Props)
}
entry, ok := omitted[key].(map[string]any)
if !ok {
t.Fatalf("%s 表里没有 %q 的 Digest:%+v", omittedPropKey, key, omitted)
}
return Digest{
Bytes: int(entry["bytes"].(float64)),
Runes: int(entry["runes"].(float64)),
SHA256: entry["sha256"].(string),
}
}
// anyToStrings 把 Attrs() 的键值对压成字符串切片,便于做「搜不到」断言。
func anyToStrings(attrs []any) []string {
out := make([]string, 0, len(attrs))
for _, a := range attrs {
out = append(out, strings.Join(strings.Fields(toString(a)), " "))
}
return out
}
// toString 渲染一个 Attr 值(只用于测试断言)。
func toString(v any) string {
switch x := v.(type) {
case string:
return x
case nil:
return ""
default:
b, err := json.Marshal(x)
if err != nil {
return ""
}
return string(b)
}
}