My Blog Now Scans Itself for PII Before Every Publish — Here's the Pipeline
I live in Singapore. My blog is hosted on Cloudflare Pages, served globally. If I accidentally publish a post containing someone’s NRIC number, a Singapore phone number, my own API keys, or a client’s email address — I’ve violated the Personal Data Protection Act. The fines start at S$1 million.
The obvious fix: don’t publish PII. The engineering fix: make it impossible to publish PII by putting a scanner in the pipeline.
What pdpa-sg-clj detects
pdpa-sg-clj is a Clojure/Babashka toolkit I built that wraps ripgrep with Singapore-specific detection rules:
| Severity | What it detects | Example |
|---|---|---|
| critical | Valid NRIC/FIN numbers | S0100000J (Mod-11 checksum verified) |
| critical | SG phone numbers with +65 | +65 9123 4567 |
| high | AWS access keys | AKIA... |
| high | Stripe live keys | sk_live_... |
| high | GitHub personal access tokens | ghp_... |
| high | PEM private key blocks | -----BEGIN RSA PRIVATE KEY----- |
Additional lower-severity detections:
| Severity | What it detects | Example |
|---|---|---|
| medium | Hardcoded passwords | password = "supersecret" |
| medium | Generic API secrets | API_KEY = "sk-..." |
| low | Real email addresses | [email protected] (not example/test domains) |
The NRIC detection is particularly careful — it
doesn’t just regex-match the S1234567A pattern. That would
generate false positives on hex strings, git hashes, and BSV transaction
IDs (all of which appear in my blog). Instead, it runs the
Singapore ICA Modulo-11 checksum algorithm to verify
the check digit is mathematically valid. A hex string like
deadbeefF matches the structural pattern but fails the
checksum — so it’s ignored.
The pipeline integration
The blog’s publish script (scripts/publish.bb) already
had a validate step that checks frontmatter, diagram syntax, and
paragraph length. I inserted the PDPA scan as Step 1.5
— after validation but before the expensive build:
The critical design decision: the scan runs on a single
post during publish, not the entire repo. When I run
bb scripts/publish.bb check-suspend-fix-automation-fish,
the scanner only opens that one .md file. It takes ~0.2
seconds instead of 3 seconds for a full repo scan. The full-repo scan is
available as bb pdpa-scan (no argument) for periodic
audits.
The blocking rule
Only critical and high findings block publishing:
(def clean? (and (zero? (:critical counts))
(zero? (:high counts))))
Medium and low findings are printed as warnings but don’t block. This
is a deliberate trade-off: my blog uses fake email addresses in code
examples ([email protected]), which the scanner correctly
ignores. But if I accidentally paste a real client email into a post, I
want to know — without the scanner blocking every post that contains an
@ sign.
How it’s wired
Three files make it work:
pdpa-sg-clj/— a git submodule pointing at the toolkit reposcripts/pdpa-scan.bb— a 70-line wrapper that callspdpa.scan/scanon a single post and formats the outputscripts/publish.bb— one new step (1.5) that calls the wrapper and blocks on critical/high findings
The bb.edn was updated to include
pdpa-sg-clj/src in its classpath and add
cheshire as a dependency (needed by pdpa.scan
for ripgrep JSON parsing).
Why a submodule and not a library dependency
I chose a git submodule over a Maven/Clojars dependency for one
reason: the scanner rules need to evolve with Singapore PDPA
amendments. The Safe NRIC rule, for example, changes on 31
December 2026. When I update the detection rules in
pdpa-sg-clj, a git submodule update --remote
pulls the latest rules into every project that uses it. No version bump,
no release cycle — the scanner is always current with the law.
What happens when the scanner finds something
If I accidentally write S1234567A in a post body (a
syntactically valid NRIC — the checksum matches), the pipeline
stops:
🛡️ PDPA SG Compliance Scan
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Scanning: draft-post.md
Findings: critical=1 high=0 medium=0 low=0
[CRITICAL] draft-post.md:42 — Live Singapore NRIC / FIN (Mod-11 valid)
❌ PDPA scan BLOCKED — fix findings above before publishing
Use: bb redact <file> (from pdpa-sg-clj submodule)
Or manually remove PII from the post.
The build never runs. The git push never happens. The PII never leaves my machine.
The fix is one of: - Redact it —
bb redact draft-post.md replaces the NRIC with
[REDACTED_NRIC] and creates a .redact.bak
backup - Replace it — substitute a fake NRIC like
S0000000Z (structurally valid but checksum-fails — the
scanner ignores it) - Remove it — just delete the
line
The 11-obligation checklist
The scanner handles obligation #5 (Protection — prevent unauthorized disclosure). But pdpa-sg-clj also ships a full 11-obligation checklist covering every requirement of the Singapore Personal Data Protection Act:
| # | Obligation | How my blog addresses it |
|---|---|---|
| 1 | Consent | No PII collected — no consent needed |
| 2 | Purpose Limitation | Contact form states purpose explicitly |
| 3 | Notification | Privacy policy published at /privacy |
| 4 | Accuracy | Static content, reviewed before publish |
| 5 | Protection | PDPA scanner in pipeline — this post |
| 6 | Retention Limitation | No user data stored (static site) |
Cross-border and governance obligations:
| # | Obligation | How my blog addresses it |
|---|---|---|
| 7 | Transfer Limitation (§26) | Cloudflare Pages serves globally; no user data transferred |
| 8 | Access & Correction | Contact DPO for any concerns |
| 9 | Withdrawal of Consent | No consent collected — no withdrawal needed |
| 10 | Data Breach Notification | 3-day PDPC window; monitoring via Cloudflare analytics |
| 11 | Accountability / DPO | DPO contact published, internal audit trail |
Most obligations are design-satisfied. For a static blog that doesn’t collect user data, most obligations are satisfied by design. The scanner covers the one vector where data could leak: my own writing.
The bottom line
The PDPA scan adds 0.2 seconds to my publish pipeline and prevents an entire class of compliance failures. It costs nothing to run. It blocks publishing when it finds something. It works because of three decisions:
- Run on single post during publish — fast enough to be in the critical path
- Block only on critical/high — doesn’t create noise from false positives
- Submodule, not library — scanner rules stay current with the law without release cycles
If you publish content in Singapore, the same pattern works for any static site generator. The scanner is a Babashka script that reads text files — it doesn’t care what generated them.
Built with pdpa-sg-clj v0.2.0 (MIT), Babashka 1.4, ripgrep. Singapore PDPA rules verified against 21 June 2026 amendments. Not legal advice — consult your DPO.