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 }