Skip to content

fix: keep UUIDs intact in scrubbed text - #7

Merged
pavish merged 3 commits into
mainfrom
fix/keep-uuids-in-scrubbed-text
Nov 6, 2026
Merged

pavish merged 3 commits into
mainfrom
fix/keep-uuids-in-scrubbed-text

Conversation

@pavish

@pavish pavish commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

Summary

The catch-all scrub (scrubText) finds phone numbers inside UUIDs. A run of digits in an id matches the international phone pattern:

409cd08e-512e-4077-825e-001030608345  ->  409cd08e-512e-4077-825e-[REDACTED]

About 0.04% of random UUIDs are affected with the current patterns; 0.2.x affected about 0.2%. A damaged id no longer resolves and no longer matches the same id elsewhere in the data.

This PR keeps complete UUIDs (8-4-4-4-12 hex, any case, also inside longer ids such as loop-edge-<uuid>) out of the IBAN and phone patterns:

  • In scrubPlain, the IBAN and phone replacements run with each UUID swapped for a private-use marker. The UUIDs are restored afterwards.
  • Only these patterns are guarded, because only they can match digit runs inside a UUID.
  • Email: matched on the unmasked text as before, so a UUID used as an email's local part is pseudonymized together with the email.
  • Streets: matched on the unmasked text as before.
  • No detector pattern changes. PII next to a UUID is still found, and all phone formats, including hyphenated ones, still match.
  • URL components are decoded before they are scanned, so markers never reach URL encoding.

Limits:

  • Hex ids without dashes (e.g. 32-character hex ids) are not UUID-shaped and are not protected.
  • Text that already contains a marker code point (U+E100 to U+EFFF) is scanned without the guard, so its UUIDs can still be damaged as before.
  • In a single string, UUIDs after the 3,840th are not protected.

Performance

Added work. It happens only for strings that pass the existing NUMBER_CANDIDATE check (an IBAN or phone candidate):

  1. Every such string gets one extra regex test, UUID_HINT (no lookbehind).
  2. If that matches, there's a second test for an existing marker code point, then a pass of UUID_TOKEN (with lookbehinds) that builds a masked copy of the string.
  3. After the IBAN and phone replacements, a final pass puts the UUIDs back.

That is two to three extra full passes over the string, plus one extra string and a small array per string that contains a UUID. Strings without a candidate (most labels, timestamps and bare ids) run the same code as before.

Measured. Each variant ran in its own process; the numbers are the median of 5 runs. Node 22, one machine, synthetic strings plus 25 real stored configs.

Workload main This PR Difference
scrub, 100k labels without hyphen 37 ms 36 ms −4%
scrub, 100k ISO timestamps 28 ms 31 ms +11%
scrub, 100k bare UUIDs 40 ms 44 ms +9%
scrub, 100k texts with a UUID and a phone number 660 ms 850 ms +29%
anonymizeResponse, 16k mixed strings (as in performance.test.ts) 92 ms 89 ms −4%
scrub, 25 real stored flow-template configs, 200 times 310 ms 328 ms +6%

How to read the numbers:

  • Variance: two identical copies of main, measured the same way, differed by −15% to +4% on the small workloads. Differences of about ±15% on the 30–40 ms rows are not distinguishable from noise.
  • Timestamps and bare UUIDs (+11%, +9%): these mostly don't reach the new code. The difference is probably noise, but a small real cost isn't ruled out.
  • The clear cost: +29% on strings that contain both a UUID and an IBAN or phone candidate, about 1.9 µs extra per string. It grows with string length and with the number of UUIDs in the string.
  • Not measured: memory, very long strings, and strings with many UUIDs.

Test plan

  • npm run lint, npm test (99 existing + 5 new tests), npm run build
  • New tests in uuid-protection.test.ts:
    • 20,000 random UUIDs and known-affected ones stay intact;
    • PII next to a UUID is still pseudonymized;
    • a UUID in an email's local part is pseudonymized with the email;
    • a UUID in a URL path stays while the URL's email is removed;
    • idempotence, including text that already contains marker code points.
  • Differential run against main on 200,000 random strings (UUIDs, phones, IBANs, emails, streets, timestamps, URLs, numbers, glued and separated):
    • without a UUID the output is identical to main;
    • with UUIDs, every UUID stays intact and the rest matches main's output for the same text with each UUID replaced by a neutral character;
    • UUIDs that are part of an email or URL give the same output as main.
  • CI (Node 18, 20, 22, 24)

🤖 Generated with Claude Code

https://claude.ai/code/session_01E3AAifuwHuKGdoVpqZoQAo

Pavish Kumar Ramani Gopal and others added 3 commits November 5, 2026 10:37
The catch-all scrub scanned inside UUIDs, so a group of digits in an id
was taken for a phone number: `409cd08e-512e-4077-825e-001030608345`
became `409cd08e-512e-4077-825e-[REDACTED]`. With the current patterns
about 0.04% of random UUIDs are damaged; a damaged id no longer resolves
or matches the same id elsewhere.

scrubPlain now swaps every complete UUID for a private-use marker before
the detectors run and restores it afterwards. A UUID cannot hold PII, and
PII next to it is still found. Running per plain chunk keeps URLs working:
components are decoded before they are scanned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3AAifuwHuKGdoVpqZoQAo
Only the IBAN and phone patterns can match digit runs inside a UUID, and
none of them runs without a NUMBER_CANDIDATE hit. Masking now happens
inside that branch, behind a cheap UUID hint, so most strings (labels,
timestamps, bare ids) never reach it. Emails are matched on the unmasked
text again, so a UUID used as an email's local part is pseudonymized with
the email.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3AAifuwHuKGdoVpqZoQAo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3AAifuwHuKGdoVpqZoQAo
@pavish
pavish marked this pull request as ready for review November 5, 2026 11:30
@pavish
pavish merged commit d46c978 into main Nov 6, 2026
5 checks passed
@pavish pavish mentioned this pull request Nov 9, 2026
1 task done
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