fix: use one content classifier across all log redaction paths

This commit is contained in:
2026-10-10 02:07:13 +08:00
parent 6c4ca28a24
commit 2683d985d2
3 changed files with 54 additions and 14 deletions
+22 -4
View File
@@ -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 {
-10
View File
@@ -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
+32
View File
@@ -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"} {