You just inherited a Node.js codebase. You have 30 minutes before the standup
where someone asks "how bad is it?" — this is the exact protocol: three ESLint
plugins, four shell commands, and a ranked heatmap that tells you more about the
codebase's security posture than its previous team knew in two years.
The same handful of patterns shows up in most inherited codebases —
string-concatenated SQL, secrets in source, MD5 where a password hash should be.
A traditional audit takes weeks, a consultant, and a 200-page PDF you'll file and
forget. You have one ESLint run, and it returns a measurable risk heatmap.
I ran exactly this protocol on a real inherited corpus — except the "departing
engineer" was an AI. On 2026-02-09 I had Gemini 2.5 Pro generate 140 Node.js
functions (database, auth, file, command, config tasks; 7 iterations each, no
security guidance) and pointed the same scan at the output. The heatmap:
102 of the 140 functions shipped with at least one vulnerability — 168 findings,
average CVSS 8.3. Top of the uniq -c ranking was detect-non-literal-fs-filename
(50 hits), then unpooled pg queries, child-process calls, and hardcoded
credentials in the query layer. If a human had handed me that repo on day one,
I'd have called it the worst codebase I'd inherited in a year. A model wrote it
at 36 seconds a function. (Run + numbers below.)
That's the uncomfortable part: the heatmap looks identical whether a tired senior
or a frontier model wrote the code. Here's the exact 30-minute protocol — and at
the end, the live Gemini run,
reproducible
command for command.
Disclosure up front: the linter grading every number here is my own product.
I wrote the rules and I'm holding the scorecard — read "vulnerable" as "what my
ruleset flags," not an independent verdict. The honest answer to that: every
command below prints your numbers, not mine. The inherited-service table
further down is illustrative — a shape, not a repo I'm quoting.Inherited-codebase series. This is the Node.js + PostgreSQL playbook. For
the same protocol run on a real NestJS service —
12 seconds of ESLint, 47 violations across 6 vulnerability classes
— see the framework-specific walkthrough.
Step 1 — install the layers (2 min)
Three plugins cover the highest-yield server-side risks: injection, secrets, and
crypto.
# npm (yarn: yarn add -D … · pnpm: pnpm add -D … · bun: bun add -d …)
npm install --save-dev eslint-plugin-secure-coding eslint-plugin-pg eslint-plugin-node-security
Versions at time of writing: eslint-plugin-secure-coding 3.3.3 ·
eslint-plugin-pg 1.4.7 · eslint-plugin-node-security 4.4.2 (2026-07-28). Pin
them in CI — a rule added between runs moves the count with nothing in the
codebase changing.
New to these plugins? The
eslint-plugin-secure-coding getting-started
walks through the full rule set in five minutes; this article is the 30-minute
triage you run once all three are wired.
Step 2 — configure for maximum detection (3 min)
// eslint.config.mjs — `configs` is a NAMED export on every plugin
import { configs as secureCoding } from "eslint-plugin-secure-coding";
import { configs as pg } from "eslint-plugin-pg";
import { configs as nodeSecurity } from "eslint-plugin-node-security";
export default [
secureCoding.strict, // the full secure-coding set, as errors — maximal for a scan
pg.recommended,
nodeSecurity.recommended,
];
strict turns the whole secure-coding rule set on as errors — including the
experimental and opinionated rules — which is exactly what you want for a first
pass, where false positives
are cheaper than missed risk. But know your noise floor before you trust the
count. Our own Wild-corpus scorecard (22 OSS repos, 1.8M LOC; generated
2026-05-17) shows where the volume lives: secure-coding/no-unlimited-resource-allocation
fires 474 times across 19 repos and node-security/no-buffer-overread 136
times — and that second rule has no synthetic-fixture coverage, which means we
publish no precision
number for it at all. High volume, unmeasured accuracy: exactly the row you don't
put on a slide. The injection / secrets / crypto rules this article ranks first
are the measured ones.
So the triage rule is simple: read the heatmap top-down and discount those two
rows — or start from recommended-strict (the recommended set with every rule
promoted to error, no experimental rules) for a quieter first pass. Why two loud
rules can dominate a raw count even when most rules are tight is
the base-rate problem.
Step 3 — run it to JSON (5 min)
npx eslint . --format=json > security-audit.json
A finding carries the CWE,
the OWASP Top 10
category, a CVSS, the severity, and the compliance tags — the audit evidence, in
the message:
src/utils/crypto.js
42:18 error 🔒 CWE-327 OWASP:A04-Cryptographic CVSS:7.5 | Use of weak hash algorithm: md5. md5 is cryptographically broken and unsuitable for security purposes. | CRITICAL [PCI-DSS,HIPAA,ISO27001,NIST-CSF]
Fix: Replace with sha256: crypto.createHash("sha256").update(data)
(The CLI also appends the rule's doc URL to the Fix: line; trimmed here.)
Step 4 — build the heatmap (20 min)
Rank the findings by rule. This one line is the whole heatmap:
jq -r '.[].messages[].ruleId' security-audit.json | sort | uniq -c | sort -rn
The shape of a typical first run — and the frequency is the signal:
| Count | Rule | Severity | Reads as |
|---|---|---|---|
| 15 | pg/no-unsafe-query |
🔴 Critical | systemic SQL injection — no query layer |
| 8 | secure-coding/no-hardcoded-credentials |
🔴 Critical | secrets in source — rotate now |
| 3 | node-security/no-weak-hash-algorithm |
🔴 Critical | MD5/SHA1 in crypto paths |
15 injections isn't 15 bugs — it's a team that never had a query layer. That's
the real finding.
Two guardrails before that number leaves your laptop. It's a floor, not a
census: a linter reads syntax, so a business-logic authorization hole produces
zero findings and still ends your quarter — those are
false negatives
this protocol structurally cannot see
(static analysis vs SAST vs linting
draws the boundary). And once "total findings" is the number leadership tracks
weekly, it stops measuring risk and starts measuring how good your team is at
disabling rules —
Goodhart's law arrives
on schedule. Track the top three rows and their fix dates, not the total.
Why none of this got caught in code review. The first
client.query("SELECT ... " + id) passed review because the reviewer was reading
for logic, not for
taint — and then
became the copy-paste template for every query after it. The MD5 call sat in
utils/legacy_auth.js from before anyone on the current team joined: nobody owns
it, so nobody touches it. Hardcoded credentials read as "config we'll move to env
later." None of these are exotic mistakes. They're the default failure mode of a
team without a guardrail in CI, which is why a machine pass finds in 30 minutes
what two years of human review walked past.
What one run buys you
-
The attack surface — group by OWASP category to see what's most exposed:
jq -r '.[].messages[].message' security-audit.json | grep -o 'OWASP:[^ ]*' | sort | uniq -c | sort -rn -
The hotspots — group by file instead of rule to find the worst modules:
jq -r '.[].filePath' security-audit.json | sort | uniq -c | sort -rn - The culture — did the previous team have any guardrails? The heatmap answers honestly.
It's not a penetration test. It's a data-driven first slide — and unlike the
consultant's PDF, you can re-run it weekly to measure remediation velocity.
Before you start fixing those 15 SQL findings, read
The SQL Injection Pattern node-postgres Can't Save You From
— the parameterization fix is one line; the reason it kept shipping is the real
lesson. And if you want to know why these three plugins and not three others, the
benchmark across 17 ESLint security plugins
measures detection rate, false-positive rate, and overlap on a shared corpus.
Then make it permanent
# CI — the audit becomes a gate; errors fail the build, and --max-warnings 0
# also blocks any warning-level rule
- run: npx eslint . --max-warnings 0
The same [PCI-DSS,HIPAA,ISO27001,…] tags in each finding become your audit
evidence, and the structured messages are built for AI assistants to action.
The codebase you inherit next won't be human-written
The inherited-codebase framing has a successor problem: a growing share of the
code you'll audit was written by an AI assistant, and the heatmap looks
identical. Claude writing 80 Node.js functions with no security context — 20
prompts across four models — put 65–75% of them in the vulnerable column, led
by the same three patterns this scan ranks first. (Full experiment:
I Let Claude Write 80 Functions — 65–75% Had Security Vulnerabilities.)
And it isn't a Claude problem. When I widened the benchmark to 700
AI-generated functions across 5 models from Claude and Google's Gemini —
7 iterations per prompt, 20 security-critical tasks — every model landed in a
49–73% vulnerability rate (χ² = 18.43, p < 0.05 —
a real difference, not sampling noise),
and Gemini 2.5 Pro topped the table at 73%. Different vendor, same three
patterns at the top of the heatmap. (Full data:
We Ranked 5 AI Models by Security — The Leaderboard Is Wrong.)
Run the exact protocol on a Gemini-generated diff
This isn't a thought experiment — it's the run from the top of this article, in
full. I pointed Steps 1–4 at 140 functions generated by Gemini 2.5 Pro (Gemini
CLI v0.27.3, -p from an empty temp dir, 7 iterations across 20
security-critical prompts in 5 categories, no security guidance in the prompt),
measured 2026-02-09. That scan ran four plugins — the three you just
installed plus eslint-plugin-jwt — and every rule at the top of the ranking
below comes from the three in this protocol. The same
jq -r '.[].messages[].ruleId' | sort | uniq -c | sort -rn heatmap, aggregated
across the run:
50 node-security/detect-non-literal-fs-filename # path taken from input, unsanitized
20 pg/prefer-pool-query # connection-per-call, no pooling
19 node-security/detect-child-process # shelling out on user-influenced args
13 node-security/no-arbitrary-file-access # fs call reachable by path traversal
12 pg/no-hardcoded-credentials # DB creds inline in the query layer
11 pg/no-select-all # SELECT * into the response
Read that ranking honestly: pg/prefer-pool-query and pg/no-select-all are
hardening rules, not injection sinks. They earn their rows, but they are not "a
frontier model wrote SQL injection." The sinks here are the file-path,
child-process and credentials lines.
102 of 140 functions were vulnerable — a 73% rate, 168 findings, average
CVSS 8.3, generated at ~36 seconds a function. The cluster is the same one the
inherited-human heatmap surfaces: file-path injection, hardcoded secrets, unsafe
data access. Then the part that should end the "I'll just ask it to fix them"
reflex: I fed every finding back and asked Gemini to remediate its own output.
101 of the 102 vulnerable functions came back with an attempt; it fully fixed
47 of them (47%) and eliminated 74 of the 167 findings in those 101
functions — a 44.3% reduction, 93 still standing. The model that wrote the
holes could not reliably close them — and that was the second-best remediation
score of the five models in the run. A guardrail in CI is not optional on AI
output; it's the only thing in the loop that doesn't leave more than half the
findings exactly where it found them.
None of that is a knock on one model or one vendor — it's a systemic property of
generating code without a guardrail in the loop. So point the same
npx eslint . --format=json at your coding agent's output — Claude, Gemini,
Copilot, whatever writes the next commit — before the diff reaches review, and the
machine-written client.query("SELECT ... " + id) fails the build at the same
rule the human-written one did. The protocol doesn't change. The author does.
Compatibility
All three plugins ship the same contract:
| Surface | Support |
|---|---|
| Package managers | npm, yarn, pnpm, bun |
| Node | >= 18.0.0 |
| ESLint | `^8.0.0 \ |
| Module system | Plugins ship CommonJS; your config can be {% raw %}eslint.config.js or .mjs
|
| Oxlint | flagship rules (incl. pg/no-unsafe-query) run today via the oxlint JS-plugin tier — same plugin source, measured ~13–22× faster wall time (oxlint 1.63.0 vs eslint 9.39.4, 905 files) |
Links
- 📦 eslint-plugin-secure-coding — core OWASP coverage
- 📦 eslint-plugin-pg — PostgreSQL / data-layer
- 📦 eslint-plugin-node-security — crypto & system
- 📖 Full rule docs (per-rule CWE)
- 💻 Source on GitHub — ⭐ if the heatmap told you something your code review didn't
- 📊 Benchmark: 17 ESLint security plugins compared
- 🔍 I inherited a NestJS codebase — the first lint run found 6 vulnerabilities
::dev-to-cta{url="https://www.npmjs.com/package/eslint-plugin-secure-coding"}
📦 npm i -D eslint-plugin-secure-coding — the one install that turns the
30-minute audit into a gate that runs on every commit after it.
::
Then run the four commands on the worst service you've inherited and tell me the
top line of your heatmap. What was your uniq -c | sort -rn number-one rule —
and how long had it been quietly shipping before the scan named it?
And when the heatmap has to become a plan, read
Mapping Your Codebase to the OWASP Top 10 with ESLint
— same security-audit.json, reorganised into the framework your leadership
already recognises. The heatmap gets you the meeting; the mapping gets you the
headcount.
Part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz
I'm Ofri Peretz, a security engineering leader and the author of the
Interlace ESLint ecosystem — domain-specific static analysis for security,
reliability, and performance on the Node.js stack.
Top comments (6)
We loved your post so we shared it on social.
Keep up the great work!
Thanks for the share 🧡
Thanks, I used the security plugin and it prevented quite a few traps like prototype pollution. However at that time it did not report the owasp IDs and cwes.
Hi @jankapunkt Appreciate the feedback, can you provide specific examples that you've experienced false negatives, so I will be able to improve the plugin/s?
@ofri-peretz sorry for the confusion. I used
eslint-plugin-securityin the past. The ones you propose look like a massive improvement!What nice feedback to receive! Feel free to share any type of feedback you have. I'm here to iterate on these plugins fast. If you have ideas for more useful rules, lmk.