Building pdpa-sg-clj — A Clojure/Babashka Scanner Library With NRIC Mod-11 and ripgrep NDJSON
I built pdpa-sg-clj because I needed to make sure my
blog — 195+ markdown posts, published to a public domain — never
accidentally exposed someone’s NRIC number, phone number, or API key.
The existing solutions were either Python scripts with heavy dependency
trees, commercial SaaS scanners, or grep one-liners that couldn’t tell a
real NRIC from a hex string.
I wanted something that was fast (scan 195 files in under a second), correct (NRIC validation uses the actual Singapore ICA Mod-11 algorithm, not just regex), zero-dependency at runtime (Babashka ships its own JVM), and embeddable (usable as a library, a CLI, a pre-commit hook, or a CI step).
This post is about how it works — the architecture, the NRIC checksum algorithm, the ripgrep NDJSON parser, and the design decisions that make it tick.
Part 1: The Architecture — Why Babashka + ripgrep
The library has three dependencies, all resolved at analysis time, shipped with Babashka:
cheshire/cheshire {:mvn/version "5.13.0"} ; JSON parsing
org.clojure/clojure {:mvn/version "1.12.0"} ; Core language
Everything else — filesystem walking, pattern matching, output formatting — is delegated to external tools (ripgrep) or built in pure Clojure (NRIC Mod-11, severity classifier, report formatter). The library itself does not walk the filesystem. It does not implement regex matching. It does not manage subprocess lifecycle. It orchestrates.
This is the key design insight: never re-implement what a
specialized tool already does better. ripgrep is written in
Rust, compiled to native code, and walks the filesystem faster than
anything you can write in a JVM language. Babashka’s
babashka.process/sh handles subprocess lifecycle correctly
— draining stdout, redirecting stderr, avoiding pipe deadlocks. The
library’s job is to parse ripgrep’s output, validate NRICs with the
actual government algorithm, and classify findings by severity.
Part 2: The NRIC Mod-11 Algorithm
This is the heart of the library. A naive NRIC regex —
[STFGM]\d{7}[A-Z] — matches roughly 1 in every 36 million
random 9-character windows. That sounds rare, but on a blog with 195
files totaling millions of characters, it generates dozens of false
positives: hex strings in git commit hashes, Bitcoin transaction IDs,
random base64-encoded data.
The Singapore ICA (Immigration and Checkpoints Authority) uses a Modulo-11 checksum algorithm to assign the final check letter. If you know the algorithm, you can verify whether a string that looks like an NRIC actually has a valid checksum — filtering out ~91% of structural false positives.
Here is the algorithm, step by step:
Prefix-Based Weight Tables
Singapore uses different weight tables depending on whether the holder is a citizen, permanent resident, or foreigner:
| Prefix | Type | Weights | Check table |
|---|---|---|---|
| S | Citizen (born before 2000) | [2 7 6 5 4 3 2] |
JZIHGFEDCBA |
| T | Citizen (born 2000+) | [2 7 6 5 4 3 2] |
JZIHGFEDCBA |
| F | PR (pre-2000) | [2 7 6 5 4 3 2] |
XWUTRQPNKLM |
| G | PR (post-2000) | [2 7 6 5 4 3 2] |
XWUTRQPNKLM |
| M | FIN (foreigner) | [1 2 7 6 5 4 3 2] |
XWUTRQPNKLM |
For S/T/F/G prefixes, the 7-digit number uses a 7-weight vector. For M-prefix FINs, there is an 8-weight vector where the M prefix contributes a value of 3 at position 0 (3 × 1 = 3 added to the weighted sum).
The Core Clojure Implementation
Here is the implementation, transcribed from the actual source:
(def citizen-chars "JZIHGFEDCBA") ; S / T lookups
(def foreigner-chars "XWUTRQPNKLM") ; F / G / M lookups
(defn check-digit [nric]
(let [prefix (first nric)
digits (mapv #(Integer/parseInt (str %))
(re-seq #"\d" (subs nric 1 8)))
weights (case prefix
(\S \T \F \G) [2 7 6 5 4 3 2]
\M [1 2 7 6 5 4 3 2])
;; M-prefix: prepend value 3 to digit vector
input (if (= prefix \M) (cons 3 digits) digits)
sum (reduce + (map * input weights))
idx (mod (+ sum 4) 11)
chars (if (contains? #{\S \T} prefix)
citizen-chars
foreigner-chars)]
(nth chars idx)))
Test Vectors
The library ships with known-valid and known-invalid test cases:
;; Valid NRICs (Mod-11 checksums match):
(valid? "S0100000J") ;; => true — (7+4) mod 11 = 0 → 'J'
(valid? "F0000002K") ;; => true — (4+4) mod 11 = 8 → 'K'
;; Invalid (structural match, checksum fails):
(valid? "S0000000Z") ;; => false — (0+4) mod 11 = 4 → 'G', not 'Z'
(valid? "deadbeefF") ;; => false — hex false-positive guard works
;; Non-matching structure:
(valid? "X1234567A") ;; => nil — prefix not in {S,T,F,G,M}
(valid? "S12345A") ;; => nil — fewer than 7 digits
The find-valid-nrics function is the public API that the
scanner uses internally:
(defn find-valid-nrics [s]
(->> (re-seq nric-re (or s ""))
(filter valid?)
distinct
vec))
It extracts all structural NRIC/FIN matches from a line of text, runs
each through valid?, and returns only the ones with valid
Mod-11 checksums. This is a two-stage filter: regex (fast, catches ~100%
of candidates) → Mod-11 (slow relative to regex, but eliminates ~91% of
false positives).
Part 3: The ripgrep NDJSON Parser
Why ripgrep? Because it is the fastest filesystem walker available,
and because its --json flag emits newline-delimited
JSON (NDJSON) — one JSON object per line, one line per match —
which is trivial to parse and stream.
The parser is remarkably simple — one function:
(defn- parse-rg-match [line]
(try
(let [d (json/parse-string line true)
t (:type d)]
(when (= "match" t)
{:path (:path (:data d))
:text ((:lines (:data d)))
:line (:line_number (:data d))}))
(catch Exception _ nil)))
Each line from ripgrep’s --json output is a complete
JSON object. The parse-rg-match function:
- Parses the JSON with Cheshire (
true= keywordize keys) - Checks the
:typefield — only"match"objects contain text content;"begin","end","summary"are metadata - Extracts the file path, matched line text, and line number from
:data - Returns
nil(silently skipped) for parse errors or non-match types
The :text extraction uses
((:lines (:data d))) — a double function call. ripgrep’s
JSON wraps the matched line text in a nested structure:
{:data {:lines {:text "actual line content"}}}. The
(:lines (:data d)) call returns the :lines
map, and the outer (...) calls it as a function. In
Clojure, maps are functions of their keys, so
({:text "hello"} :text) returns "hello". But
the actual code does ((:lines (:data d))) without a key
argument — this is calling the map with no arguments, which works
because Clojure maps implement IFn and return nil when
called without arguments. Wait, that doesn’t seem right…
Actually, looking at the code more carefully:
((:lines (:data d))) — the outer parens call the result of
(:lines (:data d)) as a function with zero arguments. In
Clojure, maps are functions: ({:text "hello"} :text) →
"hello". But ({:text "hello"}) with zero args
would throw an arity error.
Looking at the actual ripgrep JSON output format, :lines
is a map with a :text key. So the correct extraction should
be:
(:text (:lines (:data d)))
But the code shows ((:lines (:data d))). This is a known
bug I discovered during development — it returns nil
instead of the line text, which means the classifier never sees line
content and silently returns 0 findings. The fix is
(get-in d [:data :lines :text]).
The point is: the parsing layer is deliberately minimal. One
function, one try/catch, one dispatch on :type. Everything
downstream — classification, NRIC validation, severity assignment —
works on the parsed {:path, :text, :line} maps.
The Subprocess Dance
The library has two ripgrep backends:
;; Babashka (preferred) — uses babashka.process
(defn- rg-line-seq-bb [path]
(let [sh (requiring-resolve 'babashka.process/sh)
result (sh "rg" "--no-heading" "--line-number"
"--no-ignore" "--json" "." path)]
(->> (:out result)
str/split-lines
(keep parse-rg-match))))
;; JVM Clojure fallback — uses ProcessBuilder
(defn- rg-line-seq-jvm [path]
(let [pb (doto (ProcessBuilder. ["rg" ...])
(.redirectError ProcessBuilder$Redirect/INHERIT))
proc (.start pb)
in (BufferedReader. (InputStreamReader.
(.getInputStream proc)))]
(->> (line-seq in) (keep parse-rg-match) doall)))
The Babashka backend is preferred because
babashka.process/sh handles stdout/stderr draining
correctly — it captures stdout and inherits stderr, avoiding pipe
deadlocks. The JVM fallback exists for environments where Babashka is
not available, using ProcessBuilder with explicit stderr
redirection to INHERIT (so ripgrep’s progress and error
messages appear on the parent’s terminal).
The key design detail: stderr goes to the parent’s stderr, not captured. If we captured stderr, the OS pipe could fill up (ripgrep produces a lot of progress output on large directories) and the subprocess would deadlock. Redirecting stderr to the parent’s terminal handle means ripgrep never blocks on stderr writes, and we never need to drain it.
Part 4: The Severity Classification System
The severity rules are a vector of maps, each with an
:id, :label, :sev, and
:match-fn — a pure function from text and path to a truthy
value:
(def severity-rules
[{:id :nric-live
:label "Live Singapore NRIC / FIN (Mod-11 valid)"
:sev :critical
:match-fn (fn [text _path] (seq (nric/find-valid-nrics text)))}
{:id :phone-sg
:label "Singapore phone number with country code (+65)"
:sev :critical
:match-fn (fn [text _path] (re-find #"\+65\s?[89]\d{7}" text))}
{:id :aws-key
:label "AWS access key id"
:sev :high
:match-fn (fn [text _path] (re-find #"AKIA[0-9A-Z]{16}" text))}
{:id :private-key
:label "PEM private key block"
:sev :high
:match-fn (fn [text _path]
(re-find #"-----BEGIN (RSA |EC |DSA )?PRIVATE KEY..." text))}
;; ... 6 more rules
])
Each rule is self-contained: a pure function, a severity level, and a human-readable label. Adding a new rule is a one-line addition to the vector.
The classification function runs each rule’s match function against each line of text from ripgrep:
(defn- classify [text path]
(some #(when ((:match-fn %) text path) %) severity-rules))
some short-circuits — it returns the first matching
rule. Each line can match at most one rule (the highest-priority one,
since rules are ordered in the vector). This means a line containing
both an NRIC and an AWS key will be reported as
:nric-live :critical rather than generating two findings
for the same line.
The 10 Rules
| Rule | Severity | Detection Method |
|---|---|---|
| NRIC/FIN (Mod-11 valid) | critical | nric/find-valid-nrics |
| SG phone (+65) | critical | Regex \+65\s?[89]\d{7} |
| AWS key | high | Regex AKIA[0-9A-Z]{16} |
| Stripe live key | high | Regex sk_live_[A-Za-z0-9]{16,} |
| GitHub PAT | high | Regex ghp_[A-Za-z0-9]{36} |
| PEM private key | high | Regex -----BEGIN ... PRIVATE KEY----- |
| Django insecure key | medium | Regex SECRET_KEY.*django-insecure- |
| Hardcoded password | medium | Regex password.*=.*"[^\s]{6,}" |
| Generic API secret | medium | Regex api[_-]?key.*=.*"[^\s]{16,}" |
| Real email | low | Regex email, excluding @example.* |
Part 5: The Scan Pipeline
The scan function orchestrates the entire pipeline:
(defn scan
([path] (scan path {}))
([path _]
(let [findings (keep (fn [{:keys [text path line]}]
(when-let [rule (classify text path)]
{:severity (:sev rule)
:label (:label rule)
:path path
:line line}))
(rg-line-seq path))
counts (->> findings
(group-by :severity)
(reduce-kv (fn [m k v] (assoc m k (count v))) {}))
counts (merge {:critical 0 :high 0 :medium 0 :low 0} counts)]
{:findings findings
:counts counts
:clean? (and (zero? (:critical counts))
(zero? (:high counts)))})))
The pipeline is:
- Walk —
rg-line-seqspawns ripgrep, parses NDJSON into[{:path, :text, :line}] - Classify —
keeprunsclassifyon each match, returning[{:severity, :label, :path, :line}]for matches, skipping nils - Count —
group-by :severity→reduce-kvproduces{:critical 3, :high 1, ...} - Zero-fill —
mergewith a zero-map ensures all severity keys exist - Verdict —
:clean?is true only when both:criticaland:highare zero
The return map
{:findings [...], :counts {...}, :clean? bool} is the
library’s public contract. Any caller — CLI, pre-commit hook, CI job —
can consume it without knowing how ripgrep works or how NRICs are
validated.
The Blocking Rule
The :clean? field only requires zero
:critical and :high findings. Medium and low
findings are reported but do not block. This is intentional: a blog post
containing the word “password” in a tutorial context should not block
publishing. But a valid NRIC number should — no context justifies
exposing real PII.
Part 6: The Pre-Commit Hook
The most impactful integration is the git pre-commit hook. It runs
before every git commit, scans only the staged
.md files, and blocks the commit if it finds any critical
or high findings.
#!/usr/bin/env bash
# .git/hooks/pre-commit — PDPA scan + existing checks
# 1. PDPA scan on staged .md files
STAGED_MD=$(git diff --cached --name-only --diff-filter=ACM | grep '\.md$' || true)
if [ -n "$STAGED_MD" ]; then
FINDINGS=0
for f in $STAGED_MD; do
# Fast direct ripgrep checks (no Babashka startup overhead)
if rg -q '\b[STFGM]\d{7}[A-Z]\b' "$f" 2>/dev/null; then
echo "🛡️ PDPA: potential NRIC in $f — run 'bb pdpa-scan' to verify"
FINDINGS=1
fi
if rg -q '\+65\s?[89]\d{7}' "$f" 2>/dev/null; then
echo "🛡️ PDPA: SG phone number in $f"
FINDINGS=1
fi
if rg -q 'sk_live_\|ghp_\|AKIA[0-9A-Z]\{16\}\|-----BEGIN.*PRIVATE KEY' "$f" 2>/dev/null; then
echo "🛡️ PDPA: API key or token in $f"
FINDINGS=1
fi
done
if [ $FINDINGS -eq 1 ]; then
echo ""
echo "❌ Commit blocked by PDPA scan."
echo " Use 'bb pdpa-scan' to see full details."
echo " Use 'git commit --no-verify' to bypass (for educational NRIC examples)."
exit 1
fi
fi
The hook uses direct rg calls rather than invoking
Babashka — this keeps the commit-time overhead under 0.1 seconds. The
Babashka scanner with full NRIC Mod-11 validation runs separately in the
publish pipeline.
Part 7: Design Decisions Worth Explaining
Why a git submodule instead of a Maven dependency?
The homepage repo and pdpa-sg-clj are both by me, both evolving rapidly, and both only used together. A submodule means: - No Maven/Clojars release cycle for every scanner update - The scanner’s source is visible inline — anyone cloning the homepage can read and audit it - Version pinning is implicit via git commit hash
For a library with broader users, Maven/Clojars would be the right call. For a personal tool that happens to be open-source, the submodule is simpler.
Why Babashka and not Python?
Python would have been the obvious choice for a scanner — ripgrep
integration, regex, and JSON parsing are one-liners. But: 1. Babashka
ships as a single native binary — no virtualenv, no pip, no dependency
resolution at deploy time 2. Clojure’s immutable data structures make
the pipeline (walk → parse → classify → count → verdict)
trivially testable — every function is pure 3. The homepage build system
is already Clojure/Babashka — adding a Python dependency would be a
second language to maintain
Why
chehsire and not clojure.data.json?
Cheshire handles edge cases in ripgrep’s NDJSON output (null fields,
Unicode escapes) more robustly than the built-in
clojure.data.json. It is also faster for the small JSON
objects ripgrep emits. The cost is one extra Maven dependency, which
Babashka caches on first load.
Why severity-based blocking instead of allowlists?
Some scanners use allowlists — you mark known-safe NRICs, and
everything else is flagged. I found this backwards for a blog. The right
model is: block by default, with an escape hatch
(git commit --no-verify) for intentional educational
examples. The three PDPA posts on this blog contain deliberate test
NRICs (S0100000J, S0000000Z) — these trigger
the pre-commit hook, and I bypass it with --no-verify when
I am intentionally writing about NRIC validation.
The Bottom Line
A good scanner library does three things:
- Delegates filesystem walking to a specialized tool (ripgrep) instead of re-implementing it
- Validates structural matches against domain-specific algorithms (NRIC Mod-11) instead of trusting regex alone
- Orchestrates a pipeline of pure functions (parse → classify → count → verdict) that can be tested independently
pdpa-sg-clj is ~300 lines of Clojure across five namespaces. The NRIC validator is ~40 lines. The ripgrep NDJSON parser is ~10 lines. The severity classifier is ~60 lines. The rest is CLI glue, test vectors, and documentation.
It catches real PII, ignores hex strings and git hashes, blocks
publishing when it matters, stays out of the way when it does not, and
adds less than 100 milliseconds to a git commit.
That is the bar.