- Allowlist nvidia.com, mellanox.com, gnu.org, debian.org, ubuntu.com; the
common "not specified / not available" FRU placeholders.
- Reject matches with no alphanumeric or <2 chars (stray ":" from a dumped
resolv line), fru_location values that echo the field name ("Base Board
Asset Tag", "P1-DIMMA1_AssetTag"), IPv4 embedded in a version string
("18:6.1.4.5"), and e-mail on kernel ring-buffer lines (driver copyright).
- Customer guess: don't report a single-hit low-confidence domain at all -
"unidentified" beats guessing nvidia.com from a driver comment.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
177 lines
4.2 KiB
Go
177 lines
4.2 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 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:], ".")
|
|
}
|