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
+1
View File
@@ -24,6 +24,7 @@ All modes converge on the same normalized hardware model and exporter pipeline.
- Reanimator export and batch conversion workflows
- Embedded `pci.ids` lookup for vendor/device name enrichment
- Customer-data / anonymization scan of ingested sources (detection only; see `docs/privacy-scan.md`)
- In-place, length-preserving de-identification of an uploaded dump (`docs/log-sanitization.md`)
## Current vendor coverage
+21
View File
@@ -159,6 +159,27 @@ Returns the customer-data scan for the current dataset (`models.PrivacyScan`:
Returns `{ "loaded": false }` when nothing is loaded or the scan produced
nothing. Detection only; see `docs/privacy-scan.md`.
### `POST /api/sanitize`
Builds a de-identified copy of the current uploaded file (customer-data spans
replaced with same-length neutral fillers, format and timestamps preserved) and
returns the change preview:
```json
{ "filename": "...", "input_size": 0, "output_size": 0,
"total_replaced": 0, "changes": [{"path","category","count","sample_before","sample_after"}],
"skipped_binary": ["member (binary - sanitize manually)"] }
```
`422` when the current dataset is not an uploaded file (live Redfish / snapshot)
or the format cannot be rebuilt. See `docs/log-sanitization.md`.
### `GET /api/sanitize/download`
Streams the file produced by the last `POST /api/sanitize`
(`Content-Disposition: attachment; filename="<original>"`,
`Content-Type: application/octet-stream`). `404` if none is ready.
### `GET /api/parsers`
Returns registered parser metadata.
+1
View File
@@ -9,6 +9,7 @@
| `GET /api/export/reanimator` | JSON | Reanimator hardware payload |
| `GET /chart/current?print=true` | HTML (auto-print) | Print/PDF version of the report — opens in new tab, calls `window.print()` |
| `POST /api/convert` | async ZIP artifact | Batch archive-to-Reanimator conversion |
| `POST /api/sanitize` + `GET /api/sanitize/download` | same-format file | De-identified copy of the upload (`docs/log-sanitization.md`) |
## Raw export
+2
View File
@@ -8,6 +8,8 @@ Defined in `cmd/logpile/main.go`:
|------|---------|---------|
| `--port` | `8082` | HTTP server port |
| `--file` | empty | Preload archive file |
| `--sanitize` | empty | De-identify customer data in this file and exit (`docs/log-sanitization.md`) |
| `--sanitize-out` | empty | Where `--sanitize` writes its result (default: overwrite the input) |
| `--version` | `false` | Print version and exit |
| `--no-browser` | `false` | Do not auto-open browser |
| `--hold-on-crash` | `true` on Windows | Keep console open after fatal crash |
+42
View File
@@ -1937,3 +1937,45 @@ token, remediation hint).
`TestIsAllowlistedDomain`, `TestRegistrableDomain`, `TestGuessCustomers_*`),
`internal/server` (`TestHandleGetPrivacyScan_*`,
`TestBuildRawExportBundle_*PrivacyReport*`).
---
## ADL-067 — In-place, length-preserving log sanitization
**Date:** 2026-09-02
**Context:** ADL-066 detects customer-identifying data but the operator still
redacts by hand before forwarding a dump. The redaction has to keep the file
usable and unremarkable: same format, same archive structure, same embedded
timestamps, no "sanitized by" marker.
**Decision:** `internal/sanitize` rewrites the spans `internal/privacy` finds
with **same-length neutral fillers**, in place.
- Fillers: hostname/e-mail/AD/cert → letters and digits to `x`, punctuation
kept (`sigma.sbrf.ru` → `xxxxx.xxxx.xx`); public IP → digits to `0`
(`93.184.216.34` → `00.000.000.00`, `net.ParseIP` → nil); timezone name →
a same-length valid neutral IANA zone from a curated `len → zone` table
(`Europe/Moscow` → `Etc/Universal`); UTC offset → zeros (`180` → `000`).
Timestamps are never recomputed.
- Because member payload length never changes, tar/zip entry headers,
checksums, names, modes and mtimes are byte-identical; for `.gz`/`.zip` only
the compression layer is rebuilt. Uncompressed `.tar`/`.sds`/plain text with
nothing to redact come out byte-for-byte identical.
- Detection and redaction share one matcher: `privacy.FindSpans`. The fillers
are recognised by `isRedactionFiller` so a re-scan / second pass is a no-op.
- Not edited: binary members (`FRU.bin`, `localtime` tzdata, `redis-dump.rdb`,
DER certs), unreadable nested archives — listed in `Result.SkippedBinary`
for manual handling. `.gz`/`.zip` cannot be byte-identical (recompression).
ctime is not restorable on a CLI in-place edit.
- Surfaced by `POST /api/sanitize` (+ `GET /api/sanitize/download`), the
"Обезличить и скачать копию" button in the Customer-data panel, and
`logpile -sanitize <file>` (restores mtime/atime via `os.Chtimes`).
**Consequences:**
- Only formats the walker can rebuild losslessly are offered
(`sanitize.CanSanitize`): `.tar .sds .gz .tgz .zip .txt .log`. AHS is
reported, not edited.
- Re-parsing a sanitized dump yields the identical hardware inventory
(verified on Dell TSR, xFusion, Inspur onekeylog, H3C `.sds`).
- Full contract and rule list: `bible-local/docs/log-sanitization.md`.
- Tests: `internal/sanitize` (`TestRedactText_*`, `TestSanitize_Tar*`,
`TestSanitize_Zip_*`, `TestSanitize_BinaryMemberFlagged`,
`TestNeutralZonesAreValidAndSameLength`, `TestTZFiller`),
`internal/server` (`TestHandleSanitize_*`).
+1
View File
@@ -23,6 +23,7 @@ Keep top-level docs minimal and put maintained architecture/API contracts here.
| [07-exporters.md](07-exporters.md) | Raw export, Reanimator export, batch convert |
| [docs/hardware-ingest-contract.md](docs/hardware-ingest-contract.md) | Reanimator ingest schema mirrored locally |
| [docs/privacy-scan.md](docs/privacy-scan.md) | Customer-data / anonymization scan of ingested sources |
| [docs/log-sanitization.md](docs/log-sanitization.md) | In-place, length-preserving redaction of customer data |
| [08-build-release.md](08-build-release.md) | Build and release workflow |
| [09-testing.md](09-testing.md) | Test expectations and regression rules |
| [10-decisions.md](10-decisions.md) | Architectural Decision Log |
+82
View File
@@ -0,0 +1,82 @@
# Log sanitization
`internal/sanitize` produces a de-identified copy of an uploaded diagnostic
file. It is the redaction counterpart of the detection-only `internal/privacy`
scan (see `privacy-scan.md`). ADL-067.
## Contract
- **Same format, same structure.** Archive entry names, modes, uid/gid, and all
embedded timestamps (tar `ModTime`/`AccessTime`/`ChangeTime`, gzip `Name`/
`ModTime`/`OS`, zip `Modified`/`Extra`) are preserved. Entry order is kept.
- **Length-preserving replacement.** Every filler is the exact byte length of
the token it replaces, so tar/zip member sizes - and therefore the tar
headers and their checksums - are byte-identical. Untouched zip entries are
copied raw (`(*zip.Writer).Copy`).
- **Byte-identical when nothing changes.** An uncompressed `.tar`/`.sds`/`.txt`/
`.log` with no redactable data comes out equal to the input. `.gz`/`.zip`
cannot be byte-identical because the compression layer is always rebuilt
(member payloads and metadata still match; only the compressed stream and the
total size differ).
- **No marker.** Nothing is stamped into the file.
- **Idempotent.** The fillers are on the privacy allowlist (`isRedactionFiller`),
so a re-scan finds nothing and a second `Sanitize` is a no-op.
## Entry point
```go
sanitize.Sanitize(filename string, data []byte) (*sanitize.Result, error)
sanitize.CanSanitize(filename string) bool // .tar .sds .gz .tgz .zip .txt .log
```
`Result{ Data, Changes []Change, SkippedBinary []string, TotalReplaced }`.
Format is resolved by extension; nested archives (`.tar.gz` in a `.zip`, ...)
are rewritten recursively.
## Fillers
| Category | Example | Filler |
|----------|---------|--------|
| `domain`, `mgmt_subdomain`, `resolv`, `ad_ldap`, `nsupdate`, `collector`, `dhcp`, `cert` | `sigma.sbrf.ru` | letters/digits → `x`, punctuation kept → `xxxxx.xxxx.xx` |
| `email` | `admin@corp.acme.ru` | `xxxxx@xxxx.xxxx.xx` |
| `public_ip` | `93.184.216.34` | digits → `0`, dots kept → `00.000.000.00` (`net.ParseIP` → nil) |
| `timezone` name | `Europe/Moscow` | same-length valid neutral IANA zone from `neutralZoneByLen` (`Etc/Universal`, `Antarctica/McMurdo`, ...) |
| `timezone` offset | `SELTimeUTCOffset=180` | `000` |
| `timezone` abbr | `... MSK 2026` | `UTC` (len 3), else `x`-fill |
Timezone redaction changes the config value only; **event timestamps are never
recomputed**, so a re-analysis of the sanitized dump may read event times in the
wrong zone (the same trade-off as the KB manual cleanup).
`fru_location` spans are **not** redacted (often a serial / manufacturing code,
sometimes in a binary FRU area) - only reported by the scan.
Private IPs, `pool.ntp.org` and everything else on the privacy allowlist are
never touched. Vendor factory-template members (`*_tianyiyun`, `*.conf_bak`,
`raw_export.json`, ...) are skipped whole (`privacy.IsAllowlistedFile`).
## Not edited (`Result.SkippedBinary`)
Reported for manual handling, never modified:
- Binary members with a customer string in a printable run: `FRU.bin`
(Asset Tag), `redis-dump.rdb`, `SDR.dat`, SOL captures with control bytes,
`racsessioninfo` / `session_token`.
- `configuration/conf/localtime` - a real UTC tzdata blob is a different length,
so it cannot be swapped in place.
- Truncated / mis-named nested archives (copied verbatim).
- HPE `.ahs` (proprietary container).
## Surfaces
| Where | How |
|-------|-----|
| API | `POST /api/sanitize` runs it on the retained upload bytes and returns the preview JSON (`total_replaced`, `changes[]`, `skipped_binary[]`); `GET /api/sanitize/download` streams the file (`Content-Disposition: attachment; filename="<original>"`, `application/octet-stream`). `422` for live-Redfish / snapshot sources. |
| UI | "Обезличить и скачать копию" in the Customer-data panel (shown when `privacy-scan` reports `sanitizable: true`) → preview → download. |
| CLI | `logpile -sanitize <file> [-sanitize-out <file>]` - edits in place (or writes `-sanitize-out`), restores mtime/atime via `os.Chtimes`, prints the change summary. ctime reflects the edit. |
## Limits
- Whole file is held in memory and rebuilt: `maxInputBytes` 800 MiB,
`maxMemberBytes` 1 GiB per decompressed member.
- Fixtures and tests use `acme.ru` / `corp.acme.local` - never a real customer
domain, same rule as the privacy scan.
+7
View File
@@ -108,6 +108,13 @@ Reference data, not vendor-detection logic: RFC 2606/5737 names, `pool.ntp.org`
`foobar.edu` / `issue.net` (stock `hosts.allow` / sshd banner), and factory
default values (`Asia/Shanghai`, `To Be Filled By O.E.M.`, `NULL`, `0.0.0.0`).
## Redaction
`privacy.FindSpans(line, certFile)` is the matcher shared with
`internal/sanitize`, which rewrites the same spans in place with same-length
neutral fillers (see `log-sanitization.md`). The fillers are recognised by
`isRedactionFiller` so a scan of a sanitized file is clean.
## Customer guess (`customer.go`)
Findings in `domain`, `resolv`, `ad_ldap`, `cert`, `nsupdate`,