From 2683d985d237153eb0c25355fdb680ced8a641d7 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 10 Oct 2026 02:07:13 +0800 Subject: [PATCH] fix: use one content classifier across all log redaction paths --- internal/logging/redact.go | 26 +++++++++++++++++---- internal/logging/text.go | 10 -------- internal/logging/text_security_test.go | 32 ++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/internal/logging/redact.go b/internal/logging/redact.go index 37c76d6..1f067b3 100644 --- a/internal/logging/redact.go +++ b/internal/logging/redact.go @@ -16,18 +16,36 @@ import ( const redacted = "[REDACTED]" -func sensitive(key string) bool { - normalized := strings.Map(func(r rune) rune { +func normalizedField(key string) string { + return strings.Map(func(r rune) rune { if unicode.IsLetter(r) || unicode.IsDigit(r) { return unicode.ToLower(r) } return -1 }, key) +} + +// Content ownership is defined once for JSON, markup, scalar logs and text +// continuation. It always implies sensitivity; credentials own only values. +func conversationField(key string) bool { + normalized := normalizedField(key) switch normalized { - case "privatekey", "privatekeypem", "clientkey", "clientkeypem", "serverkey", "serverkeypem", "content", "headers", "history", "sentence", "sentences", "words", "openingremark", "openingremarks", "greeting", "sessionnonce", "credential", "credentials", "key", "authorization", "proxyauthorization", "apikey", "accesskeyid", "accesskeysecret", "appkey", "appsecret", "secret", "secretkey", "password", "passwd", "token", "accesstoken", "refreshtoken", "uploadtoken", "providertoken", "sessioncredential", "clientnonce", "servernonce", "cookie", "setcookie", "prompt", "systemprompt", "instructions", "messages", "messagecontent", "transcript", "transcription", "finaltranscript", "text", "utterance", "audio", "audiodata", "recordingdata", "raw", "closingremark", "signature", "targeturl": + case "prompt", "systemprompt", "systemmessage", "instructions", "messages", "conversation", "history", "chathistory", "conversationhistory", "dialogue", "dialog", "transcript", "transcription", "finaltranscript", "messagecontent", "text", "content", "utterance", "sentence", "sentences", "words", "openingremark", "openingremarks", "greeting", "closingremark", "audio", "audiodata", "recordingdata", "raw": return true } - return strings.HasSuffix(normalized, "password") || strings.HasSuffix(normalized, "secret") || strings.HasSuffix(normalized, "apikey") || strings.HasSuffix(normalized, "token") || strings.HasSuffix(normalized, "text") + return strings.HasSuffix(normalized, "text") +} + +func sensitive(key string) bool { + if conversationField(key) { + return true + } + normalized := normalizedField(key) + switch normalized { + case "privatekey", "privatekeypem", "clientkey", "clientkeypem", "serverkey", "serverkeypem", "headers", "sessionnonce", "credential", "credentials", "key", "authorization", "proxyauthorization", "apikey", "accesskeyid", "accesskeysecret", "appkey", "appsecret", "secret", "secretkey", "password", "passwd", "token", "accesstoken", "refreshtoken", "uploadtoken", "providertoken", "sessioncredential", "clientnonce", "servernonce", "cookie", "setcookie", "signature", "targeturl": + return true + } + return strings.HasSuffix(normalized, "password") || strings.HasSuffix(normalized, "secret") || strings.HasSuffix(normalized, "apikey") || strings.HasSuffix(normalized, "token") } type replacement struct { diff --git a/internal/logging/text.go b/internal/logging/text.go index c965c42..d2e5ff9 100644 --- a/internal/logging/text.go +++ b/internal/logging/text.go @@ -116,16 +116,6 @@ func redactForm(body string) (string, bool) { var textFieldLine = regexp.MustCompile(`^\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*[:=]`) -func conversationField(key string) bool { - key = strings.ToLower(strings.NewReplacer("_", "", "-", "", ".", "").Replace(key)) - switch key { - case "prompt", "systemprompt", "systemmessage", "instructions", "messages", "transcript", "transcription", "text", "content", "audio", "closingremark", "conversation": - return true - default: - return false - } -} - func redactConversationLines(body string) string { lines := strings.SplitAfter(body, "\n") hiding := false diff --git a/internal/logging/text_security_test.go b/internal/logging/text_security_test.go index f8d6f29..56b3fee 100644 --- a/internal/logging/text_security_test.go +++ b/internal/logging/text_security_test.go @@ -29,6 +29,38 @@ func TestAssociatedSensitiveControlsHideContentWithoutSwallowingBusiness(t *test } } +func TestContentFieldClassificationAndEveryLoggingPathAgree(t *testing.T) { + fields := []string{"conversation", "history", "messages", "system_message", "final_transcript", "message_content", "utterance", "sentence", "sentences", "words", "opening_remark", "opening_remarks", "greeting", "chat_history", "conversation_history", "dialogue"} + for _, field := range fields { + t.Run(field, func(t *testing.T) { + if !sensitive(field) || !conversationField(field) { + t.Fatalf("content field classifications disagree: %s sensitive=%v continuation=%v", field, sensitive(field), conversationField(field)) + } + textValue := "upstream failed\n" + field + ":\n\nuser: private-user-turn\n\nassistant: private-assistant-turn\n\nstatus=retry_pending\nattempts=2" + jsonValue := `{"` + field + `":[{"role":"user","message":"private-user-turn"},{"role":"assistant","message":"private-assistant-turn"}],"task_id":4,"status":"retry_pending"}` + for _, debug := range []bool{false, true} { + out := installTestLogger(t, debug) + slog.Error("failed", "error", errors.New(textValue), "detail", textValue) + slog.Info(textValue, "stage", "failure_observed") + slog.Error("failed", "error", errors.New("upstream: "+jsonValue), "detail", jsonValue) + TextBody(t.Context(), "HTTP text observed", []byte(textValue), "transport", "http") + Body(t.Context(), "HTTP JSON observed", []byte(jsonValue), "transport", "http") + text := out.String() + for _, secret := range []string{"private-user-turn", "private-assistant-turn"} { + if strings.Contains(text, secret) { + t.Fatalf("%s debug %v leak: %s", field, debug, text) + } + } + for _, keep := range []string{"upstream failed", "retry_pending", "task_id", "attempts=2"} { + if !strings.Contains(text, keep) { + t.Fatal("business diagnostics lost", text) + } + } + } + }) + } +} + func TestConversationOwnershipPersistsAcrossBlankLines(t *testing.T) { for _, gap := range []string{"\n", "\n\n", "\n \n\t\n", "\r\n\r\n"} { for _, label := range []string{"messages", "prompt", "transcript", "content"} {