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
181 lines
3.9 KiB
Go
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
|
|
}
|