fix: apply shared conversation protection to scalar logs and named controls
This commit is contained in:
@@ -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, ":=")
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -11,6 +11,44 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssociatedSensitiveControlsHideContentWithoutSwallowingBusiness(t *testing.T) {
|
||||
body := `<form><input name="password" value="private-password"><p>business-before</p><textarea name="prompt">private-full-prompt</textarea><select name="messages"><option>private-full-dialogue</option></select><div name="transcript">private-transcript</div><p>business-after</p></form>`
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user