Files
logpile/internal/privacy/rules.go
T
Mikhail ChusavitinandClaude Sonnet 5 4a4910f207 feat(privacy): scan ingested sources for customer-identifying data
Detection-only scan (internal/privacy) attached to every AnalysisResult:
a customer-domain guess plus a findings list (category, file, line, match,
hint), ported from the KB grep playbook. Runs on archive uploads and the
serialized Redfish tree; gated by LOGPILE_PRIVACY_SCAN (default on).

Surfaced at GET /api/privacy-scan, in the "Customer data" UI panel, and as
privacy_report.json in the raw-export bundle. IP policy keeps RFC1918 and
example ranges out of findings; allowlist covers standards-body and vendor
infrastructure domains. No customer tokens in the repo. See ADL-066 and
bible-local/docs/privacy-scan.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:24:32 +03:00

208 lines
7.5 KiB
Go

package privacy
import (
"net"
"regexp"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
)
const (
severityHigh = "high"
severityMedium = "medium"
severityLow = "low"
catDomain = "domain"
catResolv = "resolv"
catADLDAP = "ad_ldap"
catTimezone = "timezone"
catEmail = "email"
catPublicIP = "public_ip"
catCollector = "collector"
catCert = "cert"
catFRULocation = "fru_location"
catHostname = "hostname"
catDHCP = "dhcp"
catNSUpdate = "nsupdate"
catMgmtSubdomain = "mgmt_subdomain"
)
// tableRule is a simple line-regexp rule. group is the submatch index used as
// the reported token (0 = whole match).
type tableRule struct {
category string
severity string
re *regexp.Regexp
group int
hint string
}
var (
// A conservative TLD set keeps the bare-FQDN rule from matching things like
// "foo.bar" in prose or "index.json" in paths.
fqdnTLD = `(?:ru|su|by|kz|ua|com|net|org|local|io|dev|cloud|info|biz|eu|de|uk|fr|nl|cn|jp|kr|us|gov|edu|mil|co|tech|online)`
reFQDN = regexp.MustCompile(`(?i)\b((?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+` + fqdnTLD + `)\b`)
reMgmtSubdomain = regexp.MustCompile(`(?i)\b([a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:mgmt|oob|ipmi|drac|idrac|ilo|bmc)\.[a-z0-9.-]+)\b`)
reIPv4 = regexp.MustCompile(`\b((?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})\b`)
reEmail = regexp.MustCompile(`(?i)\b([a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,})\b`)
reSyslogTarget = regexp.MustCompile(`@((?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}:[0-9]{1,5})`)
reTZName = regexp.MustCompile(`\b((?:Africa|America|Antarctica|Asia|Atlantic|Australia|Europe|Indian|Pacific)/[A-Za-z_]+(?:/[A-Za-z_]+)?)\b`)
reTZAbbr = regexp.MustCompile(`\b(MSK|MSD|EEST|EET|MDST|CEST|CET|WEST|WET)\b\s+20\d\d`)
tableRules = []tableRule{
{catResolv, severityHigh, regexp.MustCompile(`(?i)^\s*(?:domain|search)\s+(\S+)`), 1,
"resolv.conf DNS suffix - replace with example.local"},
{catADLDAP, severityHigh, regexp.MustCompile(`(?i)\b(racdomain|adfilterdc[0-9]|rolegroup[0-9](?:name|domain))\s*=\s*(\S+)`), 2,
"activedir.conf - AD domain / DC address / role-group name"},
{catADLDAP, severityHigh, regexp.MustCompile(`(?i)\b(binddn|bindpw)\s*=\s*(\S+)`), 2,
"ldap.conf - directory service-account bind"},
{catTimezone, severityMedium, regexp.MustCompile(`(?i)\b(TimeZone|SELTimeUTCOffset)\s*=\s*(\S+)`), 2,
"timezone reveals region - set Etc/UTC / offset 0"},
{catCollector, severityMedium, regexp.MustCompile(`(?i)\b(SyslogHostname)\s*=\s*(\S+)`), 2,
"customer syslog collector"},
{catCollector, severityMedium, regexp.MustCompile(`(?i)"Destination"\s*:\s*"([^"]+)"`), 1,
"SNMP trap / event destination"},
{catFRULocation, severityMedium, regexp.MustCompile(`(?i)\b(Asset Tag|Product Location|Chassis Location|Board Extra)\b\s*[:=]\s*(.+)`), 2,
"FRU site / inventory field"},
{catNSUpdate, severityHigh, regexp.MustCompile(`(?i)\bupdate\s+(?:add|delete)\s+(\S+\.\S+)\s+.*\b(?:A|AAAA|PTR|CNAME)\b`), 1,
"DDNS update - host FQDN + BMC record"},
{catDHCP, severityMedium, regexp.MustCompile(`(?i)option\s+domain-name\s+"?([^";]+)`), 1,
"domain-name handed out by customer DHCP"},
{catHostname, severityLow, regexp.MustCompile(`(?i)\b((?:sn|bmc|ilo|idrac|drac|srv)-[a-z0-9][a-z0-9-]{2,})\b`), 1,
"hostname follows a customer naming scheme"},
}
certLineRules = []tableRule{
{catCert, severityHigh, regexp.MustCompile(`(?i)^\s*(?:Subject|Issuer):\s*(.+)`), 1, "TLS cert subject/issuer"},
{catCert, severityHigh, regexp.MustCompile(`(?i)\bCN\s*=\s*([^,/]+)`), 1, "TLS cert common name"},
{catCert, severityHigh, regexp.MustCompile(`(?i)\bDNS:\s*([a-z0-9.\-*]+)`), 1, "TLS cert SAN"},
}
)
func scanLine(path, line string, ln int, certFile bool, emit func(models.PrivacyFinding)) {
var local []models.PrivacyFinding
specific := map[string]struct{}{} // matches from categories more precise than a bare FQDN
add := func(cat, sev, match, hint string) {
match = strings.Trim(strings.TrimSpace(match), `"',;`)
if match == "" || isAllowlistedValue(match) {
return
}
if ip := net.ParseIP(match); ip != nil && !isSensitiveIP(match) {
return
}
if cat != catDomain && cat != catEmail && cat != catPublicIP {
specific[strings.ToLower(match)] = struct{}{}
}
local = append(local, models.PrivacyFinding{
Category: cat, Severity: sev, Path: path, Line: ln,
Match: match, Excerpt: excerpt(line), Hint: hint,
})
}
for _, r := range tableRules {
for _, m := range r.re.FindAllStringSubmatch(line, -1) {
if r.group < len(m) {
add(r.category, r.severity, m[r.group], r.hint)
}
}
}
if certFile {
for _, r := range certLineRules {
for _, m := range r.re.FindAllStringSubmatch(line, -1) {
if r.group < len(m) {
add(r.category, r.severity, m[r.group], r.hint)
}
}
}
}
for _, m := range reMgmtSubdomain.FindAllStringSubmatch(line, -1) {
add(catMgmtSubdomain, severityHigh, m[1], "management-network subdomain")
}
for _, m := range reFQDN.FindAllStringSubmatch(line, -1) {
if isAllowlistedDomain(m[1]) {
continue
}
add(catDomain, severityHigh, m[1], "domain / FQDN reveals the customer")
}
for _, m := range reEmail.FindAllStringSubmatch(line, -1) {
host := m[1][strings.IndexByte(m[1], '@')+1:]
if isAllowlistedDomain(host) || !looksLikeMailHost(host) {
continue
}
add(catEmail, severityMedium, m[1], "e-mail address")
}
for _, m := range reSyslogTarget.FindAllStringSubmatch(line, -1) {
add(catCollector, severityMedium, m[1], "remote syslog target")
}
for _, m := range reIPv4.FindAllStringSubmatch(line, -1) {
if !isSensitiveIP(m[1]) {
continue
}
add(catPublicIP, severityMedium, m[1], "public IP reveals provider / site")
}
if strings.Contains(strings.ToLower(line), "timezone") || strings.Contains(line, "/") {
for _, m := range reTZName.FindAllStringSubmatch(line, -1) {
if isAllowlistedValue(m[1]) {
continue
}
add(catTimezone, severityMedium, m[1], "timezone reveals region - set Etc/UTC")
}
}
for _, m := range reTZAbbr.FindAllStringSubmatch(line, -1) {
add(catTimezone, severityLow, m[1], "localized timestamp reveals region")
}
for _, f := range local {
if (f.Category == catDomain || f.Category == catEmail) && matchCoveredBySpecific(f.Match, specific) {
continue
}
emit(f)
}
}
// looksLikeMailHost rejects the many "local@identifier.token" strings that are
// not e-mail: OData/Redfish JSON annotations (Members@odata.count), SSH
// cipher/kex names (aes256-gcm@openssh.com is handled by the domain allowlist,
// but the shape is the same).
func looksLikeMailHost(host string) bool {
h := strings.ToLower(host)
if strings.Contains(h, "odata") || strings.Contains(h, "redfish") || strings.Contains(h, "message.") {
return false
}
dot := strings.LastIndexByte(h, '.')
if dot < 0 {
return false
}
tld := h[dot+1:]
if len(tld) < 2 || len(tld) > 24 {
return false
}
for _, r := range tld {
if r < 'a' || r > 'z' {
return false
}
}
return true
}
// matchCoveredBySpecific reports whether a bare FQDN/e-mail finding is already
// represented by a more precise finding on the same line (e.g. the resolv.conf
// "domain corp.acme.ru" line yields both a resolv and a domain hit).
func matchCoveredBySpecific(match string, specific map[string]struct{}) bool {
m := strings.ToLower(match)
if _, ok := specific[m]; ok {
return true
}
if at := strings.IndexByte(m, '@'); at >= 0 {
if _, ok := specific[m[at+1:]]; ok {
return true
}
}
return false
}