package sanitize import ( "archive/tar" "archive/zip" "bytes" "compress/gzip" "io" "strings" "testing" "time" "git.mchus.pro/mchus/logpile/internal/privacy" ) const leakLog = "" + "domain corp.acme.ru\n" + "nameserver 10.0.0.1\n" + "racdomain=corp.acme.ru\n" + "server ntp01.acme.ru\n" + "contact ops@corp.acme.ru\n" + "outbound 93.184.216.34 established\n" + "TimeZone=Europe/Moscow\n" + "SELTimeUTCOffset=180\n" + "pool at pool.ntp.org keep\n" func scanClean(t *testing.T, name string, data []byte) { t.Helper() rep := privacy.Scan([]privacy.File{{Path: name, Content: data}}) if rep != nil && rep.Summary.Total > 0 { var b strings.Builder for _, f := range rep.Findings { b.WriteString("\n " + f.Category + " " + f.Match) } t.Fatalf("%s still has %d findings:%s", name, rep.Summary.Total, b.String()) } } func TestRedactText_LengthPreservedAndClean(t *testing.T) { in := []byte(leakLog) out, changes := redactText(in, false) if len(out) != len(in) { t.Fatalf("length changed: %d -> %d", len(in), len(out)) } if len(changes) == 0 { t.Fatal("no changes made") } scanClean(t, "out.log", out) // idempotent out2, ch2 := redactText(out, false) if !bytes.Equal(out, out2) || len(ch2) != 0 { t.Fatalf("not idempotent: %d more changes\n%q", len(ch2), out2) } s := string(out) if strings.Contains(s, "acme.ru") || strings.Contains(s, "93.184.216.34") || strings.Contains(s, "Europe/Moscow") { t.Fatalf("leak survived:\n%s", s) } if !strings.Contains(s, "pool.ntp.org") { t.Fatal("allowlisted pool.ntp.org was redacted") } if !strings.Contains(s, "10.0.0.1") { t.Fatal("private IP was redacted") } if !strings.Contains(s, "TimeZone=Etc/Universal") { t.Fatalf("timezone not neutralised to a same-length zone:\n%s", s) } if !strings.Contains(s, "SELTimeUTCOffset=000") { t.Fatalf("offset not zeroed:\n%s", s) } } func TestSanitize_PlainLogNoLeak_ByteIdentical(t *testing.T) { in := []byte("just a boring log line\nnothing here\n") res, err := Sanitize("x.log", in) if err != nil { t.Fatal(err) } if !bytes.Equal(res.Data, in) { t.Fatalf("clean input was modified:\n%q", res.Data) } if res.TotalReplaced != 0 { t.Fatalf("changes on clean input: %+v", res.Changes) } } func buildTar(t *testing.T, members map[string]string) []byte { t.Helper() var buf bytes.Buffer tw := tar.NewWriter(&buf) mt := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) // deterministic order for _, name := range []string{"onekeylog/clean.txt", "onekeylog/configuration/conf/resolv.conf"} { body, ok := members[name] if !ok { continue } if err := tw.WriteHeader(&tar.Header{ Name: name, Mode: 0o644, Size: int64(len(body)), ModTime: mt, Typeflag: tar.TypeReg, Format: tar.FormatGNU, }); err != nil { t.Fatal(err) } tw.Write([]byte(body)) } tw.Close() return buf.Bytes() } func tarList(t *testing.T, data []byte) map[string]tar.Header { t.Helper() m := map[string]tar.Header{} tr := tar.NewReader(bytes.NewReader(data)) for { h, err := tr.Next() if err == io.EOF { break } if err != nil { t.Fatalf("re-read tar: %v", err) } body, _ := io.ReadAll(tr) h.Size = int64(len(body)) m[h.Name] = *h } return m } func TestSanitize_Tar_PreservesListingAndCleanMember(t *testing.T) { members := map[string]string{ "onekeylog/clean.txt": "cpu model Xeon Gold\nmemory 512GB\n", "onekeylog/configuration/conf/resolv.conf": leakLog, } in := buildTar(t, members) res, err := Sanitize("dump.tar", in) if err != nil { t.Fatal(err) } before, after := tarList(t, in), tarList(t, res.Data) if len(before) != len(after) { t.Fatalf("member count changed: %d -> %d", len(before), len(after)) } for name, hb := range before { ha, ok := after[name] if !ok { t.Fatalf("member %s vanished", name) } if hb.Size != ha.Size || hb.Mode != ha.Mode || !hb.ModTime.Equal(ha.ModTime) || hb.Typeflag != ha.Typeflag { t.Fatalf("member %s header changed: %+v -> %+v", name, hb, ha) } } // clean member byte-identical if memberBytes(t, res.Data, "onekeylog/clean.txt") != members["onekeylog/clean.txt"] { t.Fatal("clean member was altered") } // leak member cleaned scanClean(t, "resolv.conf", []byte(memberBytes(t, res.Data, "onekeylog/configuration/conf/resolv.conf"))) if res.TotalReplaced == 0 { t.Fatal("nothing redacted") } } func memberBytes(t *testing.T, tarData []byte, name string) string { t.Helper() tr := tar.NewReader(bytes.NewReader(tarData)) for { h, err := tr.Next() if err == io.EOF { t.Fatalf("member %s not found", name) } if err != nil { t.Fatal(err) } if h.Name == name { b, _ := io.ReadAll(tr) return string(b) } } } func TestSanitize_TarGz_PreservesGzipHeaderAndInnerListing(t *testing.T) { inner := buildTar(t, map[string]string{ "onekeylog/clean.txt": "board serial ABC123\n", "onekeylog/configuration/conf/resolv.conf": leakLog, }) var gz bytes.Buffer gw, _ := gzip.NewWriterLevel(&gz, gzip.BestCompression) gw.Name = "dump.tar" gw.ModTime = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) gw.Write(inner) gw.Close() res, err := Sanitize("dump.tar.gz", gz.Bytes()) if err != nil { t.Fatal(err) } gr, err := gzip.NewReader(bytes.NewReader(res.Data)) if err != nil { t.Fatal(err) } if gr.Name != "dump.tar" || !gr.ModTime.Equal(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) { t.Fatalf("gzip header not preserved: name=%q mtime=%v", gr.Name, gr.ModTime) } out, _ := io.ReadAll(gr) before, after := tarList(t, inner), tarList(t, out) for name, hb := range before { if ha, ok := after[name]; !ok || !hb.ModTime.Equal(ha.ModTime) || hb.Size != ha.Size { t.Fatalf("inner member %s changed", name) } } scanClean(t, "inner-resolv", []byte(memberBytes(t, out, "onekeylog/configuration/conf/resolv.conf"))) } func TestSanitize_Zip_UntouchedEntryIdentical(t *testing.T) { var zb bytes.Buffer zw := zip.NewWriter(&zb) mod := time.Date(2025, 7, 8, 9, 10, 0, 0, time.UTC) for _, e := range []struct{ name, body string }{ {"clean.txt", "hardware inventory only\n"}, {"host/resolv.conf", leakLog}, } { w, _ := zw.CreateHeader(&zip.FileHeader{Name: e.name, Method: zip.Deflate, Modified: mod}) w.Write([]byte(e.body)) } zw.Close() res, err := Sanitize("bundle.zip", zb.Bytes()) if err != nil { t.Fatal(err) } zr, err := zip.NewReader(bytes.NewReader(res.Data), int64(len(res.Data))) if err != nil { t.Fatalf("output zip invalid: %v", err) } got := map[string]string{} for _, f := range zr.File { if !f.Modified.Equal(mod) { t.Fatalf("%s Modified changed: %v", f.Name, f.Modified) } rc, _ := f.Open() b, _ := io.ReadAll(rc) rc.Close() got[f.Name] = string(b) } if got["clean.txt"] != "hardware inventory only\n" { t.Fatalf("clean entry altered: %q", got["clean.txt"]) } scanClean(t, "zip-resolv", []byte(got["host/resolv.conf"])) } func TestSanitize_BinaryMemberFlagged(t *testing.T) { fru := append([]byte{0x01, 0x00, 0x00, 0x00}, []byte("corp.acme.ru\x00padding")...) in := func() []byte { var buf bytes.Buffer tw := tar.NewWriter(&buf) tw.WriteHeader(&tar.Header{Name: "onekeylog/FRU.bin", Mode: 0o644, Size: int64(len(fru)), Typeflag: tar.TypeReg}) tw.Write(fru) tw.Close() return buf.Bytes() }() res, err := Sanitize("d.tar", in) if err != nil { t.Fatal(err) } if len(res.SkippedBinary) != 1 || !strings.Contains(res.SkippedBinary[0], "FRU.bin") { t.Fatalf("binary member not flagged: %+v", res.SkippedBinary) } if memberBytes(t, res.Data, "onekeylog/FRU.bin") != string(fru) { t.Fatal("binary member was modified") } }