fix: parse quoted content labels without corrupting embedded JSON

This commit is contained in:
2026-10-10 04:47:51 +08:00
parent f3c4ab29aa
commit a66e1118ab
2 changed files with 91 additions and 6 deletions
+68 -6
View File
@@ -2,6 +2,7 @@ package logging
import (
"bytes"
"encoding/json"
"io"
"net/url"
"regexp"
@@ -116,8 +117,26 @@ func redactForm(body string) (string, bool) {
return strings.Join(parts, "&"), true
}
var textFieldLine = regexp.MustCompile(`^\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*[:=]`)
var textFieldLabel = regexp.MustCompile(`\b([A-Za-z_][A-Za-z0-9_.-]*)\s*[:=]`)
// One field lexer for quoted/unquoted labels and diagnostic projections.
// JSON names are decoded, so escapes cannot bypass the content classifier.
const textFieldName = `((?:\\?"(?:\\.|[^"\\\r\n])*\\?")|(?:'(?:\\.|[^'\\\r\n])*')|` + "(?:`[^`\\r\\n]*`)" + `|(?:“[^”\r\n]*”)|(?:‘[^’\r\n]*’)|(?:[\p{L}_][\p{L}\p{N}_.-]*))`
var textFieldLine = regexp.MustCompile(`^\s*` + textFieldName + `\s*[:=]`)
var textFieldLabel = regexp.MustCompile(textFieldName + `\s*[:=]`)
func textFieldKey(token string) (string, bool) {
if strings.HasPrefix(token, `\"`) && strings.HasSuffix(token, `\"`) {
token = `"` + token[2:len(token)-2] + `"`
}
if strings.HasPrefix(token, `"`) {
var key string
if err := json.Unmarshal([]byte(token), &key); err != nil {
return "", false
}
return normalizedField(key), true
}
return normalizedField(token), true
}
var diagnosticUUID = regexp.MustCompile(`^(?:call-)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
@@ -129,7 +148,10 @@ func diagnosticTextLine(line string, contentIndent int) bool {
if len(field) == 0 || len(line)-len(strings.TrimLeft(line, " \t")) > contentIndent {
return false
}
key := normalizedField(line[field[2]:field[3]])
key, valid := textFieldKey(line[field[2]:field[3]])
if !valid {
return false
}
value := strings.TrimSpace(line[field[1]:])
switch key {
case "taskid", "tenantid", "trunkid", "attempts", "remaining", "retryafter", "count", "elapsedms", "durationms":
@@ -152,11 +174,38 @@ func diagnosticTextLine(line string, contentIndent int) bool {
return false
}
// Complete embedded JSON belongs to structural redaction, not the free-text
// lexer. Keep its boundaries so quoted keys cannot discard adjacent diagnostics.
func structuredTextSpans(body string) [][2]int {
var spans [][2]int
for offset := 0; offset < len(body); {
next := strings.IndexAny(body[offset:], "{[")
if next < 0 {
break
}
start := offset + next
dec := json.NewDecoder(strings.NewReader(body[start:]))
var raw json.RawMessage
if err := dec.Decode(&raw); err != nil {
offset = start + 1
continue
}
end := start + int(dec.InputOffset())
spans = append(spans, [2]int{start, end})
offset = end
}
return spans
}
func redactConversationLines(body string) string {
spans := structuredTextSpans(body)
offset := 0
lines := strings.SplitAfter(body, "\n")
hiding := false
contentIndent := 0
for i, line := range lines {
lineOffset := offset
offset += len(line)
// Content owns its full tail, including unknown fields, speaker names,
// nested message metadata and blank paragraphs. Typed diagnostics are a
// projection of individual lines, never an inferred end of the dialogue.
@@ -169,8 +218,19 @@ func redactConversationLines(body string) string {
// Labels establish ownership wherever they occur in an error chain, not
// only at line start. Preserve the preceding actual failure description.
for _, span := range textFieldLabel.FindAllStringSubmatchIndex(line, -1) {
key := line[span[2]:span[3]]
if !sensitive(key) {
position := lineOffset + span[0]
structured := false
for _, region := range spans {
if position >= region[0] && position < region[1] {
structured = true
break
}
}
if structured {
continue
}
key, valid := textFieldKey(line[span[2]:span[3]])
if valid && !sensitive(key) {
continue
}
lines[i] = line[:span[0]] + "[REDACTED_FIELD]"
@@ -178,7 +238,9 @@ func redactConversationLines(body string) string {
lines[i] += "\n"
}
// Credentials own only this line; content owns the following paragraphs.
hiding = conversationField(key)
// Malformed quoted labels have no safely classifiable tail. Keep it
// opaque too, rather than silently exposing a failed name decode.
hiding = !valid || conversationField(key)
contentIndent = len(line) - len(strings.TrimLeft(line, " \t"))
break
}
+23
View File
@@ -61,6 +61,29 @@ func TestContentFieldClassificationAndEveryLoggingPathAgree(t *testing.T) {
}
}
func TestQuotedContentLabelsEstablishOwnershipOnEveryLoggingPath(t *testing.T) {
for _, label := range []string{`"messages"`, `'conversation'`, "`history`", `“prompt”`, `‘messages’`, `\"messages\"`, `"m\u0065ssages"`, `"m\qssages"`} {
value := "upstream failed: " + label + ":\nrole: user\nmessage: private-user-turn\nrole: assistant\nmessage: private-assistant-turn\nstatus=retry_pending\ntask_id=4\nattempts=2"
for _, debug := range []bool{false, true} {
out := installTestLogger(t, debug)
slog.Error("request failed", "error", errors.New(value), "detail", value)
slog.Info(value, "stage", "failure_observed")
TextBody(t.Context(), "HTTP text observed", []byte(value), "transport", "http")
text := out.String()
for _, secret := range []string{"private-user-turn", "private-assistant-turn"} {
if strings.Contains(text, secret) {
t.Fatalf("label %q debug %v leak: %s", label, debug, text)
}
}
for _, keep := range []string{"upstream failed", "status=retry_pending", "task_id=4", "attempts=2"} {
if !strings.Contains(text, keep) {
t.Fatal("diagnostic lost", text)
}
}
}
}
}
func TestUnknownDialogueFieldsCannotEndContentOwnership(t *testing.T) {
for _, dialogue := range []string{
"role: user\nmessage: private-user-turn\nrole: assistant\nmessage: private-assistant-turn",