Files
Mikhail ChusavitinandClaude Sonnet 5 f38fb2de69 feat(privacy): detect an already-sanitized source
Scan now reports PrivacyScan.Sanitized {detected, markers, strong, files,
evidence}. SanitizationMarkers recognises a value slot filled with one
repeated placeholder + separators (xxxxx.xxxx.xx, x@xxxx.xxxx.xx,
000.00.00.0, a decoy timezone) - it matches the shape, not the literal "x",
so evolving the redaction mechanism still trips it.

detected requires corroboration: strong>=2, or strong>=1 && markers>=3, or
markers>=4. A single filler-looking token is reported (markers:1) but never
asserted as sanitized, so a partial future pass or a coincidence does not
read as "done". 0.0.0.0 / 000 / UTC / Etc/UTC are too plausibly intentional
and do not count.

UI: the Customer-data panel shows "файл уже обезличен" and hides the
sanitize button when detected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jDYM1nnoZZ3vFz23DDaV1
2026-09-03 10:02:02 +03:00

181 lines
3.9 KiB
Go

// Package privacy scans ingested source files for customer-identifying and
// site-identifying data: DNS suffix, AD domain, NTP/DNS/syslog host names,
// timezone, e-mail, public IPs, TLS cert CN/SAN, FRU location fields.
//
// Detection only. Nothing here modifies the source. The rule catalogue is a
// port of the KB grep playbook; see bible-local/docs/privacy-scan.md.
package privacy
import (
"bufio"
"bytes"
"sort"
"strings"
"unicode/utf8"
"git.mchus.pro/mchus/logpile/internal/models"
)
// File is one unit of scanned content (an extracted archive member, or a
// serialized Redfish tree).
type File struct {
Path string
Content []byte
}
const (
maxFindings = 1000
maxExcerptRunes = 200
maxScanLineBytes = 8192
)
// Scan runs every rule over every text file and returns the aggregated report.
// Returns nil when there is nothing to scan (no text files).
func Scan(files []File) *models.PrivacyScan {
scanned := 0
seen := make(map[string]struct{})
var findings []models.PrivacyFinding
emit := func(f models.PrivacyFinding) {
if len(findings) >= maxFindings {
return
}
key := f.Category + "|" + f.Match + "|" + f.Path
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
findings = append(findings, f)
}
san := newSanTally()
for _, file := range files {
if isAllowlistedFilename(file.Path) {
continue
}
if !looksLikeText(file.Content) {
continue
}
scanned++
scanFileLines(file, emit, san)
}
if scanned == 0 {
return nil
}
sortFindings(findings)
report := &models.PrivacyScan{
FilesScanned: scanned,
Findings: findings,
Customers: guessCustomers(findings),
Summary: summarize(findings),
Sanitized: san.report(),
}
return report
}
func scanFileLines(file File, emit func(models.PrivacyFinding), san *sanTally) {
certFile := isCertFilename(file.Path)
sc := bufio.NewScanner(bytes.NewReader(file.Content))
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
ln := 0
for sc.Scan() {
ln++
line := sc.Text()
if len(line) > maxScanLineBytes {
line = line[:maxScanLineBytes]
}
scanLine(file.Path, line, ln, certFile, emit)
for _, m := range SanitizationMarkers(line) {
san.add(file.Path, ln, line, m)
}
}
}
func looksLikeText(b []byte) bool {
if len(b) == 0 {
return false
}
head := b
if len(head) > 8192 {
head = head[:8192]
}
if bytes.IndexByte(head, 0) >= 0 {
return false
}
// Tolerate a truncated multi-byte rune at the sampled boundary.
if !utf8.Valid(head) && !utf8.Valid(trimIncompleteRune(head)) {
return false
}
return true
}
func trimIncompleteRune(b []byte) []byte {
for i := 0; i < 3 && i < len(b); i++ {
if utf8.RuneStart(b[len(b)-1-i]) {
return b[:len(b)-1-i]
}
}
return b
}
func excerpt(line string) string {
line = strings.Map(func(r rune) rune {
if r == 0 || (r < 0x20 && r != '\t') {
return ' '
}
return r
}, line)
line = strings.Join(strings.Fields(line), " ")
line = strings.TrimSpace(line)
if utf8.RuneCountInString(line) <= maxExcerptRunes {
return line
}
r := []rune(line)
return string(r[:maxExcerptRunes]) + "..."
}
func severityRank(s string) int {
switch s {
case severityHigh:
return 0
case severityMedium:
return 1
default:
return 2
}
}
func sortFindings(f []models.PrivacyFinding) {
sort.SliceStable(f, func(i, j int) bool {
if f[i].Severity != f[j].Severity {
return severityRank(f[i].Severity) < severityRank(f[j].Severity)
}
if f[i].Category != f[j].Category {
return f[i].Category < f[j].Category
}
if f[i].Path != f[j].Path {
return f[i].Path < f[j].Path
}
return f[i].Match < f[j].Match
})
}
func summarize(f []models.PrivacyFinding) models.PrivacySummary {
s := models.PrivacySummary{Total: len(f), ByCategory: map[string]int{}}
for _, x := range f {
switch x.Severity {
case severityHigh:
s.High++
case severityMedium:
s.Medium++
default:
s.Low++
}
s.ByCategory[x.Category]++
}
return s
}