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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e74e01ad05
commit
a63bb17438
@@ -28,6 +28,7 @@ import (
|
||||
"git.mchus.pro/mchus/logpile/internal/ingest"
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
"git.mchus.pro/mchus/logpile/internal/sanitize"
|
||||
chartviewer "reanimator/chart/viewer"
|
||||
)
|
||||
|
||||
@@ -1273,7 +1274,15 @@ func (s *Server) handleGetPrivacyScan(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse(w, map[string]any{"loaded": false})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, result.PrivacyScan)
|
||||
jsonResponse(w, struct {
|
||||
*models.PrivacyScan
|
||||
Sanitizable bool `json:"sanitizable"`
|
||||
}{result.PrivacyScan, s.canSanitizeCurrent()})
|
||||
}
|
||||
|
||||
func (s *Server) canSanitizeCurrent() bool {
|
||||
pkg := s.GetRawExport()
|
||||
return pkg != nil && pkg.Source.Kind == "file_bytes" && sanitize.CanSanitize(pkg.Source.Filename)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1721,6 +1730,7 @@ func (s *Server) handleClear(w http.ResponseWriter, r *http.Request) {
|
||||
s.SetResult(nil)
|
||||
s.SetDetectedVendor("")
|
||||
s.SetRawExport(nil)
|
||||
s.setSanitizeArtifact(nil)
|
||||
for _, artifact := range s.clearAllConvertArtifacts() {
|
||||
if strings.TrimSpace(artifact.Path) != "" {
|
||||
_ = os.Remove(artifact.Path)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/sanitize"
|
||||
)
|
||||
|
||||
// sanitizeArtifact holds the de-identified copy of the current upload, ready for
|
||||
// download. Kept in memory only, like convertOutput.
|
||||
type sanitizeArtifact struct {
|
||||
Data []byte
|
||||
Filename string
|
||||
}
|
||||
|
||||
func (s *Server) setSanitizeArtifact(a *sanitizeArtifact) {
|
||||
s.mu.Lock()
|
||||
s.sanitizeResult = a
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) getSanitizeArtifact() *sanitizeArtifact {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.sanitizeResult
|
||||
}
|
||||
|
||||
// handleSanitize builds a de-identified copy of the retained original upload and
|
||||
// returns the change preview. The file itself is fetched from GET /api/sanitize/download.
|
||||
func (s *Server) handleSanitize(w http.ResponseWriter, r *http.Request) {
|
||||
pkg := s.GetRawExport()
|
||||
if pkg == nil || pkg.Source.Kind != "file_bytes" {
|
||||
jsonError(w, "sanitize is only available for an uploaded archive or log file", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if !sanitize.CanSanitize(pkg.Source.Filename) {
|
||||
jsonError(w, "this file format cannot be sanitized in place", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(pkg.Source.Data)
|
||||
if err != nil {
|
||||
jsonError(w, "cannot read the original file bytes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := sanitize.Sanitize(pkg.Source.Filename, data)
|
||||
if err != nil {
|
||||
jsonError(w, "sanitize failed: "+err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
|
||||
s.setSanitizeArtifact(&sanitizeArtifact{Data: res.Data, Filename: pkg.Source.Filename})
|
||||
|
||||
jsonResponse(w, map[string]any{
|
||||
"filename": filepath.Base(pkg.Source.Filename),
|
||||
"input_size": len(data),
|
||||
"output_size": len(res.Data),
|
||||
"total_replaced": res.TotalReplaced,
|
||||
"changes": res.Changes,
|
||||
"skipped_binary": res.SkippedBinary,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSanitizeDownload(w http.ResponseWriter, r *http.Request) {
|
||||
art := s.getSanitizeArtifact()
|
||||
if art == nil {
|
||||
jsonError(w, "no sanitized file ready; run POST /api/sanitize first", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// Always octet-stream so the browser saves rather than renders it.
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(art.Filename)))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(art.Data)))
|
||||
_, _ = w.Write(art.Data)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
)
|
||||
|
||||
func leakDumpBytes(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var tarBuf bytes.Buffer
|
||||
tw := tar.NewWriter(&tarBuf)
|
||||
body := []byte("domain corp.acme.ru\nracdomain=corp.acme.ru\nTimeZone=Europe/Moscow\n")
|
||||
tw.WriteHeader(&tar.Header{Name: "onekeylog/configuration/conf/resolv.conf", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg})
|
||||
tw.Write(body)
|
||||
tw.Close()
|
||||
var gz bytes.Buffer
|
||||
gw := gzip.NewWriter(&gz)
|
||||
gw.Name = "dump.tar"
|
||||
gw.Write(tarBuf.Bytes())
|
||||
gw.Close()
|
||||
return gz.Bytes()
|
||||
}
|
||||
|
||||
func serverWithUpload(t *testing.T, filename string, data []byte) *Server {
|
||||
t.Helper()
|
||||
s := &Server{}
|
||||
s.SetResult(&models.AnalysisResult{Filename: filename})
|
||||
s.SetRawExport(&RawExportPackage{
|
||||
Source: RawExportSource{
|
||||
Kind: "file_bytes",
|
||||
Filename: filename,
|
||||
MIMEType: "application/gzip",
|
||||
Encoding: "base64",
|
||||
Data: base64.StdEncoding.EncodeToString(data),
|
||||
},
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
func TestHandleSanitize_PreviewAndDownload(t *testing.T) {
|
||||
s := serverWithUpload(t, "dump.tar.gz", leakDumpBytes(t))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleSanitize(rec, httptest.NewRequest("POST", "/api/sanitize", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("preview status %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var preview struct {
|
||||
TotalReplaced int `json:"total_replaced"`
|
||||
Changes []struct {
|
||||
Category string `json:"category"`
|
||||
} `json:"changes"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &preview); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.TotalReplaced == 0 {
|
||||
t.Fatal("nothing redacted")
|
||||
}
|
||||
|
||||
dl := httptest.NewRecorder()
|
||||
s.handleSanitizeDownload(dl, httptest.NewRequest("GET", "/api/sanitize/download", nil))
|
||||
if dl.Code != http.StatusOK {
|
||||
t.Fatalf("download status %d", dl.Code)
|
||||
}
|
||||
if cd := dl.Header().Get("Content-Disposition"); !strings.Contains(cd, `filename="dump.tar.gz"`) {
|
||||
t.Fatalf("bad Content-Disposition: %q", cd)
|
||||
}
|
||||
gr, err := gzip.NewReader(bytes.NewReader(dl.Body.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("output not gzip: %v", err)
|
||||
}
|
||||
inner, _ := io.ReadAll(gr)
|
||||
tr := tar.NewReader(bytes.NewReader(inner))
|
||||
h, err := tr.Next()
|
||||
if err != nil || h.Name != "onekeylog/configuration/conf/resolv.conf" {
|
||||
t.Fatalf("inner tar broken: %v %v", h, err)
|
||||
}
|
||||
clean, _ := io.ReadAll(tr)
|
||||
if bytes.Contains(clean, []byte("acme.ru")) || bytes.Contains(clean, []byte("Europe/Moscow")) {
|
||||
t.Fatalf("leak survived sanitize:\n%s", clean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSanitize_RejectsNonFileSource(t *testing.T) {
|
||||
s := &Server{}
|
||||
s.SetResult(&models.AnalysisResult{})
|
||||
s.SetRawExport(&RawExportPackage{Source: RawExportSource{Kind: "live_redfish"}})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleSanitize(rec, httptest.NewRequest("POST", "/api/sanitize", nil))
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status %d, want 422", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSanitizeDownload_NotReady(t *testing.T) {
|
||||
s := &Server{}
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleSanitizeDownload(rec, httptest.NewRequest("GET", "/api/sanitize/download", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ type Server struct {
|
||||
result *models.AnalysisResult
|
||||
detectedVendor string
|
||||
rawExport *RawExportPackage
|
||||
sanitizeResult *sanitizeArtifact
|
||||
convertJobs map[string]struct{}
|
||||
convertOutput map[string]ConvertArtifact
|
||||
|
||||
@@ -89,6 +90,8 @@ func (s *Server) setupRoutes() {
|
||||
s.mux.HandleFunc("GET /api/firmware", s.handleGetFirmware)
|
||||
s.mux.HandleFunc("GET /api/parse-errors", s.handleGetParseErrors)
|
||||
s.mux.HandleFunc("GET /api/privacy-scan", s.handleGetPrivacyScan)
|
||||
s.mux.HandleFunc("POST /api/sanitize", s.handleSanitize)
|
||||
s.mux.HandleFunc("GET /api/sanitize/download", s.handleSanitizeDownload)
|
||||
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
|
||||
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
|
||||
s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator)
|
||||
|
||||
Reference in New Issue
Block a user