On one Node file with 12 vulnerability classes, @microsoft/eslint-plugin-sdl
running its recommended config caught 5 (and only 3 of those came from
its own @microsoft/sdl/* rules). The domain plugins caught 46 — same file.
And you'd already told the team the backend was covered by Microsoft's Security
Development Lifecycle, because the build went green. Your SQL injection, your path
traversal, your unsafe deserialization — all of it walked straight past the
linter you trusted, and the pipeline never went red to warn you.
That gap isn't a quality verdict, and it isn't Microsoft shipping a bad tool.
@microsoft/eslint-plugin-sdl is 17 rules distilled from the SDL standard, and
it's a good tool — for the surface it was built for. The gap is a layer
mismatch. Look at the rule list and the result is obvious: SDL was built to
harden frontends (Angular, Electron, the DOM), not Node backends. Point it
at an API and most of its rules have nothing to match.
Part of the ESLint Security Benchmark Series —
same fixture, same method, one tool per post. Companion piece:
SonarJS has 269 rules and found 13 where the domain plugins found 46.
What SDL's 17 rules actually target
no-angular-bypass-sanitizer no-angularjs-bypass-sce
no-angular-sanitization-trusted-urls no-angularjs-enable-svg
no-angularjs-sanitization-whitelist no-electron-node-integration
no-inner-html no-html-method no-document-write no-document-domain
no-cookies no-insecure-url no-insecure-random no-unsafe-alloc
no-msapp-exec-unsafe no-postmessage-star-origin no-winjs-html-unsafe
Five are Angular/AngularJS-specific, one is Electron, two are WinJS/MSApp,
several are DOM/browser (innerHTML, document.write, postMessage,
cookies). That's a frontend / MS-stack security surface — and a good one for
that surface.
Detection — vulnerable.js (12 Node vulnerability classes)
| Config | Engine | Security findings |
|---|---|---|
| Oxlint built-in | Oxlint | 1 |
@microsoft/sdl/* rules only |
ESLint | 3 |
@microsoft/eslint-plugin-sdl (recommended) |
ESLint | 5 |
| Interlace flagship rules | Oxlint | 5 |
| eslint-plugin-security (recommended) | ESLint | 21 |
| Interlace (4 plugins, recommended) | ESLint | 46 |
Read that table honestly — the line a senior SDL user will check first. The
SDL namespaced rules (@microsoft/sdl/*) caught 3: no-inner-html,
no-document-write, and no-cookies. Its Angular, Electron, and WinJS rules had
nothing to match — there's no Angular in a Node API. But nobody runs the bare
namespace; they run sdl.configs.recommended, and that config also turns on
four core ESLint rules (no-eval, no-implied-eval, no-new-func, no-caller)
— so on this fixture the real recommended number is 5 (the 3 above plus
no-eval and no-new-func firing on the eval() / new Function() sites).
One detail that surprised me and matters for fairness: sdl.configs.recommended
registers eslint-plugin-security as a plugin but never switches its rules to
error — I confirmed this with eslint --print-config (zero security/* rules
in the resolved config). So recommended does not quietly inherit
eslint-plugin-security's 21 findings; that 21 is what you get only if you add
security rules yourself. The honest headline is 5 vs 46, and 5 is still the
generous reading.
A robustness note, stated plainly. Three SDL rules (
no-insecure-random,
no-insecure-url,no-unsafe-alloc) threw on the fixture's dynamic
require(variable)— apath.basename(undefined)inside the rule. They're
written around browser/Angular call shapes, not Node's dynamic module loading.
It's a fair reminder that a linter only hardens the surface it was built for.
What a Node backend needs instead
The honest accounting, and it reconciles exactly. SDL's recommended config
produces 5 findings on this file; the Interlace set produces 46. Line them
up: SDL flags 5 lines, and 9 of the 46 Interlace findings land on those same
lines (the innerHTML write, the insecure cookie, and the eval/Function
sites, where SDL's core no-eval/no-new-func overlap the Interlace eval rules).
That leaves 37 Interlace findings on lines SDL never touches — and 9 + 37 = 46,
no hand-waving.
Those 37 are the Node backend surface SDL has no rule for on any config: SQL
injection, fs path traversal, object injection / prototype pollution, ReDoS,
weak hashing, insecure comparisons, dynamic require, deprecated Buffer. (SDL
also catches one thing the backend plugins skip — document.write, a pure-frontend
concern — so it isn't a subset in either direction.) That's the whole point: not
"SDL is weaker," but "SDL was never pointed at this layer."
That "37" isn't an estimate — it's the actual ruleId output. Here's the full
list from the Interlace run (--format json, counted by ruleId), grouped by
class, so you can diff it against your own:
vulnerable.js, by rule ID (37 land on lines SDL's recommended config never flags)
| Vulnerability class | Rule ID (count) | CWE |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------- | ------- |
| SQL injection | `pg/no-unsafe-query` (2), `pg/no-floating-query` (2) | CWE-89 |
| Path traversal | `node-security/detect-non-literal-fs-filename` (5), `node-security/no-arbitrary-file-access` (4) | CWE-22 |
| Object injection / proto-poll. | `secure-coding/detect-object-injection` (5) | CWE-915 |
| Unsafe deserialization / eval | `secure-coding/no-unsafe-deserialization` (4) → CWE-502; `node-security/detect-eval-with-expression` (2), `browser-security/no-eval` (2) → CWE-95 | 502 / 95 |
| ReDoS / unsafe regex | `secure-coding/detect-non-literal-regexp` (3), `secure-coding/no-redos-vulnerable-regex` (2), `secure-coding/no-unsafe-regex-construction` (1) | CWE-1333 |
| Weak hashing | `node-security/no-weak-hash-algorithm` (2) | CWE-327 |
| Insecure comparison (timing) | `secure-coding/no-insecure-comparison` (3) | CWE-208 |
| Hardcoded credentials | `secure-coding/no-hardcoded-credentials` (2) | CWE-798 |
| Dynamic / unsafe require | `node-security/no-unsafe-dynamic-require` (2) | CWE-95 |
| XSS / unsafe HTML | `browser-security/no-innerhtml` (1) | CWE-79 |
| Insecure cookies | `browser-security/require-cookie-secure-attrs` (2) | CWE-614 |
| XPath injection | `secure-coding/no-xpath-injection` (1) | CWE-643 |
| Deprecated Buffer | `node-security/no-deprecated-buffer` (1) | CWE-676 |
**Total: 46.** SDL's `recommended` config touches three of these rows — XSS row
(via `no-inner-html`), cookie row (via `no-cookies`), and the `eval`/`Function`
sites in the deserialization row (via core `no-eval` / `no-new-func`, which
`recommended` enables — confirm with `eslint --print-config`, where both resolve
to `"error"`). That's **9** of the 46 findings on shared lines; the other **37**
rows — SQLi, path traversal, object injection, ReDoS, weak hashing, timing,
dynamic require, deprecated Buffer — have **no SDL rule at all**.
(`eslint-plugin-security`'s `recommended` reproduces 21 of these on its own — but,
as noted above, `sdl.configs.recommended` registers that plugin without enabling
its rules; the full 46 needs the four Interlace `recommended` configs together.
Versions and exact commands in the methodology section.)
One bug, end to end: the SQL line SDL can't see
Aggregate counts are easy to wave away, so walk a single finding the whole way.
Line 78 of the fixture — a line that has shipped in real services I've reviewed:
// vulnerable.js — queryDatabase(userId, orderBy)
db.query('SELECT * FROM users WHERE id = ' + userId);
Why a reviewer waves this through. It's not incompetence — it's context
collapse. userId reads like an integer from a typed route param, the diff is
one line in a 400-line PR, and the CI badge is green because the SDL linter ran
and found nothing to say about it. The reviewer isn't approving SQL injection;
they're trusting a green check that was scoped to a layer this file isn't on.
SDL has no query-aware rule, so the line is invisible to it — not downgraded to
a warning, absent from the output entirely.
What the domain rule does instead. pg/no-unsafe-query keys on the
.query() call shape and flags any argument built by concatenation or
interpolation:
vulnerable.js:78:10 error SQL Injection Risk: unsafe SQL query detected.
Variable interpolation found. Use parameterized queries ($1, $2)
instead of string concatenation. [CWE-89] pg/no-unsafe-query
The fix is the smallest possible diff — hand the value to the driver as a
parameter so it's never part of the SQL string:
// parameterized — the driver escapes $1; the query string is now constant
db.query('SELECT * FROM users WHERE id = $1', [userId]);
That is the entire loop the aggregate number hides, repeated 46 times across the
file. SDL is silent on this specific line not because it's a weak linter, but
because a query-shape rule was never in its frontend-hardening scope. (Deeper
on this exact pattern:
three SQL-injection shapes in node-postgres and the rule that catches each.)
Why this mismatch survives review
Nobody approves "ship SQL injection." What gets approved is a green pipeline.
@microsoft/eslint-plugin-sdl carries the Microsoft name and the letters SDL —
the same standard that gates a lot of enterprise shops — so when it lands in CI
and the build goes green, "do we have a security linter?" quietly becomes "yes,
the Microsoft one." The plugin did exactly what it promised; it just promised to
harden a surface this repo doesn't have. The rules that would have caught the
backend bugs were never installed, so there was nothing red for a reviewer to
question. A passing security linter on the wrong layer is more dangerous than no
linter, because it converts an open question into a settled one.
I keep meeting this pattern in code review, and lately I keep meeting it in
AI-generated code too — same root cause, faster.
The AI angle: assistants reintroduce exactly what SDL can't see
This is the part that turns a layer mismatch into a recurring incident. The 37
findings SDL's recommended config has no rule for are the modal mistakes an LLM
makes when it writes a Node backend: string-concatenated pg queries, fs paths
built from request input, object injection, weak hashing. (Its core no-eval
catches the textbook eval(userInput), but the LLM-modal bug is rarely literal
eval — it's a db.query built by interpolation, which SDL never sees.) I ran the
experiment directly: when I
let Claude write 80 backend functions, 65–75% shipped with a security
vulnerability —
and it isn't a Claude problem. I extended it to
700 AI-generated functions across 5 models from Claude and Gemini,
and every model landed between a 49% and 73% vulnerability rate — Gemini 2.5
Pro topped the chart at 73%, and even the safest model still shipped a vuln
in nearly half its functions. These are the exact CWE classes SDL's recommended
config has no rule for: SQLi, path traversal, object injection, weak hashing,
ReDoS. Whether the author is Claude, Gemini, or a junior in a hurry, the backend
bug lands on a line your frontend linter was never built to read.
The uncomfortable part isn't the aggregate rate — it's that
no single model is safe across every domain:
the model that writes the cleanest auth code writes some of the worst file-I/O
code, so you can't pick your way out with a "secure" model. You need the layer,
not a better author.
So the failure compounds: your assistant generates a backend vuln, your
frontend-layer linter has no rule for it, your pipeline is green, and the PR
merges. SDL never sees the thing it was never built to see. The fix isn't to
distrust SDL — it's to run a linter that covers the layer where the code (and
your AI assistant) actually lives. Reproduce it yourself: paste an
AI-generated route handler into the fixture directory below and run both configs.
Cover the backend layer — copy-paste
Keep SDL for your frontend. Add the domain plugins for everything Node:
npm i -D eslint eslint-plugin-secure-coding eslint-plugin-node-security \
eslint-plugin-pg eslint-plugin-browser-security
// eslint.config.mjs — backend + browser-surface coverage
import { configs as secureCoding } from "eslint-plugin-secure-coding";
import { configs as nodeSecurity } from "eslint-plugin-node-security";
import { configs as pg } from "eslint-plugin-pg";
import { configs as browserSecurity } from "eslint-plugin-browser-security";
export default [
secureCoding.recommended, // object injection, unsafe deserialization, weak hashing
nodeSecurity.recommended, // fs path traversal, dynamic require, deprecated Buffer
pg.recommended, // SQL injection in node-postgres
browserSecurity.recommended, // innerHTML/eval/cookie — the rows that overlap SDL
];
That's the bulk of the 37 findings SDL's recommended config had no rule for. The
full dual-layer config — SDL scoped to src/web/**, the domain plugins to
src/api/** — is below.
False positives — safe-patterns.js
| Config | False positives |
|---|---|
| Oxlint built-in | 0 |
| Interlace @ Oxlint | 0 |
| @microsoft/eslint-plugin-sdl | 1 (no-inner-html on a DOMPurify-sanitized assignment — conservative) |
| Interlace @ ESLint | 3 (a perf rule + a conservative-by-design rule) |
| eslint-plugin-security | 5 (genuine — validated-key + path-validated) |
Use the right layer
If you ship Angular, Electron, or a browser frontend, SDL is a sensible
hardening layer for that code — it encodes Microsoft's SDL guidance for those
surfaces. For the Node backend (APIs, data layer, crypto, serverless), reach for
the domain plugins. They're different layers of the same app:
// eslint.config.mjs
import sdl from "@microsoft/eslint-plugin-sdl";
import { configs as secureCoding } from "eslint-plugin-secure-coding";
import { configs as nodeSecurity } from "eslint-plugin-node-security";
import { configs as pg } from "eslint-plugin-pg";
export default [
// frontend bundle — SDL where Angular/Electron/DOM code lives.
// (SDL's configs are flat-config ARRAYS, so map files onto each entry —
// unlike the Interlace configs, which are single objects. Spread `c`
// FIRST, then set `files`, so our scope wins over any files key in c.)
...sdl.configs.common.map((c) => ({
...c,
files: ["src/web/**", "src/electron/**"],
})),
// backend — the domain security plugins (their configs ARE single objects)
{ ...secureCoding.recommended, files: ["src/api/**", "src/db/**"] },
{ ...nodeSecurity.recommended, files: ["src/**"] },
{ ...pg.recommended, files: ["**/db/**"] },
];
The mental model that survives this article: a security linter only hardens
the layer it was built for, and a green pipeline is not the same as a covered
one. Audit what your "security linter" actually has rules for before you tell
the team the backend is safe.
What's the security tool you trusted that turned out to be scoped to the wrong
layer — the SAST that only spoke one language, the SCA that ignored your lockfile,
the linter green on a surface you didn't have? I want the war story in the
comments.
Methodology — reproduce it
Honest disclosure: the fixtures are team-authored (vulnerable.js, 12 Node
vulnerability classes; safe-patterns.js), so they measure the Node backend
surface the Interlace rules target — SDL would score very differently on an
Angular/Electron fixture, which is its home turf. Versions (measured 2026-05-31):
eslint@9.39, @microsoft/eslint-plugin-sdl@1.1.0 (17 rules),
eslint-plugin-secure-coding@3.2.0, eslint-plugin-node-security@4.2.0,
eslint-plugin-pg@1.4.3 (the Interlace plugin — not the pg node-postgres
driver, which is at 8.x), eslint-plugin-browser-security@1.2.3. Findings counted
by ruleId over --format json output.
What "SDL" means in the table, stated once, plainly. The 3 is the count
of @microsoft/sdl/*-namespaced rules that fired. The 5 is the count from
SDL's own recommended config (sdl.configs.recommended) — which additionally
enables core no-eval/no-new-func, the source of the extra 2. SDL's three
browser-shaped rules (no-insecure-random, no-insecure-url, no-unsafe-alloc)
throw on this fixture's dynamic require(variable) (a path.basename(undefined)
inside the rule), so they're set to off to let the rest of the config run —
that throw is itself reported above. Note that sdl.configs.recommended
registers eslint-plugin-security but does not enable its rules at error
(verify with eslint --print-config), so it contributes 0 here.
The whole run reproduces from the two configs below — no private files needed:
npm i -D eslint@9 @microsoft/eslint-plugin-sdl eslint-plugin-secure-coding \
eslint-plugin-node-security eslint-plugin-pg eslint-plugin-browser-security
// eslint.config.sdl.mjs — SDL's own recommended config, throwing rules disabled
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const sdl = require("@microsoft/eslint-plugin-sdl");
const off = ["no-insecure-random", "no-insecure-url", "no-unsafe-alloc"];
export default [
{ files: ["**/*.js"], languageOptions: { sourceType: "commonjs" } },
...sdl.configs.recommended.map((c) =>
c.rules
? { ...c, rules: Object.fromEntries(Object.entries(c.rules).map(([k, v]) =>
[k, off.some((r) => k.endsWith(r)) ? "off" : v])) }
: c,
),
];
// eslint.config.interlace.mjs — the four domain plugins at recommended
import { configs as secureCoding } from "eslint-plugin-secure-coding";
import { configs as nodeSecurity } from "eslint-plugin-node-security";
import { configs as pg } from "eslint-plugin-pg";
import { configs as browserSecurity } from "eslint-plugin-browser-security";
export default [
{ files: ["**/*.js"], languageOptions: { sourceType: "commonjs" } },
secureCoding.recommended, nodeSecurity.recommended,
pg.recommended, browserSecurity.recommended,
];
npx eslint --config eslint.config.sdl.mjs vulnerable.js --format json # → 5
npx eslint --config eslint.config.interlace.mjs vulnerable.js --format json # → 46
The fixtures (vulnerable.js, safe-patterns.js) and the four-plugin benchmark
harness live in the repo's
packages/eslint-plugin-secure-coding/benchmark/;
paste the two configs above next to them to reproduce the SDL-vs-Interlace
numbers exactly.
The full 4-engine version (ESLint + Oxlint, built-in + plugins) is in
the security-linter benchmark.
Run it on your AI's output — including Gemini
The fixture is hand-authored, but the method is the point, and it's one experiment
away from a Gemini-grounded version: ask Gemini 2.5 Pro to generate a batch of
Node route handlers, drop them in place of vulnerable.js, and run the same two
configs. I already have the priors — across
700 AI-generated functions from 5 Claude and Gemini models,
Gemini 2.5 Pro shipped a vulnerability in 73% of its functions, the highest in
the field — so the SDL-recommended config will catch the literal eval, miss the
pg concatenation and the fs path, and the domain plugins will catch the rest,
on real generated code instead of a fixture. That rerun, tagged `#googleai
geminichallenge`, is a Build-with-Gemini XPRIZE entry; this post is the harness
it would reuse.
Compatibility
| Surface | Support |
|---|---|
| Package managers | npm, yarn, pnpm, bun |
| Node | >= 18.0.0 |
| ESLint | Interlace plugins `^8 \ |
| Module system | Plugins ship CommonJS; your config can be {% raw %}eslint.config.js or .mjs
|
| Oxlint | Interlace flagship rules run via the interlace-* ports, parity-gated |
ESLint Security Benchmark Series
Same fixture, same method, one tool per post:
- The benchmark hub: 17 ESLint security plugins compared — the full field, all engines.
-
You are here:
@microsoft/eslint-plugin-sdl— 5 vs 46 on recommended, wrong layer. - SonarJS has 269 rules and found 13 — the rule-count-vs-coverage companion.
- The 4-engine ground truth: ESLint + Oxlint, built-in + plugins — the methodology these posts all share.
Why these numbers keep mattering: the bugs they catch are the
same ones AI assistants reintroduce
at a 49–73% clip, which is why the layer — not the author — is the thing to fix.
Links
- 📦 secure-coding · node-security · pg · browser-security
- 📦 @microsoft/eslint-plugin-sdl — the frontend layer
- 📖 Full rule docs
- 💻 Source on GitHub
::dev-to-cta{url="https://github.com/ofri-peretz/eslint"}
⭐ Star on GitHub if your Node backend — and the code your AI assistant writes for
it — needs more than a frontend security linter.
::
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 (0)