Files
Mikhail ChusavitinandClaude Sonnet 5 2be215fca1 fix(privacy): calibrate against the RMA log corpus (130k files)
Cross-checked internal/privacy against the batch companion-app report over
project/rma. Fixes for the dominant false-positive classes:

- Real-TLD gate (tld.go): FQDN/e-mail must end in a curated TLD or a
  pseudo-TLD with a >=3-char label; two-letter file/code suffixes
  (.sh .so .md .id .service ...) are a hard denylist. Kills "0.linux"
  (45996 hits), "mountall.sh", "libc.so", "@odata.id",
  "serial-getty@ttyAMA0.service".
- Clean-token boundary + Title-case reject: "auth.backend.gssapi.store-creds",
  "OS.It" are code, not hosts.
- Kernel ring-buffer ("[ 8.07][ T1] ...") and Go stack-trace lines skipped.
- resolv domain/search values must contain a dot ("domain 53" -> out).
- IPv4: skip comment lines, version/spec lines (X.Org, IEEE Std, l0fw_ver),
  "0."/"1."/".0" quads; allowlist Yandex resolvers + RFC3849 2001:db8::/32.
- fru_location: drop all-digit / serial-like / field-name-echo values.
- Drop the hostname rule (zero real hits, only "bmc-state-manager" noise).
- domain category: high -> low, medium at 3+ labels. Real customer signal
  now comes from resolv/nsupdate/ad_ldap/mgmt, which the corpus confirms
  catches every actual customer (netwell.local, tcsbank.ru).
- Allowlist smartmontools.org, openib.org, apache.org, freebsd.org,
  golang.org, ipxe.org, nvidia.com and other FOSS/vendor infra; skip
  LOGPile's own raw_export.json / parser_fields.json / collect.log members.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 16:45:35 +03:00

183 lines
4.3 KiB
Go

package privacy
import (
"sort"
"strconv"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
)
// Multi-label public suffixes handled explicitly; every other final label is
// treated as the suffix. This is a heuristic, not a full PSL - good enough to
// collapse "sn-x.mgmt.corp.example.co.uk" to "example.co.uk".
var multiLabelSuffixes = []string{
"co.uk", "org.uk", "gov.uk", "ac.uk", "me.uk",
"com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn",
"com.au", "net.au", "org.au", "co.jp", "co.kr", "com.br", "com.tr", "com.ua",
}
// Categories whose findings feed the customer guess, and whether a hit in that
// category is strong evidence on its own.
var customerCategories = map[string]bool{
catDomain: false,
catResolv: true,
catADLDAP: true,
catCert: true,
catNSUpdate: true,
catMgmtSubdomain: true,
catDHCP: false,
catEmail: false,
}
type customerTally struct {
domain string
hits int
paths map[string]struct{}
strong bool
evidence []string
evidenceSeen map[string]struct{}
}
func guessCustomers(findings []models.PrivacyFinding) []models.CustomerGuess {
tallies := map[string]*customerTally{}
for _, f := range findings {
strongCat, ok := customerCategories[f.Category]
if !ok {
continue
}
host := f.Match
if f.Category == catEmail {
if at := strings.IndexByte(host, '@'); at >= 0 {
host = host[at+1:]
}
}
reg := registrableDomain(host)
if reg == "" || isAllowlistedDomain(reg) {
continue
}
t := tallies[reg]
if t == nil {
t = &customerTally{domain: reg, paths: map[string]struct{}{}, evidenceSeen: map[string]struct{}{}}
tallies[reg] = t
}
t.hits++
if f.Path != "" {
t.paths[f.Path] = struct{}{}
}
if strongCat {
t.strong = true
}
if len(t.evidence) < 3 && f.Excerpt != "" {
line := evidenceLine(f)
if _, dup := t.evidenceSeen[line]; !dup {
t.evidenceSeen[line] = struct{}{}
t.evidence = append(t.evidence, line)
}
}
}
guesses := make([]models.CustomerGuess, 0, len(tallies))
for _, t := range tallies {
guesses = append(guesses, models.CustomerGuess{
Domain: t.domain,
Confidence: confidence(t),
Hits: t.hits,
Evidence: t.evidence,
})
}
sort.Slice(guesses, func(i, j int) bool {
si, sj := score(tallies[guesses[i].Domain]), score(tallies[guesses[j].Domain])
if si != sj {
return si > sj
}
return guesses[i].Domain < guesses[j].Domain
})
// Drop weak single-hit low-confidence candidates entirely - a "could not
// identify" is more useful than guessing a driver-comment or kernel-source
// domain (nvidia.com, linux.it, ...).
out := make([]models.CustomerGuess, 0, 3)
for _, g := range guesses {
if len(out) >= 3 {
break
}
if g.Hits < 2 && g.Confidence == "low" {
continue
}
out = append(out, g)
}
return out
}
func score(t *customerTally) int {
s := t.hits + 3*len(t.paths)
if t.strong {
s += 10
}
return s
}
func confidence(t *customerTally) string {
switch {
case t.strong && len(t.paths) >= 2:
return "high"
case t.strong || len(t.paths) >= 2:
return "medium"
default:
return "low"
}
}
func evidenceLine(f models.PrivacyFinding) string {
loc := f.Path
if f.Line > 0 {
loc += ":" + strconv.Itoa(f.Line)
}
return loc + ": " + f.Excerpt
}
// registrableDomain returns the registrable ("eTLD+1") portion of a host name,
// or "" if host is an IP, has no dot, or is a bare TLD.
func registrableDomain(host string) string {
host = strings.ToLower(strings.Trim(strings.TrimSpace(host), ".*"))
host = strings.TrimPrefix(host, "*.")
if host == "" || strings.ContainsAny(host, ":/ ") {
return ""
}
if at := strings.IndexByte(host, '@'); at >= 0 {
host = host[at+1:]
}
if !plausibleFQDN(host) {
return ""
}
if strings.Count(host, ".") == 3 {
allDigits := true
for _, r := range host {
if r != '.' && (r < '0' || r > '9') {
allDigits = false
break
}
}
if allDigits {
return ""
}
}
labels := strings.Split(host, ".")
if len(labels) < 2 {
return ""
}
for _, suf := range multiLabelSuffixes {
if strings.HasSuffix(host, "."+suf) {
sufLabels := strings.Count(suf, ".") + 1
if len(labels) > sufLabels {
return strings.Join(labels[len(labels)-sufLabels-1:], ".")
}
return ""
}
}
return strings.Join(labels[len(labels)-2:], ".")
}