Adds internal/sanitize: rewrites the customer-identifying spans that internal/privacy detects (domain/FQDN/e-mail/AD/public-IP/timezone) with same-length neutral fillers, in place, without changing the file format. - Fillers keep byte length: "sigma.sbrf.ru" -> "xxxxx.xxxx.xx", IP -> "00.000.000.00", "Europe/Moscow" -> "Etc/Universal" (same-length valid neutral IANA zone), offset "180" -> "000". Timestamps are not recomputed. - Lossless recursive archive walk (tar/.sds/gz/tgz/zip): entry names, modes, and all embedded timestamps preserved; untouched zip entries copied raw; member payload length unchanged so tar headers stay byte-identical; only the .gz/.zip compression layer is rebuilt. 0 redactions -> byte-identical output. - privacy.FindSpans is the one matcher shared by detection and redaction; fillers are recognised by isRedactionFiller so a re-scan / second pass is a no-op. New privacy FPs fixed along the way: syslog selectors (local7.info), "MEVersion" firmware quads, *.conf_bak vendor templates, bundled viewer domains. - Binary members (FRU.bin, localtime, redis-dump.rdb, SOL captures) and unreadable nested archives are reported in Result.SkippedBinary, never edited. - Surfaces: POST /api/sanitize (+ GET /api/sanitize/download), the "Обезличить и скачать копию" button in the Customer-data panel, and logpile -sanitize <file> (restores mtime/atime). Verified: re-parsing a sanitized Dell TSR / xFusion / Inspur onekeylog / H3C .sds yields the identical hardware inventory; re-scan is clean. ADL-067, bible-local/docs/log-sanitization.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
279 lines
7.5 KiB
Go
279 lines
7.5 KiB
Go
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")
|
|
}
|
|
}
|