fix(privacy): drop nvidia-bug-report / dmidecode false positives

- 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>
This commit is contained in:
Mikhail Chusavitin
2026-09-02 16:27:17 +03:00
co-authored by Claude Sonnet 5
parent c9748f3830
commit 551d8e450d
5 changed files with 105 additions and 12 deletions
+7
View File
@@ -47,6 +47,13 @@ via `parser.PrivacyScanEnabled()`.
a `resolv` finding, not also a `domain` one). a `resolv` finding, not also a `domain` one).
- A match that parses as an IP but is not sensitive (see IP policy) is dropped - A match that parses as an IP but is not sensitive (see IP policy) is dropped
regardless of the rule that produced it. regardless of the rule that produced it.
- Noise guards: matches shorter than 2 chars or with no alphanumeric are
dropped; `fru_location` values that echo the field name (`Base Board Asset
Tag`, `P1-DIMMA1_AssetTag`) are dropped; IPv4 inside a longer dotted-number
run (`18:6.1.4.5`) is not an address; e-mail on a kernel ring-buffer line
(`[ 8.07] ...`) is driver/copyright text, not customer contact.
- Customer guess: a single-hit low-confidence domain is not reported at all
(better "unidentified" than guessing `nvidia.com` from a driver comment).
## Rule catalogue (`rules.go`) ## Rule catalogue (`rules.go`)
+9
View File
@@ -19,6 +19,7 @@ var (
"libssh.org", "rsyslog.com", "adiscon.com", "redhat.com", "kernel.org", "libssh.org", "rsyslog.com", "adiscon.com", "redhat.com", "kernel.org",
"megarac.com", "ami.com", "commond.com", "megarac.com", "ami.com", "commond.com",
"oasis-open.org", "w3.org", "xmlsoap.org", "purl.org", "ietf.org", "oasis-open.org", "w3.org", "xmlsoap.org", "purl.org", "ietf.org",
"nvidia.com", "mellanox.com", "gnu.org", "debian.org", "ubuntu.com",
"inspur.com", "inspurcloud.com", "inservice-iq.com", "ieisystem.com", "kaytus.com", "inspur.com", "inspurcloud.com", "inservice-iq.com", "ieisystem.com", "kaytus.com",
"jd.com", "jd.local", "jdcloud.com", "in-addr.arpa", "ip6.arpa", "arpa", "jd.com", "jd.local", "jdcloud.com", "in-addr.arpa", "ip6.arpa", "arpa",
} }
@@ -35,6 +36,14 @@ var (
"null": {}, "null": {},
"0": {}, "0": {},
"0.0.0.0": {}, "0.0.0.0": {},
"not specified": {},
"not available": {},
"not present": {},
"unspecified": {},
"no asset tag": {},
"no asset information": {},
"empty": {},
"[empty]": {},
} }
// Archive members that are vendor factory templates, not the active config. // Archive members that are vendor factory templates, not the active config.
+4 -2
View File
@@ -96,13 +96,15 @@ func guessCustomers(findings []models.PrivacyFinding) []models.CustomerGuess {
return guesses[i].Domain < guesses[j].Domain return guesses[i].Domain < guesses[j].Domain
}) })
// Keep the strongest few; drop weak single-hit noise unless it is all we have. // 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) out := make([]models.CustomerGuess, 0, 3)
for _, g := range guesses { for _, g := range guesses {
if len(out) >= 3 { if len(out) >= 3 {
break break
} }
if g.Hits < 2 && g.Confidence == "low" && len(out) > 0 { if g.Hits < 2 && g.Confidence == "low" {
continue continue
} }
out = append(out, g) out = append(out, g)
+38
View File
@@ -110,6 +110,44 @@ func TestScan_AllowlistedDomainNotFlagged(t *testing.T) {
} }
} }
func TestScan_NvidiaBugReportNoise(t *testing.T) {
// dmidecode + dmesg boilerplate that must not be flagged.
rep := Scan([]File{{Path: "nvidia-bug-report.log", Content: []byte(
"driver bug via the NVIDIA Linux forum (see forums.developer.nvidia.com)\n" +
"or by sending email to 'linux-bugs@nvidia.com'.\n" +
" Asset Tag: Base Board Asset Tag\n" +
" P1-DIMMA1_AssetTag (Date:24/31)\n" +
" ME FW Version\n 18:6.1.4.5\n" +
"[ 8.078174] pps_core: Software ver. 5.3.6 - Copyright 2005-2007 Rodolfo Giometti <giometti@linux.it>\n")}})
if rep != nil && len(rep.Findings) > 0 {
t.Fatalf("nvidia boilerplate flagged: %+v", rep.Findings)
}
if rep != nil && len(rep.Customers) > 0 {
t.Fatalf("nvidia boilerplate produced a customer guess: %+v", rep.Customers)
}
}
func TestScan_RealResolvStillCaughtInNoisyFile(t *testing.T) {
rep := Scan([]File{{Path: "nvidia-bug-report.log", Content: []byte(
"[ 8.078174] pps_core: <giometti@linux.it>\n" +
"--- /etc/resolv.conf ---\ndomain corp.acme.ru\nnameserver 10.0.0.1\n")}})
if rep == nil {
t.Fatal("nil report")
}
found := false
for _, f := range rep.Findings {
if f.Category == catResolv && f.Match == "corp.acme.ru" {
found = true
}
}
if !found {
t.Fatalf("real resolv.conf leak missed: %+v", rep.Findings)
}
if len(rep.Customers) == 0 || rep.Customers[0].Domain != "acme.ru" {
t.Fatalf("customer guess = %+v, want acme.ru", rep.Customers)
}
}
func TestSummary(t *testing.T) { func TestSummary(t *testing.T) {
rep := Scan([]File{{Path: "resolv.conf", Content: []byte("domain acme.ru\n")}}) rep := Scan([]File{{Path: "resolv.conf", Content: []byte("domain acme.ru\n")}})
if rep.Summary.Total != len(rep.Findings) || rep.Summary.Total == 0 { if rep.Summary.Total != len(rep.Findings) || rep.Summary.Total == 0 {
+47 -10
View File
@@ -50,6 +50,9 @@ var (
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})`) 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`) 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`) reTZAbbr = regexp.MustCompile(`\b(MSK|MSD|EEST|EET|MDST|CEST|CET|WEST|WET)\b\s+20\d\d`)
// Kernel ring-buffer line: "[ 12.345678] ...". These carry driver/copyright
// strings (emails, versions), never customer identity.
reKernelTimestamp = regexp.MustCompile(`^\[\s*\d+\.\d+\]\s`)
tableRules = []tableRule{ tableRules = []tableRule{
{catResolv, severityHigh, regexp.MustCompile(`(?i)^\s*(?:domain|search)\s+(\S+)`), 1, {catResolv, severityHigh, regexp.MustCompile(`(?i)^\s*(?:domain|search)\s+(\S+)`), 1,
@@ -84,15 +87,21 @@ var (
func scanLine(path, line string, ln int, certFile bool, emit func(models.PrivacyFinding)) { func scanLine(path, line string, ln int, certFile bool, emit func(models.PrivacyFinding)) {
var local []models.PrivacyFinding var local []models.PrivacyFinding
specific := map[string]struct{}{} // matches from categories more precise than a bare FQDN specific := map[string]struct{}{} // matches from categories more precise than a bare FQDN
kernelLine := reKernelTimestamp.MatchString(line)
add := func(cat, sev, match, hint string) { add := func(cat, sev, match, hint string) {
match = strings.Trim(strings.TrimSpace(match), `"',;`) match = strings.Trim(strings.TrimSpace(match), `"',;:`)
if match == "" || isAllowlistedValue(match) { if len(match) < 2 || !strings.ContainsFunc(match, isAlnum) || isAllowlistedValue(match) {
return return
} }
if ip := net.ParseIP(match); ip != nil && !isSensitiveIP(match) { if ip := net.ParseIP(match); ip != nil && !isSensitiveIP(match) {
return return
} }
// BIOS/SMBIOS echoes the field name as the value ("Base Board Asset Tag",
// "P1-DIMMA1_AssetTag") - not a customer site tag.
if cat == catFRULocation && strings.Contains(strings.ToLower(strings.ReplaceAll(match, " ", "")), "assettag") {
return
}
if cat != catDomain && cat != catEmail && cat != catPublicIP { if cat != catDomain && cat != catEmail && cat != catPublicIP {
specific[strings.ToLower(match)] = struct{}{} specific[strings.ToLower(match)] = struct{}{}
} }
@@ -129,21 +138,27 @@ func scanLine(path, line string, ln int, certFile bool, emit func(models.Privacy
} }
add(catDomain, severityHigh, m[1], "domain / FQDN reveals the customer") add(catDomain, severityHigh, m[1], "domain / FQDN reveals the customer")
} }
for _, m := range reEmail.FindAllStringSubmatch(line, -1) { if !kernelLine {
host := m[1][strings.IndexByte(m[1], '@')+1:] for _, m := range reEmail.FindAllStringSubmatch(line, -1) {
if isAllowlistedDomain(host) || !looksLikeMailHost(host) { host := m[1][strings.IndexByte(m[1], '@')+1:]
continue if isAllowlistedDomain(host) || !looksLikeMailHost(host) {
continue
}
add(catEmail, severityMedium, m[1], "e-mail address")
} }
add(catEmail, severityMedium, m[1], "e-mail address")
} }
for _, m := range reSyslogTarget.FindAllStringSubmatch(line, -1) { for _, m := range reSyslogTarget.FindAllStringSubmatch(line, -1) {
add(catCollector, severityMedium, m[1], "remote syslog target") add(catCollector, severityMedium, m[1], "remote syslog target")
} }
for _, m := range reIPv4.FindAllStringSubmatch(line, -1) { for _, loc := range reIPv4.FindAllStringIndex(line, -1) {
if !isSensitiveIP(m[1]) { if inDottedNumberRun(line, loc[0], loc[1]) {
continue // part of a longer version string like "18:6.1.4.5"
}
ip := line[loc[0]:loc[1]]
if !isSensitiveIP(ip) {
continue continue
} }
add(catPublicIP, severityMedium, m[1], "public IP reveals provider / site") add(catPublicIP, severityMedium, ip, "public IP reveals provider / site")
} }
if strings.Contains(strings.ToLower(line), "timezone") || strings.Contains(line, "/") { if strings.Contains(strings.ToLower(line), "timezone") || strings.Contains(line, "/") {
for _, m := range reTZName.FindAllStringSubmatch(line, -1) { for _, m := range reTZName.FindAllStringSubmatch(line, -1) {
@@ -165,6 +180,28 @@ func scanLine(path, line string, ln int, certFile bool, emit func(models.Privacy
} }
} }
func isAlnum(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
}
// inDottedNumberRun reports whether line[start:end] (a dotted quad) is embedded
// in a longer numeric run - a version string like "18:6.1.4.5" or "1.2.3.4.5" -
// rather than a standalone address.
func inDottedNumberRun(line string, start, end int) bool {
if start > 0 {
switch line[start-1] {
case '.', ':':
if start >= 2 && line[start-2] >= '0' && line[start-2] <= '9' {
return true
}
}
}
if end < len(line) && line[end] == '.' {
return true
}
return false
}
// looksLikeMailHost rejects the many "local@identifier.token" strings that are // looksLikeMailHost rejects the many "local@identifier.token" strings that are
// not e-mail: OData/Redfish JSON annotations (Members@odata.count), SSH // not e-mail: OData/Redfish JSON annotations (Members@odata.count), SSH
// cipher/kex names (aes256-gcm@openssh.com is handled by the domain allowlist, // cipher/kex names (aes256-gcm@openssh.com is handled by the domain allowlist,