package trace import ( "encoding/json" "strings" "testing" "time" ) // TestRedactTextScrubsCredentials asserts every credential shape the dashboard // could be handed is replaced, while ordinary text passes through unchanged. func TestRedactTextScrubsCredentials(t *testing.T) { t.Parallel() secrets := []string{ "api_key=sk-abcdef0123456789", `{"token":"abcdef1234567890"}`, "password=hunter2hunter2", "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc.def", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4", "access_token: ghp_0123456789abcdefghij", } for _, in := range secrets { got := RedactText(in) if !strings.Contains(got, redactedMarker) { t.Fatalf("RedactText(%q) = %q, want the redaction marker", in, got) } for _, leak := range []string{"sk-abcdef0123456789", "hunter2hunter2", "abcdef1234567890", "eyJhbGciOiJIUzI1NiJ9", "ghp_0123456789abcdefghij"} { if strings.Contains(got, leak) { t.Fatalf("RedactText(%q) = %q, still carries %q", in, got, leak) } } } benign := "the tool returned 3 rows; stop_reason=tool_calls" if got := RedactText(benign); got != benign { t.Fatalf("RedactText(%q) = %q, want it unchanged", benign, got) } if got := truncate("", maxSummaryRunes); got != "" { t.Fatalf("empty text = %q, want empty", got) } long := strings.Repeat("x", maxSummaryRunes+10) if got := RedactText(long); !strings.HasSuffix(got, "…") || len([]rune(got)) != maxSummaryRunes+1 { t.Fatalf("long text was not truncated to the summary bound: %d runes", len([]rune(got))) } } // TestRedactEventScrubsNestedValues asserts a secret nested in a map or a slice is // scrubbed too — the shapes a tool result or error payload actually has. func TestRedactEventScrubsNestedValues(t *testing.T) { t.Parallel() ev := Event{Type: "tool.done", Props: map[string]any{ "tool": "http", "headers": map[string]any{ "Authorization": "Bearer sk-live-0123456789abcdef", }, "args_keys": []string{"api_key", "url"}, "nested": []any{map[string]any{"password": "hunter2hunter2"}}, "count": 2, }} got := RedactEvent(ev) if got.Type != ev.Type || got.Time != ev.Time { t.Fatalf("event envelope changed: %+v", got) } blob, err := json.Marshal(got) if err != nil { t.Fatalf("marshal: %v", err) } for _, leak := range []string{"sk-live-0123456789abcdef", "hunter2hunter2"} { if strings.Contains(string(blob), leak) { t.Fatalf("redacted event still carries %q: %s", leak, blob) } } if count, ok := got.Props["count"].(int); !ok || count != 2 { t.Fatalf("non-string prop = %v, want it passed through", got.Props["count"]) } // The original event must not be mutated (a caller may still need it). if orig, _ := ev.Props["headers"].(map[string]any)["Authorization"].(string); !strings.Contains(orig, "sk-live-") { t.Fatalf("RedactEvent mutated the caller's event: %v", orig) } } // TestNodePropsAndLogAreRedacted asserts the published graph — both a tool node's // result_summary prop and its log events — never carries a credential. This is the // design/19 §3 red line at the last point before publication. // // M40 之后 result_summary 是**生产侧**写入的已脱敏摘要(loop/recordToolResult), // 读侧只做白名单投影,不再从结果原文反推。因此这里同时钉住两件事:白名单键 // 的值仍被逐层清洗;而任何「结果原文」形态的 prop(表外的 result)根本不出图, // 哪怕某个生产者又把它塞了回来。 func TestNodePropsAndLogAreRedacted(t *testing.T) { t.Parallel() rec := spanRec("t1", "step1", "tool.http", SpanKindTool, SpanStatusOK, map[string]any{ "result_summary": `{"api_key":"sk-abcdef0123456789"}`, "result": map[string]any{"body": `{"api_key":"sk-abcdef0123456789"}`}, }) rec.Events = []Event{{ Type: "tool.done", Props: map[string]any{ "tool": "http", "args_keys": []string{"api_key"}, "result_summary": `{"api_key":"sk-abcdef0123456789"}`, }, }} g := graphFor([]SpanRecord{rec}, "token=abcdef1234567890", "reply") node := nodeByID(g, "t1") if len(node.Log) != 1 || node.Log[0].Type != "tool.done" { t.Fatalf("node log = %+v, want the recorded event", node.Log) } blob, err := json.Marshal(g) if err != nil { t.Fatalf("marshal graph: %v", err) } if strings.Contains(string(blob), "sk-abcdef0123456789") { t.Fatalf("published graph leaked the tool result: %s", blob) } if !strings.Contains(string(blob), redactedMarker) { t.Fatalf("published graph carries no redaction marker: %s", blob) } if got, ok := node.Props["result_summary"].(string); !ok || !strings.Contains(got, redactedMarker) { t.Fatalf("result_summary prop = %v, want it scrubbed", node.Props["result_summary"]) } if _, ok := node.Props["result"]; ok { t.Fatalf("表外 prop result 出图了:%+v(读侧只投影白名单)", node.Props) } // The round's own input/output labels are scrubbed as well. if in := nodeByID(g, "input").Label; strings.Contains(in, "abcdef1234567890") { t.Fatalf("input label leaked a credential: %q", in) } } // TestRedactEventKeepsEmptyPropsNil guards the wire shape: an event without props // must not gain an empty map. func TestRedactEventKeepsEmptyPropsNil(t *testing.T) { t.Parallel() ev := Event{Type: "llm.done", Time: time.Now()} if got := RedactEvent(ev); got.Props != nil { t.Fatalf("props = %v, want nil", got.Props) } }