Files
logpile/internal/sanitize/archive.go
T
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

349 lines
9.7 KiB
Go

package sanitize
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"fmt"
"io"
"path"
"strings"
"git.mchus.pro/mchus/logpile/internal/privacy"
)
const (
maxInputBytes = 800 << 20 // whole file, read into memory and rebuilt
maxMemberBytes = 1 << 30 // single decompressed member
)
// rewriteBytes de-identifies data according to its format (by filename
// extension), recursing into nested archives. It returns the rebuilt bytes plus
// the replacements made and any binary members that held customer data but
// could not be edited safely.
func rewriteBytes(name string, data []byte) (out []byte, changes []change, skippedBinary []string, err error) {
switch strings.ToLower(path.Ext(name)) {
case ".tar", ".sds":
return rewriteTar(name, data)
case ".gz", ".tgz":
return rewriteGz(name, data)
case ".zip":
return rewriteZip(name, data)
case ".ahs":
// Proprietary HPE container - not editable in place. Report if it holds
// customer data.
if scanHasFindings(data) {
return data, nil, []string{name + " (HPE AHS container - sanitize manually)"}, nil
}
return data, nil, nil, nil
case ".txt", ".log":
if privacy.IsAllowlistedFile(name) {
return data, nil, nil, nil
}
nb, ch := redactText(data, isCertName(name))
return nb, prefixPath(ch, path.Base(name)), nil, nil
default:
return rewriteMember(name, data, false)
}
}
// rewriteMember handles one file inside an archive: recurse if it is itself an
// archive, redact if it is text, otherwise leave it (and flag it when a binary
// member carries customer data). Returned changes/skips are already qualified
// with name, so callers append them verbatim.
func rewriteMember(name string, data []byte, isDirOrSpecial bool) ([]byte, []change, []string, error) {
if isDirOrSpecial || len(data) == 0 || privacy.IsAllowlistedFile(name) {
return data, nil, nil, nil
}
if isNestedArchive(name) || looksLikeTar(data) {
nb, ch, skip, err := rewriteBytes(name, data)
if err == nil {
return nb, prefixPath(ch, name), prefixNames(skip, name), nil
}
// A truncated / mis-named "archive" must not fail the whole job: treat it
// as a plain file if it is text, otherwise copy it and flag it.
if looksLikeText(data) {
nb, ch := redactText(data, isCertName(name))
return nb, prefixPath(ch, name), nil, nil
}
if len(data) <= 8<<20 && binaryHasLeak(data) {
return data, nil, []string{name + " (unreadable as an archive; holds customer data - sanitize manually)"}, nil
}
return data, nil, nil, nil
}
if looksLikeText(data) {
nb, ch := redactText(data, isCertName(name))
return nb, prefixPath(ch, name), nil, nil
}
// Binary member: never edit it (checksums / structure), but tell the
// operator if a printable run inside it holds customer data.
if len(data) <= 8<<20 && binaryHasLeak(data) {
return data, nil, []string{name + " (binary - sanitize manually)"}, nil
}
return data, nil, nil, nil
}
func rewriteTar(name string, data []byte) ([]byte, []change, []string, error) {
tr := tar.NewReader(bytes.NewReader(data))
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
var changes []change
var skipped []string
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, nil, nil, fmt.Errorf("%s: tar read: %w", name, err)
}
body, err := io.ReadAll(io.LimitReader(tr, maxMemberBytes+1))
if err != nil {
return nil, nil, nil, fmt.Errorf("%s: read %s: %w", name, hdr.Name, err)
}
special := !hdr.FileInfo().Mode().IsRegular()
newBody, ch, skip, err := rewriteMember(hdr.Name, body, special || int64(len(body)) > maxMemberBytes)
if err != nil {
return nil, nil, nil, err
}
h := *hdr // reuse every header field verbatim (name, mode, uid/gid, mtime/atime/ctime, pax, format)
h.Size = int64(len(newBody))
if err := tw.WriteHeader(&h); err != nil {
return nil, nil, nil, fmt.Errorf("%s: write header %s: %w", name, hdr.Name, err)
}
if _, err := tw.Write(newBody); err != nil {
return nil, nil, nil, err
}
changes = append(changes, ch...)
skipped = append(skipped, skip...)
}
if len(changes) == 0 {
return data, nil, skipped, nil // nothing redacted -> byte-identical
}
if err := tw.Close(); err != nil {
return nil, nil, nil, err
}
return buf.Bytes(), changes, skipped, nil
}
func rewriteGz(name string, data []byte) ([]byte, []change, []string, error) {
gzr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, nil, nil, fmt.Errorf("%s: gzip: %w", name, err)
}
hdr := gzr.Header
decompressed, err := io.ReadAll(io.LimitReader(gzr, maxMemberBytes+1))
gzr.Close()
if err != nil {
return nil, nil, nil, fmt.Errorf("%s: gunzip: %w", name, err)
}
if int64(len(decompressed)) > maxMemberBytes {
return data, nil, []string{name + " (too large to sanitize)"}, nil
}
innerName := strings.TrimSuffix(hdr.Name, ".gz")
if innerName == "" {
innerName = strings.TrimSuffix(path.Base(name), ".gz")
}
var newInner []byte
var ch []change
var skip []string
switch {
case looksLikeTar(decompressed):
newInner, ch, skip, err = rewriteTar(innerName, decompressed)
if err != nil {
return data, nil, []string{name + " (unreadable inner tar - copied as-is)"}, nil
}
case privacy.IsAllowlistedFile(innerName):
newInner = decompressed
case looksLikeText(decompressed):
newInner, ch = redactText(decompressed, isCertName(innerName))
ch = prefixPath(ch, innerName)
case len(decompressed) <= 8<<20 && binaryHasLeak(decompressed):
newInner, skip = decompressed, []string{innerName + " (binary - sanitize manually)"}
default:
newInner = decompressed
}
if len(ch) == 0 {
return data, nil, skip, nil // nothing redacted -> byte-identical
}
var buf bytes.Buffer
gzw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression)
gzw.Name = hdr.Name
gzw.Comment = hdr.Comment
gzw.ModTime = hdr.ModTime
gzw.OS = hdr.OS
gzw.Extra = hdr.Extra
if _, err := gzw.Write(newInner); err != nil {
return nil, nil, nil, err
}
if err := gzw.Close(); err != nil {
return nil, nil, nil, err
}
return buf.Bytes(), ch, skip, nil
}
func rewriteZip(name string, data []byte) ([]byte, []change, []string, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, nil, nil, fmt.Errorf("%s: zip: %w", name, err)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
if zr.Comment != "" {
_ = zw.SetComment(zr.Comment)
}
var changes []change
var skipped []string
for _, f := range zr.File {
if f.FileInfo().IsDir() {
if err := zw.Copy(f); err != nil {
return nil, nil, nil, err
}
continue
}
rc, err := f.Open()
if err == nil {
var body []byte
body, err = io.ReadAll(io.LimitReader(rc, maxMemberBytes+1))
rc.Close()
if err == nil {
newBody, ch, skip, mErr := rewriteMember(f.Name, body, int64(len(body)) > maxMemberBytes)
if mErr == nil {
if bytes.Equal(newBody, body) {
if err := zw.Copy(f); err != nil {
return nil, nil, nil, err
}
} else {
fh := f.FileHeader
fh.CRC32, fh.CompressedSize, fh.CompressedSize64 = 0, 0, 0
fh.UncompressedSize, fh.UncompressedSize64 = 0, 0
w, cErr := zw.CreateHeader(&fh)
if cErr != nil {
return nil, nil, nil, cErr
}
if _, wErr := w.Write(newBody); wErr != nil {
return nil, nil, nil, wErr
}
}
changes = append(changes, ch...)
skipped = append(skipped, skip...)
continue
}
}
}
// Unreadable / unprocessable entry: copy it raw, flag it.
if err := zw.Copy(f); err != nil {
return nil, nil, nil, fmt.Errorf("%s: copy %s: %w", name, f.Name, err)
}
skipped = append(skipped, f.Name+" (unreadable zip entry - copied as-is)")
}
if len(changes) == 0 {
return data, nil, skipped, nil // nothing redacted -> byte-identical
}
if err := zw.Close(); err != nil {
return nil, nil, nil, err
}
return buf.Bytes(), changes, skipped, nil
}
func isNestedArchive(name string) bool {
switch strings.ToLower(path.Ext(name)) {
case ".gz", ".tgz", ".tar", ".zip", ".sds":
return true
}
return false
}
func isCertName(name string) bool {
switch strings.ToLower(path.Ext(name)) {
case ".pem", ".csr", ".crt", ".cer":
return true
}
return false
}
func looksLikeTar(b []byte) bool {
if len(b) < 512 {
return false
}
_, err := tar.NewReader(bytes.NewReader(b)).Next()
return err == nil
}
// looksLikeText mirrors the privacy scanner's heuristic: no NUL byte in the
// first 8 KiB.
func looksLikeText(b []byte) bool {
if len(b) == 0 {
return false
}
head := b
if len(head) > 8192 {
head = head[:8192]
}
return bytes.IndexByte(head, 0) < 0
}
func scanHasFindings(b []byte) bool {
rep := privacy.Scan([]privacy.File{{Path: "member", Content: b}})
return rep != nil && rep.Summary.Total > 0
}
// binaryHasLeak reports whether any printable ASCII run inside a binary member
// contains a redactable span (privacy.Scan itself skips non-text files, so it
// cannot see strings embedded in FRU.bin / redis-dump.rdb / SDR.dat).
func binaryHasLeak(b []byte) bool {
start := -1
check := func(run []byte) bool {
if len(run) < 6 {
return false
}
for _, sp := range privacy.FindSpans(string(run), false) {
if redactableCat[sp.Category] {
return true
}
}
return false
}
for i, c := range b {
printable := c >= 0x20 && c < 0x7f
if printable && start < 0 {
start = i
}
if !printable && start >= 0 {
if check(b[start:i]) {
return true
}
start = -1
}
}
if start >= 0 && check(b[start:]) {
return true
}
return false
}
func prefixPath(ch []change, parent string) []change {
for i := range ch {
if ch[i].path == "" {
ch[i].path = parent
} else {
ch[i].path = parent + "::" + ch[i].path
}
}
return ch
}
func prefixNames(names []string, parent string) []string {
out := make([]string, len(names))
for i, n := range names {
out[i] = parent + "::" + n
}
return out
}