diff --git a/bible-local/docs/privacy-scan.md b/bible-local/docs/privacy-scan.md index 9fa7cf4..10024ae 100644 --- a/bible-local/docs/privacy-scan.md +++ b/bible-local/docs/privacy-scan.md @@ -47,6 +47,13 @@ via `parser.PrivacyScanEnabled()`. a `resolv` finding, not also a `domain` one). - A match that parses as an IP but is not sensitive (see IP policy) is dropped 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`) diff --git a/internal/privacy/allowlist.go b/internal/privacy/allowlist.go index c648b70..cb2b491 100644 --- a/internal/privacy/allowlist.go +++ b/internal/privacy/allowlist.go @@ -19,6 +19,7 @@ var ( "libssh.org", "rsyslog.com", "adiscon.com", "redhat.com", "kernel.org", "megarac.com", "ami.com", "commond.com", "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", "jd.com", "jd.local", "jdcloud.com", "in-addr.arpa", "ip6.arpa", "arpa", } @@ -35,6 +36,14 @@ var ( "null": {}, "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. diff --git a/internal/privacy/customer.go b/internal/privacy/customer.go index e37f733..04caffb 100644 --- a/internal/privacy/customer.go +++ b/internal/privacy/customer.go @@ -96,13 +96,15 @@ func guessCustomers(findings []models.PrivacyFinding) []models.CustomerGuess { 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) for _, g := range guesses { if len(out) >= 3 { break } - if g.Hits < 2 && g.Confidence == "low" && len(out) > 0 { + if g.Hits < 2 && g.Confidence == "low" { continue } out = append(out, g) diff --git a/internal/privacy/privacy_test.go b/internal/privacy/privacy_test.go index bda91d9..0abe1b1 100644 --- a/internal/privacy/privacy_test.go +++ b/internal/privacy/privacy_test.go @@ -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 \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: \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) { rep := Scan([]File{{Path: "resolv.conf", Content: []byte("domain acme.ru\n")}}) if rep.Summary.Total != len(rep.Findings) || rep.Summary.Total == 0 { diff --git a/internal/privacy/rules.go b/internal/privacy/rules.go index 359d3da..0ce43ef 100644 --- a/internal/privacy/rules.go +++ b/internal/privacy/rules.go @@ -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})`) 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`) + // 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{ {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)) { var local []models.PrivacyFinding specific := map[string]struct{}{} // matches from categories more precise than a bare FQDN + kernelLine := reKernelTimestamp.MatchString(line) add := func(cat, sev, match, hint string) { - match = strings.Trim(strings.TrimSpace(match), `"',;`) - if match == "" || isAllowlistedValue(match) { + match = strings.Trim(strings.TrimSpace(match), `"',;:`) + if len(match) < 2 || !strings.ContainsFunc(match, isAlnum) || isAllowlistedValue(match) { return } if ip := net.ParseIP(match); ip != nil && !isSensitiveIP(match) { 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 { 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") } - for _, m := range reEmail.FindAllStringSubmatch(line, -1) { - host := m[1][strings.IndexByte(m[1], '@')+1:] - if isAllowlistedDomain(host) || !looksLikeMailHost(host) { - continue + if !kernelLine { + 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") } - 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]) { + for _, loc := range reIPv4.FindAllStringIndex(line, -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 } - 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, "/") { 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 // not e-mail: OData/Redfish JSON annotations (Members@odata.count), SSH // cipher/kex names (aes256-gcm@openssh.com is handled by the domain allowlist,