diff --git a/internal/logging/text.go b/internal/logging/text.go index 5f88aea..357efb4 100644 --- a/internal/logging/text.go +++ b/internal/logging/text.go @@ -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 } diff --git a/internal/logging/text_security_test.go b/internal/logging/text_security_test.go index 10bab30..0ec2221 100644 --- a/internal/logging/text_security_test.go +++ b/internal/logging/text_security_test.go @@ -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",