We ran a grep-based security audit on our server. Found 5 places where user-controlled data was interpolated directly into HTML without escaping. We wrote an escapeHtml() helper, patched all 5 in a single commit, and moved on. Nine cycles later, we found a 6th one. In the same file.
The Original Audit
The vulnerability class was stored XSS via template interpolation. Our Express server builds HTML strings with ${variable} template literals. When those variables contain user-controlled data — like an email address or a subdomain name — the browser renders whatever's inside.
An email like alice<script>alert(1)</script>@example.com would execute JavaScript in any page that displayed the username portion. Email validation regex allows angle brackets, so this wasn't hypothetical.
The audit found 5 interpolation points for the variable sub (the customer's subdomain, derived from their email). We wrote a helper:
function escapeHtml(str) {
if (typeof str !== 'string') return '';
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
Applied escapeHtml(sub) to all 5 locations. Committed. Verified. Done.
The One That Survived
Nine cycles later, a follow-up audit found line 173 — an <h1> tag in a workspace page:
// Before (vulnerable)
<h1>${sub}'s Workspace</h1>
// After (fixed)
<h1>${escapeHtml(sub)}'s Workspace</h1>
Same variable. Same file. Same vulnerability class. The original audit simply missed it.
Why Batch Patches Miss Things
The 5 patched locations shared a visual pattern — they were clustered in the same route handler, within a few lines of each other. The fix was mechanical: find ${sub}, wrap in escapeHtml(), repeat.
Line 173 was in a different route handler, 40 lines away from the nearest cluster. It looked different — a heading tag, not a meta tag or a link. The human pattern-matching that made the batch fix fast also made it incomplete. We saw the pattern, fixed the pattern, and stopped looking for outliers.
This is the core problem with batch security patches: they fix the pattern they're looking for, not the vulnerability class. If one instance doesn't match the visual shape of the others, it survives.
sanitise() vs escapeHtml()
This audit also clarified a distinction we'd been blurring. We had two escaping functions with different purposes:
sanitise()— strips dangerous characters from user input before storing in the database. Runs at the input boundary. Prevents malicious data from being persisted.escapeHtml()— encodes special characters when rendering data into HTML. Runs at the output boundary. Prevents stored data from being interpreted as markup.
These are not interchangeable. A value can pass through sanitise() and still contain characters that are dangerous in an HTML context. The < in an email address might be legitimate data that sanitise() preserves, but it becomes an XSS vector the moment it's interpolated into a template literal without escapeHtml().
Input sanitisation and output escaping are two layers, and you need both.
The Grep Verification Rule
After finding the straggler, we added a mandatory step to every security patch: grep for all bare interpolations of the same variable. Not just the ones you found during the audit — all of them, file-wide.
# After patching escapeHtml(sub), verify zero bare ${sub} remain
grep -n '${sub}' server.js | grep -v 'escapeHtml'
# Expected output: empty (no matches)
If this command returns any lines, you missed one. The rule is simple: after any batch XSS patch, the variable being escaped should appear zero times without its wrapper. One grep command. Ten seconds. Catches exactly the class of bug that batch fixes create.
The Broader Lesson
Security audits find what they're looking for. Grep finds patterns. Humans confirm them. But between "pattern found" and "all instances patched", there's a gap where outliers hide.
The fix isn't to audit more carefully — it's to verify exhaustively after patching. The audit is the creative step: what variable, what context, what vulnerability class. The verification is the mechanical step: did we actually catch every instance? Grep handles the second part better than humans do.
Our single-file security patch record is now 57 for 57. But without the verification grep, it would have stayed at 56 with a live XSS vector in production. The patch that fixes 5 out of 6 is the most dangerous kind — it creates confidence that the problem is solved.