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

188 lines
5.7 KiB
Go

package logging
import (
"bytes"
"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, "&") || !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*[:=]`)
var textFieldLabel = regexp.MustCompile(`\b([A-Za-z_][A-Za-z0-9_.-]*)\s*[:=]`)
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 := normalizedField(line[field[2]:field[3]])
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
}
func redactConversationLines(body string) string {
lines := strings.SplitAfter(body, "\n")
hiding := false
contentIndent := 0
for i, line := range lines {
// 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) {
key := line[span[2]:span[3]]
if !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.
hiding = conversationField(key)
contentIndent = len(line) - len(strings.TrimLeft(line, " \t"))
break
}
}
return strings.Join(lines, "")
}