Open a private security advisory on this repository (or contact the maintainer directly). Please do not open a public issue for a vulnerability.
anon-tool is a local tool. Its whole security value is a boundary, and the boundary is:
- The engine (
anon.py,deanon.py) is deterministic and stdlib-only: no network capability, no LLM, no telemetry. Anonymization that required a model would first have to hand the model the data it is supposed to protect. - The optional suggestion model is a separate process, never the engine: it runs only on loopback (the seam refuses any other endpoint before sending a byte), the container attaches it as a sidecar sharing the UI's network namespace so nothing new is published, it receives the text you paste — and, for the dictionary verification only, the conflicting surfaces with their candidate types, a bounded slice the operator asks for, never the whole dictionary and never the map — and it only proposes: the deterministic engine still writes every placeholder, and the advice on which duplicate to keep is applied by the operator's own save, which re-checks.
- The web UI has no authentication. That is acceptable only because it binds
127.0.0.1; the container publishes127.0.0.1:1407:1407, never1407:1407. Exposing the port to a network exposes your documents and your entity dictionary — that is a configuration decision, not a bug, and it is why the server refuses a non-loopback bind unless--allow-lanis passed explicitly. - Requests must carry the correct
Hostheader (an allowlist) and a per-run token delivered through a CSP nonce; a foreignOriginis refused when the header is present. This is what stands between a hostile page in your own browser and the API. - No client-supplied filesystem path is ever used: uploads land in a per-request temp directory
under generated names and are deleted afterwards — and a kill cannot run that cleanup, so each
work dir carries its server's pid and the next start sweeps the ones whose owner is gone (a dir
owned by a live pid is never touched). A redacted DOCUMENT is published in the private
store (
~/.anon/downloads/, mode 0600, pruned after an hour) and streamed from a token-named URL in 64 KB blocks: it is already redacted, so a leftover is not a leak, and it is never written next to the original./api/mapsexposes counts only, and the placeholder→real-value mapping is shown only behind an explicit, warned action with a timeout./api/audit-documentholds the same line for the redacted file: the upload is converted in a per-request temp directory that is deleted before the answer, the response carries verdict, counts and findings — a line number and a type, never a value — plus the extracted text so the line numbers point at something the operator can see, and the file is never published to the downloads store./api/maps/purgeis the perimeter's only deleting endpoint: it requires the explicit confirmation and a count that still matches the store, deletes exactly that counted set (a map created after the count survives; a stale count deletes nothing) and reports how many went. - size limits on uploads (160 MB,
ANON_MAX_UPLOAD_BYTES) and on/api/*requests per minute (token bucket,--rate-limit, default 120/min, 0 disables), no shell, noeval, no content or value logging. - the socket has a 30 s read timeout, so a client that announces a body and stalls cannot pin a
worker thread, and the listen backlog is 128 (the
socketserverdefault is 5) so a burst of simultaneous connections is not reset at the kernel before it can be accepted; the external converter runs in its own process group, is killed as a group, is bounded in time (300 s,ANON_CONVERT_TIMEOUT) and in output — the cap is applied WHILE the output is produced (ANON_CONVERT_MAX_BYTES, default 160 MB), never after buffering it. - the container runs as a non-root user, with a read-only root filesystem,
no-new-privileges, and only the data volume (/data) plus a tmpfs writable. - The one third-party component installed at build time (
firecrawl-anydoc) is pinned by digest (requirements-anydoc.txt,pip install --require-hashes), so a replaced wheel fails the build instead of running.
- any way to make the tool send document content, entity names or map values over the network;
- any way for a page from another origin to drive the API, read a response, or learn the token;
- any path that lets a request read or write outside the sandbox, or reach a subprocess argument in a dangerous position;
- any case where the tool reports success while leaving sensitive content in place, or restores values that do not belong to that document;
- any way the private store's backup or restore path writes outside its destination, accepts a payload that does not match its own manifest, or puts the real values into the archive in clear;
- any way the MCP seam (
mcp_anon.py:check/anonymize) hands back a real value instead of a verdict, the redacted text or a map id, or reaches the network — it is stdio only, it imports no network stack, and a value it returns is a bug, not a feature; - any way a clean/smudge filter runs under the repository: a
filterattribute rewrites a file between the working tree and the object database, so what is committed is not what was reviewed. Thepre-commithook (make hooks) refuses it; - any way the engine silently fails open without a visible indicator.
- contextual references ("the client from Ancona") — redaction is deterministic pattern matching plus a curated dictionary; a human read of the redacted document is still required;
- images/screenshots — pixels are not scannable, so a screenshot of a client document passes;
bash-style reads in the Pi guard are outside its default perimeter (--anon-guard=allextends it to shell output);- the guard's own false positives: the guard scans with the same engine, so a heuristic that
over-matches blocks a legitimate read (in this repository that happens on its own source and
docs). The remedy is
~/.anon/allow.txt, a session-only--anon-guard-allow '/a/*,/b/*'(or/anon-allow <path>), oranon.py --allow-glob GLOBfor one run — never a workaround; - a check slower than the timeout or bigger than the cap is refused, not read (fail-closed).
- the private store is not encrypted at rest (
DEC-0033):~/.anonis0700and its files are0600, but a process that can read the maps reads the real values. The encrypted copy is the backup (make backup), and it comes with its own declared gaps: no scheduler, no remote destination, no key management — the passphrase is never stored, so losing it loses the archive — and no AEAD tag, so a modified or damaged payload is caught by the manifest inside the archive while authenticity rests on the passphrase staying secret.ANON_BACKUP_PASSPHRASEis read once and removed before any child process is spawned, but a same-user process can still read it from the environment while the script is starting: the prompt is the path with no such window. - two runs cannot be confused: the tag is part of every placeholder (
[EMAIL-1-a3f9d1]), so a map from a different run leaves the tokens untouched and the restore fails loudly (exit 3) instead of substituting another client's values. The tag is 6 hex digits and is allocated against the tags already in use by the maps it can see (the default directory and the destination of--map): the width alone is not enough (scripts/tag-collision.pymeasures ~3% at 1 000 maps), so uniqueness is checked, not hoped for. Two runs allocating in the same instant are a declared residual race, and the check is per-directory: a map written under a differentANON_HOMEis invisible to it. A mangled tag in the document makes restoration fail, by design.
Only the current main (the latest tagged release) is supported.