pii-scan
Agent workflow
This is an executable audit, not a checklist. When a usable file is available, run the
bundled scanner instead of only explaining how someone could scan it.
- Identify the export the user wants checked. If several files are plausible and the
choice changes the audit, ask which one; otherwise use the clearly relevant file.
- Resolve
scripts/scan.py relative to this SKILL.md and run it with an available
Python 3 interpreter. In Claude Code, ${CLAUDE_SKILL_DIR} is this skill directory;
in other hosts use the skill path supplied by the host. Quote every path.
- Let column auto-detection run first. Add
--column NAME only when the user named a
column or the report shows that the wrong one was selected. Use --json only when the
user wants machine-readable output.
- Interpret exit codes exactly:
0 means the supplied export produced no findings, 1
means findings were produced, and 2 means the input could not support a trustworthy
scan. On 2, relay the actionable error and ask for the smallest correction; never
describe the file as clean.
- Summarize the highest-severity verified findings first, preserve the distinction between
! verified values and ? name-only signals, and give the first remediation step. Do
not reveal, reconstruct, or repeat an unredacted personal value.
Never modify the source export, upload it, or invent findings beyond the scanner's output.
If the user needs help preparing an export, read references/input-guide.md.
When to use this
Reach for it when someone asks any of these:
- "Are we sending PII to Google Analytics?"
- "We got a privacy complaint / a DPA question about our tracking."
- "We're about to switch on server-side tagging" — check what is already in the URLs first.
- "Audit our GA4 setup" — configuration audits do not read values, so they never find this.
- Before a migration, a new consent tool, or handing an analytics property to an agency.
Why it matters
Google's terms prohibit sending personally identifiable information to Google Analytics.
A violation can require data deletion and can put continued access to the service at risk.
It rarely happens deliberately.
It happens because a form submits over GET, or a tag-manager variable captures the whole
field, or a confirmation page puts the customer's name in the path. Nobody checks, because
checking means reading thousands of URLs by hand.
How to run it
python skills/pii-scan/scripts/scan.py <export> [--column NAME] [--json] [--fail-on critical|high|medium]
It accepts whatever the user actually has:
| Source |
How to get it |
| GA4 |
Reports > Engagement > Pages and screens, export to CSV. Or an Explore with page path and event parameters. |
| BigQuery (GA4 export) |
Any query returning page_location or event parameter values, exported as CSV or JSON lines. |
| Server logs |
The access log itself. The request column is picked up automatically. |
| Tag manager |
A Preview-mode export, or any list of URLs. |
| Anything else |
One URL per line in a text file. |
The column is auto-detected from the header. Pass --column when the file has an unusual
name for it, and --json when the output feeds something else.
Reading the output
Every finding carries a signal marker, and the distinction matters:
! value verified — the value itself is personal data. The email parses, the card
passes Luhn, the IBAN checksum is correct. This is not a guess.
? parameter name only — the parameter is called email or fname, and the value
was not independently verifiable. Worth investigating, not worth an incident report.
Severities: critical is verified personal data or a credential. high is a strong name
signal or a documented test card. medium is an uncorroborated pattern, or a PII-named
parameter that is currently empty — which still means the plumbing exists and will carry a
real value the first time someone fills that field in.
What to tell the user afterwards
Lead with the fix, not the count. The scanner prints remediation for each kind it found;
the ordering of those steps is the important part and it is always the same:
- Stop it at the source. A form that submits over GET, a variable that captures too
much, a redirect that carries a parameter through. Until that is fixed, everything else
is cleanup.
- Then configure the platform. GA4's Redact data setting for email and phone, and the
URL query parameter exclusion list. Both are second lines of defence — they only catch
what they recognise.
- Then decide about the data already collected. Card numbers, IBANs and credentials
are an incident, not a settings change: the value has also reached server logs, any CDN
in front of them, and the browser history of the person who typed it.
What it will not do
- It does not read your GA4 property. It reads a file. That is deliberate — the whole
point is that it needs no access grant, no OAuth, and no vendor subscription.
- It does not print unredacted values, in the table, the locations, or the JSON.
- It does not claim a finding is confirmed when only the parameter name looked suspicious.
- It cannot tell you the scan is complete. It tells you what is in the export you supplied.
A one-month export answers a one-month question.
- It does not recognise national identity number formats, because verifying them properly
is country-specific and a guess here would produce false alarms about salaries, invoice
numbers and order references.
Tests
python tests/test_pii_scan.py
The fixture carries twelve planted defects and the test asserts the scanner finds exactly
those and invents nothing. It also asserts that no personal value from the fixture appears
in full anywhere in the report — that test failed on first run and caught the scanner
reprinting a card number that sat in a URL path.
1---2name: pii-scan3description: Find personal data leaking into analytics URLs, page paths and event parameters. Use when auditing GA4, Google Tag Manager, server logs or any tracking export for emails, phone numbers, card numbers, IBANs, tokens or names that should never have reached an analytics platform. Runs on a CSV or log file you already have, needs no API access, and never prints an unredacted value.4---56# pii-scan78## Agent workflow910This is an executable audit, not a checklist. When a usable file is available, run the11bundled scanner instead of only explaining how someone could scan it.12131. Identify the export the user wants checked. If several files are plausible and the14 choice changes the audit, ask which one; otherwise use the clearly relevant file.152. Resolve `scripts/scan.py` relative to this `SKILL.md` and run it with an available16 Python 3 interpreter. In Claude Code, `${CLAUDE_SKILL_DIR}` is this skill directory;17 in other hosts use the skill path supplied by the host. Quote every path.183. Let column auto-detection run first. Add `--column NAME` only when the user named a19 column or the report shows that the wrong one was selected. Use `--json` only when the20 user wants machine-readable output.214. Interpret exit codes exactly: `0` means the supplied export produced no findings, `1`22 means findings were produced, and `2` means the input could not support a trustworthy23 scan. On `2`, relay the actionable error and ask for the smallest correction; never24 describe the file as clean.255. Summarize the highest-severity verified findings first, preserve the distinction between26 `!` verified values and `?` name-only signals, and give the first remediation step. Do27 not reveal, reconstruct, or repeat an unredacted personal value.2829Never modify the source export, upload it, or invent findings beyond the scanner's output.30If the user needs help preparing an export, read [references/input-guide.md](references/input-guide.md).3132## When to use this3334Reach for it when someone asks any of these:3536- "Are we sending PII to Google Analytics?"37- "We got a privacy complaint / a DPA question about our tracking."38- "We're about to switch on server-side tagging" — check what is already in the URLs first.39- "Audit our GA4 setup" — configuration audits do not read values, so they never find this.40- Before a migration, a new consent tool, or handing an analytics property to an agency.4142## Why it matters4344Google's terms prohibit sending personally identifiable information to Google Analytics.45A violation can require data deletion and can put continued access to the service at risk.46It rarely happens deliberately.47It happens because a form submits over GET, or a tag-manager variable captures the whole48field, or a confirmation page puts the customer's name in the path. Nobody checks, because49checking means reading thousands of URLs by hand.5051## How to run it5253```bash54python skills/pii-scan/scripts/scan.py <export> [--column NAME] [--json] [--fail-on critical|high|medium]55```5657It accepts whatever the user actually has:5859| Source | How to get it |60|---|---|61| GA4 | Reports > Engagement > Pages and screens, export to CSV. Or an Explore with page path and event parameters. |62| BigQuery (GA4 export) | Any query returning `page_location` or event parameter values, exported as CSV or JSON lines. |63| Server logs | The access log itself. The request column is picked up automatically. |64| Tag manager | A Preview-mode export, or any list of URLs. |65| Anything else | One URL per line in a text file. |6667The column is auto-detected from the header. Pass `--column` when the file has an unusual68name for it, and `--json` when the output feeds something else.6970## Reading the output7172Every finding carries a signal marker, and the distinction matters:7374- `!` **value verified** — the value itself is personal data. The email parses, the card75 passes Luhn, the IBAN checksum is correct. This is not a guess.76- `?` **parameter name only** — the parameter is called `email` or `fname`, and the value77 was not independently verifiable. Worth investigating, not worth an incident report.7879Severities: `critical` is verified personal data or a credential. `high` is a strong name80signal or a documented test card. `medium` is an uncorroborated pattern, or a PII-named81parameter that is currently empty — which still means the plumbing exists and will carry a82real value the first time someone fills that field in.8384## What to tell the user afterwards8586Lead with the fix, not the count. The scanner prints remediation for each kind it found;87the ordering of those steps is the important part and it is always the same:88891. **Stop it at the source.** A form that submits over GET, a variable that captures too90 much, a redirect that carries a parameter through. Until that is fixed, everything else91 is cleanup.922. **Then configure the platform.** GA4's Redact data setting for email and phone, and the93 URL query parameter exclusion list. Both are second lines of defence — they only catch94 what they recognise.953. **Then decide about the data already collected.** Card numbers, IBANs and credentials96 are an incident, not a settings change: the value has also reached server logs, any CDN97 in front of them, and the browser history of the person who typed it.9899## What it will not do100101- It does not read your GA4 property. It reads a file. That is deliberate — the whole102 point is that it needs no access grant, no OAuth, and no vendor subscription.103- It does not print unredacted values, in the table, the locations, or the JSON.104- It does not claim a finding is confirmed when only the parameter name looked suspicious.105- It cannot tell you the scan is complete. It tells you what is in the export you supplied.106 A one-month export answers a one-month question.107- It does not recognise national identity number formats, because verifying them properly108 is country-specific and a guess here would produce false alarms about salaries, invoice109 numbers and order references.110111## Tests112113```bash114python tests/test_pii_scan.py115```116117The fixture carries twelve planted defects and the test asserts the scanner finds exactly118those and invents nothing. It also asserts that no personal value from the fixture appears119in full anywhere in the report — that test failed on first run and caught the scanner120reprinting a card number that sat in a URL path.