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"} {