Files
go-sip/internal/logging/text.go
T

255 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package logging
import (
"bytes"
"encoding/json"
"io"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"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, "=") {
return body, false
}
parts := strings.Split(body, "&")
for i, part := range parts {
key, value, hasValue := strings.Cut(part, "=")
decodedKey, err := url.QueryUnescape(key)
if err != nil {
// A failed field decode must not restore earlier sensitive pairs or
// expose the unclassifiable field. Keep the failure explicit in the copy.
parts[i] = url.QueryEscape("[REDACTED_INVALID_FORM_KEY]")
continue
}
if sensitive(decodedKey) {
parts[i] = key + "=" + url.QueryEscape(redacted)
continue
}
// Bare flags are valid empty-value form fields, not a parse failure.
if !hasValue {
continue
}
decoded, err := url.QueryUnescape(value)
if err != nil {
parts[i] = key + "=" + url.QueryEscape("[REDACTED_INVALID_FORM_VALUE]")
continue
}
safe := cleanString(redactConversationLines(decoded))
if safe != decoded {
parts[i] = key + "=" + url.QueryEscape(safe)
}
}
return strings.Join(parts, "&"), true
}
// 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}$`)
// Free text has no reliable closing delimiter for content. Project only typed
// diagnostic scalars from its tail; those never release content ownership.
// Arbitrary field values and message schemas remain opaque, not guessed safe.
func diagnosticTextLine(line string, contentIndent int) bool {
field := textFieldLine.FindStringSubmatchIndex(line)
if len(field) == 0 || len(line)-len(strings.TrimLeft(line, " \t")) > contentIndent {
return false
}
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":
_, err := strconv.ParseUint(value, 10, 64)
return err == nil
case "httpstatus":
n, err := strconv.ParseUint(value, 10, 16)
return err == nil && n >= 100 && n <= 599
case "eventid", "executionid":
return diagnosticUUID.MatchString(value)
case "nextat":
_, err := time.Parse(time.RFC3339Nano, value)
return err == nil
case "status":
switch value {
case "ready", "retry_pending", "pending", "running", "paused", "stopped", "finished", "rejected", "ended", "failed", "unknown", "accepted", "completed", "succeeded", "waiting", "active", "inactive":
return true
}
}
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.
if hiding {
if !diagnosticTextLine(line, contentIndent) {
lines[i] = ""
}
continue
}
// 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) {
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]"
if strings.HasSuffix(line, "\n") {
lines[i] += "\n"
}
// Credentials own only this line; content owns the following paragraphs.
// 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
}
}
return strings.Join(lines, "")
}