Files
Mikhail ChusavitinandClaude Sonnet 5 a63bb17438 feat(sanitize): in-place, length-preserving log de-identification
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>
2026-09-02 18:05:27 +03:00

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")
}
}