fix: parse quoted content labels without corrupting embedded JSON
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user