From 02e6ab938061fae360c6e9925d940fc4db27a914 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 10 Oct 2026 01:36:41 +0800 Subject: [PATCH] fix: apply shared conversation protection to scalar logs and named controls --- internal/logging/redact.go | 3 ++ internal/logging/text.go | 28 +++++++++++++++++-- internal/logging/text_security_test.go | 38 ++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/internal/logging/redact.go b/internal/logging/redact.go index b7e8a3f..37c76d6 100644 --- a/internal/logging/redact.go +++ b/internal/logging/redact.go @@ -185,6 +185,9 @@ func redactErrorJSON(s string) string { func cleanString(s string) string { s = privatePEMInText.ReplaceAllString(s, redacted) + // Scalar messages/errors and HTTP text share the same labelled-content + // protection, rather than protecting conversational blocks only in bodies. + s = redactConversationLines(redactMarkup(s)) s = redactErrorJSON(s) s = authHeaderInText.ReplaceAllStringFunc(s, func(header string) string { index := strings.IndexAny(header, ":=") diff --git a/internal/logging/text.go b/internal/logging/text.go index b84887c..0524207 100644 --- a/internal/logging/text.go +++ b/internal/logging/text.go @@ -58,7 +58,10 @@ func redactMarkup(body string) string { } } out.WriteString(tag) - if kind == html.StartTagToken && sensitive(token.Data) { + // A named sensitive field owns both its value attribute and enclosed + // content (textarea/select/custom controls). Void inputs own only value; + // they must never swallow following non-sensitive business elements. + if kind == html.StartTagToken && (sensitive(token.Data) || (hideValue && !voidMarkupElement(token.Data))) { out.WriteString(redacted) hiddenTag = token.Data depth = 1 @@ -70,6 +73,15 @@ func redactMarkup(body string) string { return out.String() } +func voidMarkupElement(name string) bool { + switch name { + case "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr": + return true + default: + return false + } +} + // A form can be identified without guessing semantic values. Untouched pairs // preserve their original encoding and order, including duplicate field names. func redactForm(body string) (string, bool) { @@ -104,6 +116,16 @@ 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 @@ -129,7 +151,9 @@ func redactConversationLines(body string) string { if strings.HasSuffix(line, "\n") { lines[i] += "\n" } - hiding = true + // Credentials/header fields own their single line, not unrelated + // following diagnostics. Only conversational fields own continuation. + hiding = conversationField(field[1]) } } return strings.Join(lines, "") diff --git a/internal/logging/text_security_test.go b/internal/logging/text_security_test.go index dc787d3..958784c 100644 --- a/internal/logging/text_security_test.go +++ b/internal/logging/text_security_test.go @@ -11,6 +11,44 @@ import ( "testing" ) +func TestAssociatedSensitiveControlsHideContentWithoutSwallowingBusiness(t *testing.T) { + body := `

business-before

private-transcript

business-after

` + safe, err := RedactText([]byte(body)) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"private-password", "private-full-prompt", "private-full-dialogue", "private-transcript"} { + if strings.Contains(safe, secret) { + t.Fatalf("leak %s: %s", secret, safe) + } + } + for _, keep := range []string{"business-before", "business-after"} { + if !strings.Contains(safe, keep) { + t.Fatal("non-sensitive markup lost", safe) + } + } +} + +func TestFreeTextConversationIsHiddenInBothModesAndAllScalarPaths(t *testing.T) { + value := "upstream failed\nmessages:\nuser: private-user-turn\nassistant: private-assistant-turn\n\nstatus=retry_pending\nattempts=2" + for _, debug := range []bool{false, true} { + out := installTestLogger(t, debug) + slog.Error("operation failed", "error", errors.New(value), "detail", value) + slog.Info(value, "stage", "failure_observed") + text := out.String() + for _, secret := range []string{"private-user-turn", "private-assistant-turn"} { + if strings.Contains(text, secret) { + t.Fatalf("leak in mode %v: %s", debug, text) + } + } + for _, keep := range []string{"upstream failed", "retry_pending", "attempts=2"} { + if !strings.Contains(text, keep) { + t.Fatal("failure metadata lost", text) + } + } + } +} + func TestFreeTextAuthorizationAndUnlabelledPrivatePEMAreHidden(t *testing.T) { for _, debug := range []bool{false, true} { out := installTestLogger(t, debug)