feat(privacy): scan ingested sources for customer-identifying data

Detection-only scan (internal/privacy) attached to every AnalysisResult:
a customer-domain guess plus a findings list (category, file, line, match,
hint), ported from the KB grep playbook. Runs on archive uploads and the
serialized Redfish tree; gated by LOGPILE_PRIVACY_SCAN (default on).

Surfaced at GET /api/privacy-scan, in the "Customer data" UI panel, and as
privacy_report.json in the raw-export bundle. IP policy keeps RFC1918 and
example ranges out of findings; allowlist covers standards-body and vendor
infrastructure domains. No customer tokens in the repo. See ADL-066 and
bible-local/docs/privacy-scan.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-09-02 15:24:32 +03:00
co-authored by Claude Sonnet 5
parent 3311bafd8e
commit 4a4910f207
23 changed files with 1466 additions and 33 deletions
+121
View File
@@ -0,0 +1,121 @@
package privacy
import "testing"
// Fixtures use "acme.ru" as a stand-in customer domain. The RFC 2606
// "example.*" names are on the allowlist (they are the sanitization target),
// so they cannot double as the customer placeholder in detection tests.
func TestScan_DetectsAndClassifies(t *testing.T) {
files := []File{
{Path: "onekeylog/configuration/conf/resolv.conf", Content: []byte(
"domain corp.acme.ru\nsearch corp.acme.ru\nnameserver 10.10.0.1\n")},
{Path: "onekeylog/configuration/conf/activedir.conf", Content: []byte(
"racdomain=corp.acme.ru\nadfilterdc1=10.10.0.5\nrolegroup1name=cab-gr-CI00363277-x86bmc\n")},
{Path: "onekeylog/configuration/conf/BMC1/misccfg.ini", Content: []byte(
"TimeZone=Europe/Moscow\nSELTimeUTCOffset=180\n")},
{Path: "onekeylog/log/audit.log", Content: []byte(
"2026-08-24T11:47:34+03:00 login admin from ntp01.acme.ru ok\n" +
"contact ops@acme.ru for access\n" +
"outbound 45.32.10.7 established\n" +
"internal 192.168.31.4 and 10.1.2.3 and doc 203.0.113.9\n")},
{Path: "onekeylog/runningdata/rundatainfo.log", Content: []byte(
"Mon Aug 24 11:58:48 MSK 2026\n")},
// Vendor factory template - must be skipped entirely.
{Path: "onekeylog/configuration/conf/ntp_auto.conf", Content: []byte(
"server ntp.should-not-match.ru\n")},
{Path: "onekeylog/bin/blob", Content: []byte{0x00, 0x01, 0x02, 0xff}},
}
rep := Scan(files)
if rep == nil {
t.Fatal("nil report")
}
if rep.FilesScanned != 5 {
t.Fatalf("FilesScanned = %d, want 5 (binary + template skipped)", rep.FilesScanned)
}
has := func(cat, match string) bool {
for _, f := range rep.Findings {
if f.Category == cat && f.Match == match {
return true
}
}
return false
}
mustHave := []struct{ cat, match string }{
{catResolv, "corp.acme.ru"},
{catADLDAP, "corp.acme.ru"},
{catADLDAP, "cab-gr-CI00363277-x86bmc"},
{catTimezone, "Europe/Moscow"},
{catTimezone, "180"},
{catDomain, "ntp01.acme.ru"},
{catEmail, "ops@acme.ru"},
{catPublicIP, "45.32.10.7"},
{catTimezone, "MSK"},
}
for _, w := range mustHave {
if !has(w.cat, w.match) {
t.Errorf("missing finding %s / %q", w.cat, w.match)
}
}
mustNotHave := []string{"192.168.31.4", "10.1.2.3", "203.0.113.9", "ntp.should-not-match.ru"}
for _, f := range rep.Findings {
for _, bad := range mustNotHave {
if f.Match == bad {
t.Errorf("unexpected finding for %q (%s)", bad, f.Category)
}
}
}
if len(rep.Customers) == 0 || rep.Customers[0].Domain != "acme.ru" {
t.Fatalf("customer guess = %+v, want acme.ru first", rep.Customers)
}
if rep.Customers[0].Confidence != "high" {
t.Errorf("confidence = %s, want high", rep.Customers[0].Confidence)
}
}
func TestScan_NothingToScan(t *testing.T) {
if Scan(nil) != nil {
t.Fatal("want nil for no files")
}
if Scan([]File{{Path: "x", Content: []byte{0}}}) != nil {
t.Fatal("want nil when only binary files")
}
}
func TestScan_Dedupe(t *testing.T) {
rep := Scan([]File{{Path: "a.log", Content: []byte(
"host is srv-prod.acme.net\nhost is srv-prod.acme.net again\n")}})
if rep == nil {
t.Fatal("nil report")
}
n := 0
for _, f := range rep.Findings {
if f.Category == catDomain && f.Match == "srv-prod.acme.net" {
n++
}
}
if n != 1 {
t.Fatalf("domain finding counted %d times, want 1", n)
}
}
func TestScan_AllowlistedDomainNotFlagged(t *testing.T) {
rep := Scan([]File{{Path: "ntp.conf", Content: []byte(
"server 0.pool.ntp.org\nserver time.nist.gov\ncontact admin@example.com\n")}})
if rep != nil && len(rep.Findings) > 0 {
t.Fatalf("allowlisted infra flagged: %+v", rep.Findings)
}
}
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 {
t.Fatalf("summary total mismatch: %+v", rep.Summary)
}
if rep.Summary.High == 0 {
t.Fatalf("expected a high finding, got %+v", rep.Summary)
}
}