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:
Mikhail Chusavitin
2026-09-02 18:05:27 +03:00
co-authored by Claude Sonnet 5
parent e74e01ad05
commit a63bb17438
25 changed files with 1742 additions and 66 deletions
+52
View File
@@ -1497,9 +1497,57 @@ async function loadPrivacyScan() {
if (findingsBox) findingsBox.style.display = privacyCollapsed ? 'none' : '';
}
const sanBox = document.getElementById('privacy-sanitize');
if (sanBox) {
sanBox.classList.toggle('hidden', !data.sanitizable);
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 = 'Обезличить и скачать копию'; }
}
section.classList.remove('hidden');
}
async function sanitizePreview() {
const btn = document.getElementById('privacy-sanitize-btn');
const prev = document.getElementById('privacy-sanitize-preview');
if (!btn || !prev) return;
btn.disabled = true;
btn.textContent = 'Обработка…';
let data;
try {
const resp = await fetch('/api/sanitize', { method: 'POST' });
data = await resp.json();
if (!resp.ok) throw new Error(data && data.error ? data.error : 'sanitize failed');
} catch (e) {
prev.innerHTML = `<div class="privacy-sanitize-err">${escapeHtml(String(e.message || e))}</div>`;
prev.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Обезличить и скачать копию';
return;
}
const changes = Array.isArray(data.changes) ? data.changes : [];
const skipped = Array.isArray(data.skipped_binary) ? data.skipped_binary : [];
let html = `<p><strong>${data.total_replaced}</strong> замен в <strong>${changes.length}</strong> файл(ах). ` +
`Формат, имена и даты записей сохранены; правки той же длины.</p>`;
if (changes.length) {
html += '<table class="parse-errors-table"><thead><tr><th>Категория</th><th>Кол-во</th><th>Файл</th></tr></thead><tbody>' +
changes.map(c => `<tr><td>${escapeHtml(c.category)}</td><td>${c.count}</td><td>${escapeHtml(c.path)}</td></tr>`).join('') +
'</tbody></table>';
}
if (skipped.length) {
html += `<div class="privacy-sanitize-warn"><strong>Не удалось отредактировать (обезличьте вручную):</strong><ul>` +
skipped.map(s => `<li>${escapeHtml(s)}</li>`).join('') + '</ul></div>';
}
html += `<button type="button" class="privacy-sanitize-dl" onclick="window.location='/api/sanitize/download'">Скачать обезличенную копию</button>`;
prev.innerHTML = html;
prev.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Пересобрать';
}
// The header + customer summary stay visible; only the findings table collapses.
let privacyCollapsed = true;
function togglePrivacy() {
@@ -1640,6 +1688,10 @@ async function clearData() {
if (privacyFindings) privacyFindings.style.display = 'none';
const privacyToggle = document.getElementById('privacy-toggle');
if (privacyToggle) { privacyToggle.textContent = '▼'; privacyToggle.style.visibility = ''; }
const privacySan = document.getElementById('privacy-sanitize');
if (privacySan) privacySan.classList.add('hidden');
const privacySanPrev = document.getElementById('privacy-sanitize-preview');
if (privacySanPrev) { privacySanPrev.innerHTML = ''; privacySanPrev.classList.add('hidden'); }
} catch (err) {
console.error('Failed to clear data:', err);
}