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, "") }