148 lines
4.2 KiB
Go
148 lines
4.2 KiB
Go
package logging
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
// The HTML tokenizer also accepts XML-shaped response fragments. Raw token
|
|
// bytes are retained; only explicitly sensitive tags/attributes are replaced.
|
|
func redactMarkup(body string) string {
|
|
if !strings.Contains(body, "<") {
|
|
return body
|
|
}
|
|
z := html.NewTokenizer(strings.NewReader(body))
|
|
var out strings.Builder
|
|
hiddenTag := ""
|
|
depth := 0
|
|
for {
|
|
kind := z.Next()
|
|
raw := bytes.Clone(z.Raw())
|
|
if kind == html.ErrorToken {
|
|
if z.Err() == io.EOF {
|
|
out.Write(raw)
|
|
}
|
|
break
|
|
}
|
|
token := z.Token()
|
|
if hiddenTag != "" {
|
|
if kind == html.StartTagToken && token.Data == hiddenTag {
|
|
depth++
|
|
}
|
|
if kind == html.EndTagToken && token.Data == hiddenTag {
|
|
depth--
|
|
if depth == 0 {
|
|
hiddenTag = ""
|
|
out.Write(raw)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if kind == html.StartTagToken || kind == html.SelfClosingTagToken {
|
|
tag := string(raw)
|
|
hideValue := false
|
|
for _, a := range token.Attr {
|
|
if (a.Key == "name" && sensitive(a.Val)) || (a.Key == "type" && strings.EqualFold(a.Val, "password")) {
|
|
hideValue = true
|
|
}
|
|
}
|
|
for _, a := range token.Attr {
|
|
if sensitive(a.Key) || (hideValue && a.Key == "value") {
|
|
pattern := regexp.MustCompile(`(?i)(\b` + regexp.QuoteMeta(a.Key) + `\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s>]+)`)
|
|
tag = pattern.ReplaceAllString(tag, `${1}"[REDACTED]"`)
|
|
}
|
|
}
|
|
out.WriteString(tag)
|
|
// 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
|
|
}
|
|
} else {
|
|
out.Write(raw)
|
|
}
|
|
}
|
|
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) {
|
|
if strings.ContainsAny(body, "\r\n<>") || !strings.Contains(body, "&") || !strings.Contains(body, "=") {
|
|
return body, false
|
|
}
|
|
parts := strings.Split(body, "&")
|
|
for i, part := range parts {
|
|
key, value, ok := strings.Cut(part, "=")
|
|
if !ok {
|
|
return body, false
|
|
}
|
|
decodedKey, err := url.QueryUnescape(key)
|
|
if err != nil {
|
|
return body, false
|
|
}
|
|
if sensitive(decodedKey) {
|
|
parts[i] = key + "=" + url.QueryEscape(redacted)
|
|
continue
|
|
}
|
|
decoded, err := url.QueryUnescape(value)
|
|
if err != nil {
|
|
return body, false
|
|
}
|
|
safe := cleanString(redactConversationLines(decoded))
|
|
if safe != decoded {
|
|
parts[i] = key + "=" + url.QueryEscape(safe)
|
|
}
|
|
}
|
|
return strings.Join(parts, "&"), true
|
|
}
|
|
|
|
var textFieldLine = regexp.MustCompile(`^\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*[:=]`)
|
|
|
|
func redactConversationLines(body string) string {
|
|
lines := strings.SplitAfter(body, "\n")
|
|
hiding := false
|
|
for i, line := range lines {
|
|
// Whitespace separates paragraphs inside a conversation, not records.
|
|
// Ownership ends only at the explicit non-conversation field below.
|
|
field := textFieldLine.FindStringSubmatch(line)
|
|
if hiding {
|
|
// Continuation lines and conversation speaker labels are not business
|
|
// fields. A distinct non-conversation key starts the next ordinary field.
|
|
speaker := len(field) > 0 && (strings.EqualFold(field[1], "user") || strings.EqualFold(field[1], "assistant") || strings.EqualFold(field[1], "system") || strings.EqualFold(field[1], "agent"))
|
|
if len(field) == 0 || speaker {
|
|
lines[i] = ""
|
|
continue
|
|
}
|
|
hiding = false
|
|
}
|
|
if len(field) > 0 && sensitive(field[1]) {
|
|
lines[i] = "[REDACTED_FIELD]"
|
|
if strings.HasSuffix(line, "\n") {
|
|
lines[i] += "\n"
|
|
}
|
|
// Credentials/header fields own their single line, not unrelated
|
|
// following diagnostics. Only conversational fields own continuation.
|
|
hiding = conversationField(field[1])
|
|
}
|
|
}
|
|
return strings.Join(lines, "")
|
|
}
|