Skip to content

feat(llm): reversible anonymizer at the LLM boundary#3

Merged
maxie7 merged 4 commits into
mainfrom
feat/m7-anonymization
Jul 22, 2026
Merged

feat(llm): reversible anonymizer at the LLM boundary#3
maxie7 merged 4 commits into
mainfrom
feat/m7-anonymization

Conversation

@maxie7

@maxie7 maxie7 commented Jul 22, 2026

Copy link
Copy Markdown
Owner

What

Escalating an event sends the trigger line, the ring-buffer context, the template and the source name to the configured LLM endpoint — verbatim. Against a local Ollama nothing leaves the machine, which is why this was never a v1 blocker; the moment --llm-url points at a hosted provider, real production logs (IPs, emails, tokens, hostnames, home paths) go over the wire in the clear.

This adds an opt-in, reversible anonymizer at that boundary. Default off — with the flag absent the outgoing payload is byte-for-byte what it was before.

How

A decorator Backend (internal/llm/anonymizing.go) wraps the OpenAI-compatible backend and is constructed only when --llm-anonymize is on. It masks on the way out and restores on the way in, so the pipeline, the template state and the TUI keep the real values — you are triaging your own system in your own terminal.

The masker itself (internal/anonymize) is a pure package with no llm dependency, deliberately kept separate from the pipeline's template masker: the two mask for different reasons and change for different reasons.

  • Type-tagged placeholders<IP_1>, <EMAIL_1>, <TOKEN_1>, <HOST_1>, <UUID_1>, <USER_1>. The tag is load-bearing: the model needs to know "this is an IP" to suggest anything useful, so a generic <REDACTED> would gut the explanation. The same value maps to the same placeholder within a request.
  • One mapper per request, in memory only — never persisted, never shared, discarded once the response is restored.
  • Detectors run specific → general: JWT → AWS key → Bearer/sk- → URL and connection-string credentials → email → UUID → IPv6 → IPv4 → URL-authority host → bare infra host → home-directory username. An email contains a host, a URL contains a host and possibly credentials, an IP is digits and dots — the order decides who wins.
  • Fail-closed: if masking errors, the escalation is skipped and the card is marked explanation-unavailable. It never falls through to sending the raw payload.
  • Startup notice when --llm-url is non-local and masking is off: to stderr in --plain, and to the status bar in the TUI, where the alternate screen would otherwise wipe it.

Two deliberately narrow choices

Bare hostnames are masked only on private/infra suffixes (internal, local, lan, corp, svc, …), not on public TLDs. Go panics are full of github.com/..., golang.org/x/..., go.uber.org/zap, and since multiline grouping a whole goroutine dump is a single escalation — masking those would degrade the explanation for no privacy gain. The sensitivity lives in db-01.acme.internal, not in github.com. Hosts inside URLs, emails and connection strings are masked regardless of suffix.

Home paths mask only the username segment/home/<USER_1>/go/pkg/mod/... — because blanking the whole path would gut every Go stack frame.

A leak found in manual wire testing

The first implementation masked the trigger line correctly but leaked secret fragments through the Template field. The template reaches the anonymizer after the pipeline masker has rewritten it, so a secret arrives shape-broken — sk-abcdefghij0123456789XYZ as sk-abcdefghij<NUM>XYZ — and detectors anchored on an unbroken run of [A-Za-z0-9] missed it. The same applies to AWS keys and JWTs: a class of bug, not one regex.

Fixed in 23fb180: the four prefix-anchored secret detectors now treat a pipeline placeholder as another character of the token run. The tests feed real pipeline.Templatize output rather than hand-written mangled strings, since the coupling between the two maskers is what caused the bug in the first place.

Not a guarantee

The README says so plainly. Free-text logs can contain anything, so this is best-effort risk reduction. Two known gaps are documented rather than hidden: a bare public hostname in prose is not masked, and one value can pick up two placeholders (one from the raw line, one from the pre-masked template) — cosmetic, not a leak.

Verification

  • gofmt, go vet, golangci-lint (0 issues), go test ./..., go test -race ./..., go build ./... — all green.
  • The headline test asserts on the bytes the fake provider actually received, not on the anonymizer's return value.
  • Manual wire test: with the flag on, a line carrying an IP, email, sk- token, AWS key, JWT, infra hostname and home path produces a request body containing none of those literals; the same run without the flag shows all of them.

maxie7 and others added 4 commits July 21, 2026 21:51
On escalation the payload carried Trigger.Raw, the ring-buffer context lines,
the template, and the source name verbatim — real production log lines with
IPs, emails, tokens, hostnames, and home paths. Against local Ollama nothing
leaves the box; the moment --llm-url points at a remote provider it does.

Add an opt-in --llm-anonymize that masks the outgoing payload with stable,
type-tagged placeholders (<IP_1>, <HOST_1>, <TOKEN_1>, …) and restores the
model's answer on the way back, so the pipeline, template state, and TUI keep
the real values and only the provider sees placeholders. The token->original
map is per-request, in memory only, never persisted or shared.

Design:
- internal/anonymize: a pure, ordered detector set (specific -> general: JWT,
  AWS key, Bearer/sk-, URL/conn-string creds, email, UUID, IPv6, IPv4, URL-host,
  bare infra-suffix host, home-dir user). Bare hostnames mask only on
  private/infra suffixes (internal/local/svc/…): public TLDs would eat Go module
  paths and stack frames — a whole goroutine dump is now one escalation — for no
  privacy gain. Every char class excludes <>, so placeholders are inert to later
  detectors and to the fail-closed re-scan.
- Mask FAILS CLOSED: it re-scans its output for deterministic-shape residue and
  errors rather than leak; the decorator then skips the escalation (card shows
  unavailable) and never calls the inner backend — no raw send on error.
- A decorator Backend (internal/llm/anonymizing.go) owns the seam, wrapping
  OpenAICompatible only when the flag is on, so default-off is byte-for-byte
  unchanged.
- Config lives under llm: (anonymize, anonymize_suffixes) with --llm-anonymize
  and --llm-anonymize-suffix. When a non-local --llm-url is used with masking
  off, a one-line startup notice fires — to stderr in --plain, and to the TUI
  status bar (stderr would be wiped by the alternate screen).

The pipeline's own template masker is untouched and not merged with this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an Anonymization section to the README: what is masked, that hosts inside
URLs/conn-strings are masked regardless of TLD while bare public hostnames in
prose are deliberately left alone, that a masking failure skips the escalation,
and — plainly — that this is best-effort risk reduction and NOT a guarantee,
with the local-Ollama default needing none of it. List --llm-anonymize in the
flags table and tick the anonymization item under Epic M7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Template field reaches the anonymizer AFTER the pipeline's template masker
has rewritten it, so a secret arrives shape-broken:
sk-abcdefghij0123456789XYZ as sk-abcdefghij<NUM>XYZ, AKIAIOSFODNN7EXAMPLE as
AKIAIOSFODNN<NUM>EXAMPLE, a JWT as a run riddled with <NUM>. The detectors are
anchored on an unbroken run of [A-Za-z0-9], so they missed all three and the
surviving characters went over the wire in the clear. Found in manual wire
testing; a class of bug, not one regex.

The four prefix-anchored secret detectors now treat a pipeline placeholder as
another character of the token run. Where a length requirement would otherwise
break ({16,} after sk-, exactly {16} after AKIA/ASIA), the shortened form is
accepted only when the run provably contains a pipeline placeholder, rather
than lowering the threshold and widening prose false positives everywhere.

This does not weaken placeholder inertness: the alternation names the
pipeline's six UNTAGGED placeholders and demands '>' right after the name, so
it can never match this package's own numbered output (<IP_1> has '_' there),
and every widened detector stays anchored on a prefix no placeholder carries.
Asserted from both sides -- no detector matches, and residue() stays clean.

Tests feed the real pipeline.Templatize output rather than hand-written
mangled forms, since the coupling between the two maskers is what caused the
bug. All four cases fail before this change; the headline wire test now
carries pre-templated secrets and catches the leak on the recorded bytes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Masking at the LLM boundary sees both the raw line and the pipeline's
pre-masked template, so a host can pick up <HOST_1> from one and <HOST_2> from
the other. Cosmetic, not a leak -- but it weakens the "one host recurs" signal
enough to be worth stating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@maxie7 maxie7 self-assigned this Jul 22, 2026
@maxie7
maxie7 merged commit 2da31be into main Jul 22, 2026
1 check passed
@maxie7
maxie7 deleted the feat/m7-anonymization branch July 22, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant