package sanitize import ( "bytes" "strings" "git.mchus.pro/mchus/logpile/internal/privacy" ) // Span categories that get rewritten. fru_location is intentionally excluded // (often a serial / manufacturing code, low value, and may live in a binary // FRU area); it is only reported by the scan, never edited. var redactableCat = map[string]bool{ "domain": true, "mgmt_subdomain": true, "resolv": true, "ad_ldap": true, "email": true, "nsupdate": true, "collector": true, "dhcp": true, "cert": true, "public_ip": true, "timezone": true, } type change struct { path string category string before string after string } // redactText rewrites every redactable span in content, keeping each // replacement byte-for-byte the same length as the original so the total // content length never changes. Line terminators are preserved exactly. func redactText(content []byte, certFile bool) ([]byte, []change) { var out bytes.Buffer out.Grow(len(content)) var changes []change for _, s := range splitKeepEOL(content) { spans := redactableSpans(s.line, certFile) if len(spans) == 0 { out.WriteString(s.line) out.Write(s.eol) continue } prev := 0 for _, sp := range spans { out.WriteString(s.line[prev:sp.Start]) orig := s.line[sp.Start:sp.End] repl := fillerFor(sp.Category, orig) if len(repl) != len(orig) { repl = xFill(orig) } out.WriteString(repl) changes = append(changes, change{category: sp.Category, before: orig, after: repl}) prev = sp.End } out.WriteString(s.line[prev:]) out.Write(s.eol) } return out.Bytes(), changes } // redactableSpans returns the redactable spans of one line, sorted by start and // with overlaps merged (so a nested domain inside an e-mail is redacted once). func redactableSpans(line string, certFile bool) []privacy.Span { raw := privacy.FindSpans(line, certFile) kept := raw[:0] for _, sp := range raw { if redactableCat[sp.Category] { kept = append(kept, sp) } } if len(kept) < 2 { return kept } sortSpans(kept) merged := kept[:1] for _, sp := range kept[1:] { last := &merged[len(merged)-1] if sp.Start <= last.End { if sp.End > last.End { last.End = sp.End } continue } merged = append(merged, sp) } return merged } func sortSpans(s []privacy.Span) { for i := 1; i < len(s); i++ { for j := i; j > 0 && s[j-1].Start > s[j].Start; j-- { s[j-1], s[j] = s[j], s[j-1] } } } // fillerFor returns a same-length neutral replacement for one matched token. func fillerFor(category, orig string) string { switch category { case "public_ip": return digitZero(orig) // 93.184.216.34 -> 00.000.000.00 case "timezone": return tzFiller(orig) default: return xFill(orig) // sigma.sbrf.ru -> xxxxx.xxxx.xx } } // xFill replaces every ASCII letter/digit with 'x', keeping punctuation // (dots, hyphens, '@', ':', '*', '_') in place. func xFill(s string) string { b := []byte(s) for i, c := range b { if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { b[i] = 'x' } } return string(b) } // digitZero replaces every digit with '0', keeping dots. func digitZero(s string) string { b := []byte(s) for i, c := range b { if c >= '0' && c <= '9' { b[i] = '0' } } return string(b) } // tzFiller neutralises a timezone value: a numeric UTC offset becomes zeros, a // Region/City name becomes a same-length valid IANA zone, an abbreviation // becomes "UTC" (len 3) or an x-fill. func tzFiller(orig string) string { if isOffset(orig) { return digitZero(orig) // 180 -> 000, -300 -> -000 } if strings.Contains(orig, "/") { if z, ok := neutralZoneByLen[len(orig)]; ok { return z } return keepSlashXFill(orig) } if len(orig) == 3 { return "UTC" } return xFill(orig) } func isOffset(s string) bool { if s == "" { return false } for i, c := range s { if c >= '0' && c <= '9' { continue } if (c == '+' || c == '-') && i == 0 { continue } return false } return true } func keepSlashXFill(s string) string { b := []byte(xFill(s)) for i, c := range []byte(s) { if c == '/' { b[i] = '/' } } return string(b) } // neutralZoneByLen maps a timezone-name length to a same-length, valid IANA // zone that carries no regional information (UTC/Etc/* where the length allows, // otherwise a fixed far-away decoy). Every value here is on the privacy // allowlist so a re-scan of the sanitized file stays clean. var neutralZoneByLen = map[int]string{ 3: "UTC", 4: "Zulu", 7: "Etc/UTC", 8: "Etc/GMT0", 9: "Universal", 10: "US/Pacific", 11: "Brazil/East", 12: "Canada/Yukon", 13: "Etc/Universal", 14: "Pacific/Tarawa", 15: "Atlantic/Azores", 16: "Antarctica/Troll", 17: "Antarctica/Vostok", 18: "Antarctica/McMurdo", 19: "Pacific/Guadalcanal", 20: "Pacific/Bougainville", } type eolSeg struct { line string eol []byte } // splitKeepEOL splits content into lines while preserving each original line // terminator ("\n", "\r\n", or none for a final unterminated line). func splitKeepEOL(b []byte) []eolSeg { var segs []eolSeg i := 0 for i < len(b) { j := bytes.IndexByte(b[i:], '\n') if j < 0 { segs = append(segs, eolSeg{line: string(b[i:])}) return segs } nl := i + j lineEnd := nl if lineEnd > i && b[lineEnd-1] == '\r' { lineEnd-- } segs = append(segs, eolSeg{line: string(b[i:lineEnd]), eol: append([]byte(nil), b[lineEnd:nl+1]...)}) i = nl + 1 } return segs }