dw darren / blog v2026.10
Back to blog
GET /v1/posts/cuddio-hide-secrets-and-pii-before-pasting-logs-into-ai 200 OK · 8 min read

Cuddio: Hide Secrets and PII Before You Paste Logs into AI

The Cuddio command-line tool replacing email addresses, card numbers and passwords in a log with placeholders such as EMAIL_1 before it is pasted into an AI chat

A payment job fails at nine in the morning. The quickest way to find out why is to paste the last fifty lines of the log into an AI chat and ask. Those fifty lines hold a customer's email address, their IP address, part of a card number and, three lines down, a DB_PASSWORD that someone logged during a config dump last year. Every one of those values has just left your machine.

I do this kind of debugging every day, and I wanted the habit to be safe by default rather than something I had to remember. So I built Cuddio, Welsh for "to hide": a small Go command-line tool that hides secrets and personal data in text, logs, .env files and SQL dumps before you share them, and puts the real values back into the answer afterwards. It runs entirely on your machine and never makes a network connection.

Why *** is the wrong answer

The obvious fix is to mask everything with stars. It works for keeping data private and ruins the debugging. If every email becomes ***, the AI cannot tell that the failing request and the retry came from the same customer, or that two different customers hit the same IP address. The relationships between values are often the whole clue.

Cuddio replaces each value with a placeholder that is keyed by the value itself. The same email address gets the same placeholder every time it appears, and a different address gets a different number:

$ cuddio app.log
2026-10-28 09:14:02 ERROR payment failed for <EMAIL_1> from <IP_1>
2026-10-28 09:14:03 ERROR retry for <EMAIL_1>, card <CARD_1>
DB_PASSWORD=<SECRET_1>

The AI can still see that one customer failed twice from one address with one card. It just never learns who they are. The labels matter too: <CARD_1> tells the model what kind of thing was there, so it can still say "the card was declined" rather than guessing.

Fakes for when a placeholder will not do

Placeholders break anything that validates its input. If you want to load a cleaned dump into a local database or run it through a parser, --mode fake swaps in realistic values from ranges reserved for fiction and testing: Ofcom's drama phone numbers, RFC 5737 documentation IP addresses, published test card numbers, QQ National Insurance numbers and ZZ99 postcodes.

$ cuddio --mode fake app.log
2026-10-28 09:14:02 ERROR payment failed for user1@example.invalid from 192.0.2.1
2026-10-28 09:14:03 ERROR retry for user1@example.invalid, card 4242 4242 4242 4242
DB_PASSWORD=uyayei6-pbk-ggsflv

A fake is never derived from the value it replaces, so it cannot leak anything about the original. Because every range is reserved, a fake can never turn out to be someone's real phone number either.

SQL dumps need a tokeniser, not a regex

Logs are line-shaped, and line-by-line pattern matching works well on them. Cuddio has 242 rules: 29 of its own for secrets and UK-flavoured personal data, and 213 derived from gitleaks for vendor API keys. Card numbers must pass a Luhn check and generic secrets must look random enough, which keeps order numbers and placeholder text from being flagged.

A database dump is different. The most sensitive values in it often look like nothing at all. 'Jo Bloggs' matches no pattern; it is a name only because it sits in a column called name. So Cuddio does not run regexes over SQL. It has a streaming tokeniser for MySQL, PostgreSQL and SQLite that understands quoting, escapes, PostgreSQL's dollar quotes and COPY blocks. It tracks which table and column each value belongs to:

$ cuddio users.sql
CREATE TABLE `users` (
  `id` int NOT NULL,
  `name` varchar(255) NOT NULL,
  `email` varchar(255) NOT NULL,
  `phone` varchar(20) DEFAULT NULL,
  `notes` text
);
INSERT INTO `users` VALUES (1,'<NAME_1>','<EMAIL_1>','<PHONE_1>','Called from <IP_1>, prefers email');

Sensitive columns are replaced whole, while free-text columns like notes still get the pattern rules, so the IP address inside the sentence is caught. The CREATE TABLE lengths are recorded too, so fake mode cuts a fake to fit varchar(20). Strict-mode MySQL and PostgreSQL reject over-long values, so this is what keeps the cleaned dump loadable.

Testing that the output is still valid SQL

"The output looks right" is not good enough for a dump you might load into a database. The test suite feeds every cleaned fixture back through real parsers: TiDB's MySQL parser, PostgreSQL's own parser compiled to WebAssembly, and a pure Go SQLite that actually loads the dump. None of them need cgo or a database server, so the tests run anywhere Go does. The dialect is detected from the first marker in the file, and the tokeniser streams, so a gigabyte dump never has to fit in memory.

Putting the real values back

Hiding is only half the loop. The AI's answer comes back full of <EMAIL_1> and <CARD_1>, and you want to act on it. With --map, Cuddio keeps the placeholder mapping, and cuddio restore puts the values back:

$ cuddio --map app.log > clean.log
# paste clean.log into the AI, copy its answer, then:
$ cuddio restore answer.txt
The retry for jo.bloggs@example.com failed because card 4111 1111 1111 1111 was declined.
$ cuddio forget
Stored mappings deleted.

The mapping is the one place Cuddio stores real values, so it is treated carefully:

  • it lives in your user cache directory, readable only by you, and expires after an hour by default
  • expired or corrupt files are deleted on the next run, and a corrupt file's error never quotes its contents
  • a later --map run carries on the numbering, so a log and a dump pasted into the same chat never reuse a placeholder for different values
  • if the input already contains text that looks like one of your placeholders, Cuddio refuses to store the mapping rather than restore the wrong value

Local only, and proved by a test

A tool that handles secrets should not need trust on that point, so I made it testable. Cuddio has no telemetry and no update check, and one test in the suite inspects the compiled binary itself. It fails if any package imports networking code beyond URL parsing, and it runs go tool nm over the linked binary to fail on any dial, listen, DNS, HTTP or TLS symbol. Cobra's flag library imports net only to parse IP flags, which is why the check looks at what is linked rather than banning the import outright.

The same thinking runs through the error handling. No error message, warning or log line ever includes a value from your input, because an error message is exactly the thing people paste into an issue or a chat.

The clipboard, and one key to clean it

Most of my pastes start as a copy, so cuddio --clip cleans the clipboard in place. It works on Windows, WSL, macOS, Wayland and X11. On WSL that meant going through PowerShell with explicit UTF-8 rather than clip.exe, because clip.exe turns accented names into mojibake. When restore --clip writes real values back, it marks them so Windows clipboard history and cloud sync skip them. Bind it to a keyboard shortcut and redaction becomes copy, press a key, paste.

Using it in CI

The same engine makes a useful gate. --check exits with 1 when anything would be hidden, and --report counts by rule without printing a single value:

$ cuddio --check --report fixtures/support-ticket.log
RULE               COUNT
email              2
ip-address         1
payment-card       1
secret-assignment  1
total              5
5 values would be hidden

That is handy for test fixtures, example files and anything else that is meant to be safe to publish. Rules, allowlists, extra sensitive SQL columns and custom patterns live in a .cuddio.yaml that sits in the repository:

allowlist:
  values: [hello@example.com]
sql:
  columns: [notes]
  tables:
    orders: [delivery_instructions]
rules:
  custom:
    - id: internal-host
      category: secret
      label: HOST
      pattern: '[a-z]+\.internal\.example\.com'

What it does not do

Redaction is best effort, and I would rather say so plainly than oversell it. Cuddio does not find names in free text: "Jo rang about her order" goes through untouched, because no pattern can tell a name from any other word without guessing. It only knows a column is sensitive by its name, so an oddly named column needs adding to the config. Reviewing my own code turned up a few common names it still misses, such as surname and birth_date, which are next on the list. It also runs on a single core today, at around 2 MB a second on a busy log, which is fine for a paste and slow for a large dump.

Read what you are about to share. Cuddio removes the obvious and the tedious, so the read-through can concentrate on what is left.

Summary

Masking with stars protects the data and destroys the debugging. Consistent, labelled placeholders keep the relationships the AI needs while hiding the values, and a restore step turns the answer back into something you can act on. SQL needs a real tokeniser because the context lives in the column, not the value. And a tool that handles secrets should prove that it stays offline, not just promise it.

Cuddio is a single static binary for Linux, macOS and Windows, open source under MIT, with a documentation site covering every rule and option. Install it with go install github.com/phoenix1331/cuddio@latest, Homebrew or Scoop. If you are working out how your team can use AI tools without leaking production data, get in touch via the contact section.