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
This commit is contained in:
Mikhail Chusavitin
2026-09-03 10:02:02 +03:00
co-authored by Claude Sonnet 5
parent a63bb17438
commit f38fb2de69
10 changed files with 334 additions and 10 deletions
+8
View File
@@ -1096,3 +1096,11 @@ code {
.privacy-sanitize-err {
color: var(--crit-fg);
}
.privacy-sanitized-note {
padding: 6px 8px;
background: #eef7ee;
border: 1px solid #cfe6cf;
border-radius: 4px;
font-size: 0.88em;
}
+20 -5
View File
@@ -1441,7 +1441,9 @@ async function loadPrivacyScan() {
const findings = Array.isArray(data.findings) ? data.findings : [];
const customers = Array.isArray(data.customers) ? data.customers : [];
if (findings.length === 0 && customers.length === 0) {
const san = data.sanitized || null;
const alreadySanitized = !!(san && san.detected);
if (findings.length === 0 && customers.length === 0 && !alreadySanitized) {
section.classList.add('hidden');
return;
}
@@ -1451,7 +1453,8 @@ async function loadPrivacyScan() {
if (s.high) parts.push(`${s.high} high`);
if (s.medium) parts.push(`${s.medium} medium`);
if (s.low) parts.push(`${s.low} low`);
const lead = customers.length > 0 ? `likely ${customers[0].domain}` : `${findings.length} finding${findings.length > 1 ? 's' : ''}`;
const lead = alreadySanitized ? 'файл уже обезличен'
: (customers.length > 0 ? `likely ${customers[0].domain}` : `${findings.length} finding${findings.length > 1 ? 's' : ''}`);
title.textContent = `Customer data — ${lead}${parts.length ? ' · ' + parts.join(', ') : ''}`;
if (customers.length > 0) {
@@ -1499,11 +1502,23 @@ async function loadPrivacyScan() {
const sanBox = document.getElementById('privacy-sanitize');
if (sanBox) {
sanBox.classList.toggle('hidden', !data.sanitizable);
sanBox.classList.toggle('hidden', !data.sanitizable && !alreadySanitized);
const prev = document.getElementById('privacy-sanitize-preview');
if (prev) { prev.classList.add('hidden'); prev.innerHTML = ''; }
const btn = document.getElementById('privacy-sanitize-btn');
if (btn) { btn.disabled = false; btn.textContent = 'Обезличить и скачать копию'; }
if (alreadySanitized) {
if (btn) btn.classList.add('hidden');
if (prev) {
prev.innerHTML = `<div class="privacy-sanitized-note">Файл выглядит уже обезличенным: ` +
`${san.markers} маркер(ов) в ${san.files} файл(ах).` +
((san.evidence && san.evidence.length)
? `<ul class="privacy-evidence">${san.evidence.map(e => `<li>${escapeHtml(e)}</li>`).join('')}</ul>`
: '') + `</div>`;
prev.classList.remove('hidden');
}
} else {
if (btn) { btn.classList.remove('hidden'); btn.disabled = false; btn.textContent = 'Обезличить и скачать копию'; }
if (prev) { prev.classList.add('hidden'); prev.innerHTML = ''; }
}
}
section.classList.remove('hidden');