<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ofri Peretz</title>
    <description>The latest articles on DEV Community by Ofri Peretz (@ofri-peretz).</description>
    <link>https://dev.to/ofri-peretz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3669992%2F50a1f256-472c-48a1-85e8-149459647ea7.png</url>
      <title>DEV Community: Ofri Peretz</title>
      <link>https://dev.to/ofri-peretz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ofri-peretz"/>
    <language>en</language>
    <item>
      <title>Your MongoDB Login Can Be Bypassed With No Password and No Quotes. The ESLint Plugin That Catches It.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 31 May 2026 16:42:43 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/getting-started-with-eslint-plugin-mongodb-security-ol6</link>
      <guid>https://dev.to/ofri-peretz/getting-started-with-eslint-plugin-mongodb-security-ol6</guid>
      <description>&lt;p&gt;MongoDB stores JavaScript objects. Your query is already structured data — there is no "query string" to inject into. Which is exactly why NoSQL injection looks different from SQL injection, and why generic security linters miss it.&lt;/p&gt;

&lt;p&gt;The attack isn't &lt;code&gt;; DROP TABLE users; --&lt;/code&gt;. It's this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// POST body: { "username": "admin", "password": { "$ne": null } }&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// ← operator injection bypasses auth&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No SQL string. No quotes. No payload your WAF recognizes. The attacker sends &lt;code&gt;{ "$ne": null }&lt;/code&gt; as the password value, Express parses it into a real JavaScript object, and &lt;code&gt;findOne&lt;/code&gt; happily matches the first user whose password is not null — which is every user. That's a full authentication bypass in valid JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this survives code review:&lt;/strong&gt; the line &lt;code&gt;password: req.body.password&lt;/code&gt; is the obvious, correct-looking thing to write. A reviewer reads it as "compare the submitted password to the stored one." It only becomes a vulnerability when &lt;code&gt;req.body.password&lt;/code&gt; stops being a string and becomes an operator object — and nothing in the diff signals that the field is attacker-shaped. The type is &lt;code&gt;any&lt;/code&gt;, the test suite posts a string, and the bug ships green. You can't catch this in review by reading harder; you catch it by encoding the rule "request data must never reach a query field unsanitized" into the linter.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;eslint-plugin-mongodb-security&lt;/code&gt; is the only ESLint plugin built specifically for MongoDB/Mongoose codebases — 16 rules, each mapped to a CWE and the relevant CVE. Here's how to use it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is part of my &lt;a href="https://ofriperetz.dev/articles/getting-started-eslint-plugin-pg" rel="noopener noreferrer"&gt;ESLint Security Plugins&lt;/a&gt; series — one plugin per data layer. The &lt;a href="https://ofriperetz.dev/articles/sql-injection-node-postgres-pattern" rel="noopener noreferrer"&gt;node-postgres edition&lt;/a&gt; covers the SQL side of the same class of bug.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;eslint-plugin-mongodb-security &lt;span class="nt"&gt;--save-dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;eslint.config.mjs&lt;/code&gt; — the &lt;code&gt;recommended&lt;/code&gt; preset wires up the plugin and turns on every rule that matters, NoSQL-injection rules as errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;mongodbSecurity&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint-plugin-mongodb-security&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nx"&gt;mongodbSecurity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line is the copy-paste that catches the auth-bypass above. Run &lt;code&gt;npx eslint .&lt;/code&gt; and the operator-injection finding shows up at the exact &lt;code&gt;password: req.body.password&lt;/code&gt; line, with the CWE and a suggested fix. If you want everything as an error (good for a CI gate that should block the merge, not just warn), use &lt;code&gt;configs.strict&lt;/code&gt;; for a Mongoose-only project, &lt;code&gt;configs.mongoose&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three rules you need most
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;no-unsafe-query&lt;/code&gt; — NoSQL operator injection (CWE-943, CVSS 9.8)
&lt;/h3&gt;

&lt;p&gt;Fires when a &lt;code&gt;$where&lt;/code&gt;, &lt;code&gt;$expr&lt;/code&gt;, or &lt;code&gt;$function&lt;/code&gt; operator receives a value directly from user input — the exact pattern that lets an attacker inject arbitrary query logic. This isn't theoretical: &lt;code&gt;$where&lt;/code&gt; runs server-side JavaScript, and a user-controlled &lt;code&gt;$where&lt;/code&gt; is the root of &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-23061" rel="noopener noreferrer"&gt;CVE-2025-23061&lt;/a&gt; and &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2024-53900" rel="noopener noreferrer"&gt;CVE-2024-53900&lt;/a&gt; in Mongoose. The plugin's &lt;code&gt;no-unsafe-where&lt;/code&gt; rule links straight to those NVD entries in its finding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Flagged — $where with user-controlled JavaScript&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;$where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`this.total &amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minTotal&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ Safe — use $gt instead of $where&lt;/span&gt;
&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$gt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minTotal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. &lt;code&gt;no-operator-injection&lt;/code&gt; — Query operator in request body (CWE-943, CVSS 9.1)
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;req.body&lt;/code&gt; (or any request property) is used directly in a MongoDB query field, an attacker can send &lt;code&gt;{ "$ne": null }&lt;/code&gt; or &lt;code&gt;{ "$gt": "" }&lt;/code&gt; as the field value to bypass authentication or extract unauthorized data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Flagged — req.body.password could be { "$ne": null }&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ Safe — hash and compare separately&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passwordHash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. &lt;code&gt;no-hardcoded-connection-string&lt;/code&gt; — Credentials in source (CWE-798, CVSS 7.5)
&lt;/h3&gt;

&lt;p&gt;Detects &lt;code&gt;mongodb://&lt;/code&gt; and &lt;code&gt;mongodb+srv://&lt;/code&gt; connection strings with embedded credentials in source code. These get committed to git history and exposed in build artifacts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Flagged — credentials in source&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MongoClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mongodb+srv://admin:hunter2@cluster0.example.com/mydb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ Safe — from environment variable&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MongoClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MONGODB_URI&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why a MongoDB-specific plugin
&lt;/h2&gt;

&lt;p&gt;Generic security linters (&lt;code&gt;eslint-plugin-security&lt;/code&gt;, &lt;code&gt;eslint-plugin-sonarjs&lt;/code&gt;) don't know the MongoDB query API. They can't distinguish &lt;code&gt;db.collection("users").find({ $where: userInput })&lt;/code&gt; from &lt;code&gt;console.log({ $where: "debug" })&lt;/code&gt;. The MongoDB-specific plugin knows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which methods are query execution points (&lt;code&gt;.find()&lt;/code&gt;, &lt;code&gt;.findOne()&lt;/code&gt;, &lt;code&gt;.aggregate()&lt;/code&gt;, &lt;code&gt;.updateMany()&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;Which operators are dangerous (&lt;code&gt;$where&lt;/code&gt;, &lt;code&gt;$expr&lt;/code&gt;, &lt;code&gt;$function&lt;/code&gt;, &lt;code&gt;$accumulator&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;What constitutes user input in the MongoDB context&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The reason this rule matters more in 2026: your AI assistant writes this exact bug
&lt;/h2&gt;

&lt;p&gt;Ask any coding assistant for "an Express login route with MongoDB" and watch what you get back. &lt;code&gt;findOne({ email: req.body.email, password: req.body.password })&lt;/code&gt; is one of the most common shapes in the training data, because it's the shape in thousands of tutorials — and almost none of those tutorials sanitize the operator case. The model reproduces the &lt;em&gt;typical&lt;/em&gt; code, and the typical code is vulnerable.&lt;/p&gt;

&lt;p&gt;I ran the broader version of this experiment: &lt;a href="https://ofriperetz.dev/articles/i-let-claude-write-60-functions-65-75-had-security-vulnerabilities" rel="noopener noreferrer"&gt;I let Claude write 80 common Node.js functions with zero security context&lt;/a&gt;, and 65–75% shipped with a vulnerability — operator injection and unsanitized request data among the most frequent. The uncomfortable part isn't that AI gets it wrong once. It's that it regenerates the same insecure shape every time you accept a completion, faster than any human reviewer can keep up.&lt;/p&gt;

&lt;p&gt;This is why the rule lives in the linter and not in a wiki page. A static rule is the only reviewer that runs on every save, every paste, every AI completion — and it doesn't get tired on the 40th login route. The plugin's findings ship with CWE-tagged, fix-oriented messages precisely so the assistant can read its own error and correct the code on the next turn, instead of you playing whack-a-mole with the same bypass.&lt;/p&gt;




&lt;h2&gt;
  
  
  All 16 rules
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;th&gt;CWE&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-unsafe-query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;td&gt;CWE-943&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-operator-injection&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;td&gt;CWE-943&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-hardcoded-connection-string&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;td&gt;CWE-798&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-hardcoded-credentials&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;td&gt;CWE-798&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;require-tls-connection&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;warn&lt;/td&gt;
&lt;td&gt;CWE-319&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;require-auth-mechanism&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;warn&lt;/td&gt;
&lt;td&gt;CWE-306&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-unsafe-regex-query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;td&gt;CWE-1333&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-unsafe-where&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;td&gt;CWE-943&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-debug-mode-production&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;warn&lt;/td&gt;
&lt;td&gt;CWE-489&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;require-schema-validation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;warn&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-select-sensitive-fields&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;warn&lt;/td&gt;
&lt;td&gt;CWE-312&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-bypass-middleware&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;warn&lt;/td&gt;
&lt;td&gt;CWE-284&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-unsafe-populate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;td&gt;CWE-943&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-unbounded-find&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;warn&lt;/td&gt;
&lt;td&gt;CWE-400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;require-projection&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;warn&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;require-lean-queries&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;warn&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;(Severities above are the &lt;code&gt;recommended&lt;/code&gt; preset. &lt;code&gt;strict&lt;/code&gt; promotes every rule to &lt;code&gt;error&lt;/code&gt;.)&lt;/p&gt;




&lt;p&gt;The auth bypass at the top of this article is one line of obvious-looking code that a reviewer waved through, a test suite covered with a string, and an AI assistant will hand you again tomorrow. The linter is the one reviewer that catches it on every one of those paths.&lt;/p&gt;

&lt;p&gt;So I'll ask the question this article is really about: &lt;strong&gt;what's the NoSQL bug that actually bit you — the &lt;code&gt;$where&lt;/code&gt; someone left in, the &lt;code&gt;req.body&lt;/code&gt; that turned into an operator, the connection string in a committed &lt;code&gt;.env.example&lt;/code&gt;?&lt;/strong&gt; Drop it in the comments. The next person grepping for "MongoDB operator injection" at 2 AM will be grateful you did.&lt;/p&gt;

&lt;p&gt;If this catches something in your codebase, &lt;a href="https://github.com/ofri-peretz/eslint" rel="noopener noreferrer"&gt;⭐ star the repo&lt;/a&gt; — it keeps the rules maintained.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More in the &lt;a href="https://ofriperetz.dev/articles/getting-started-eslint-plugin-pg" rel="noopener noreferrer"&gt;ESLint Security Plugins&lt;/a&gt; series:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/articles/sql-injection-node-postgres-pattern" rel="noopener noreferrer"&gt;Your node-postgres Data Layer Fails 4 Ways in Production&lt;/a&gt; — the SQL-side counterpart to this exact class of bug&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/articles/i-let-claude-write-60-functions-65-75-had-security-vulnerabilities" rel="noopener noreferrer"&gt;I Let Claude Write 80 Functions. 65–75% Had Security Vulnerabilities&lt;/a&gt; — the experiment behind the AI-reintroduction beat above&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;a href="https://www.npmjs.com/package/eslint-plugin-mongodb-security" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimg.shields.io%2Fnpm%2Fv%2Feslint-plugin-mongodb-security.svg" alt="npm" width="80" height="20"&gt;&lt;/a&gt; · &lt;a href="https://eslint.interlace.tools/docs/security/plugin-mongodb-security" rel="noopener noreferrer"&gt;Rule docs&lt;/a&gt; · &lt;a href="https://github.com/ofri-peretz/eslint" rel="noopener noreferrer"&gt;⭐ GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>devsecops</category>
      <category>ai</category>
    </item>
    <item>
      <title>Three SQL Injection Patterns That Still Ship in Node.js — And the ESLint Rule That Catches Them</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 31 May 2026 04:35:32 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/three-sql-injection-patterns-that-still-ship-in-nodejs-and-the-linter-that-catches-them-onb</link>
      <guid>https://dev.to/ofri-peretz/three-sql-injection-patterns-that-still-ship-in-nodejs-and-the-linter-that-catches-them-onb</guid>
      <description>&lt;p&gt;TypeScript passed it clean. The code reviewer approved it. It shipped to production. Three months later, a penetration tester sent a report.&lt;/p&gt;

&lt;p&gt;The vulnerable line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM orders WHERE user_id = &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQL injection has been a known problem for decades. OWASP A03:2021. Parameterized queries are widely understood. And it still ships — not because developers don't know, but because the three structural forms that actually appear in node-postgres codebases look harmless in code review, one line at a time. (&lt;a href="https://cwe.mitre.org/data/definitions/89.html" rel="noopener noreferrer"&gt;CWE-89&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;And now there's a second author on the team that reaches for those exact three forms by default: the coding assistant. Trained on the same corpus that produced this bug for twenty years, it regenerates it on demand — cleaner-looking, which makes it harder to catch.&lt;/p&gt;

&lt;p&gt;Here are the three patterns, why each survives review, why AI assistants reproduce all three, and how a pg-specific ESLint rule catches them statically — no matter who (or what) wrote the line.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a pg-specific rule — not a generic SQL injection linter
&lt;/h2&gt;

&lt;p&gt;Most SQL injection detectors work on one signal: string concatenation near a SQL keyword. If they see &lt;code&gt;"SELECT" + variable&lt;/code&gt;, they flag it. This produces false positives on non-query string building, and misses injection via template literals — which is syntactically distinct from &lt;code&gt;+&lt;/code&gt; but equally dangerous.&lt;/p&gt;

&lt;p&gt;A pg-specific rule knows three things a generic tool doesn't:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The API surface.&lt;/strong&gt; Only fires on &lt;code&gt;.query()&lt;/code&gt; calls — &lt;code&gt;pool.query()&lt;/code&gt;, &lt;code&gt;client.query()&lt;/code&gt;. Not on other string operations that happen to mention SQL keywords.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The parameterization contract.&lt;/strong&gt; pg uses &lt;code&gt;$1, $2&lt;/code&gt; positional placeholders, with values passed as the second argument array. If the second argument is a non-empty array, the rule treats the first argument as parameterized and stays silent. Note: &lt;code&gt;client.query("SELECT..." + x, [])&lt;/code&gt; with an empty array would still be a vulnerability — the rule checks for the presence of a values argument, not that every dynamic part is covered by a placeholder.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cross-line assignment taint.&lt;/strong&gt; When a SQL string is built via concatenation and stored in a variable before &lt;code&gt;.query()&lt;/code&gt;, the variable is marked tainted. The rule fires at the assignment — not just at the call site.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is why the rule correctly classifies all six cases in its test suite: three vulnerable patterns flagged, three parameterized patterns silent. There is one known false-positive class — covered in the trade-offs section below — but the core patterns have no FPs on legitimate parameterized code. The rule is intraprocedural — taint tracking doesn't cross function boundaries — but the direct-access patterns below are the ones that actually appear in production code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pattern 1: Direct string concatenation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Flagged — string + user input in a .query() call&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM users WHERE email = '&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it survives code review:&lt;/strong&gt; The concatenation looks harmless in isolation. The reviewer sees string building. Their mental model doesn't ask "where does &lt;code&gt;email&lt;/code&gt; come from?" — that context lives in the route handler, several stack frames up. Nobody holds the full data-flow in mind while reviewing a database layer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ Parameterized — rule stays silent&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM users WHERE email = $1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;$1&lt;/code&gt; placeholder + second-argument values array is pg's escaping contract. The database driver handles quoting and type coercion. This pattern cannot be accidentally broken.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pattern 2: Template literal interpolation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Flagged — same vulnerability, different syntax&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`SELECT * FROM orders WHERE user_id = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; AND status = '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this is especially dangerous:&lt;/strong&gt; Template literals feel like interpolation — "variables in a string." Developers who know concatenation is unsafe sometimes don't connect template expressions to the same risk. The syntax is cleaner, so the code feels safer. It isn't.&lt;/p&gt;

&lt;p&gt;The detection here is unambiguous: any &lt;code&gt;${...}&lt;/code&gt; expression inside the first argument to &lt;code&gt;.query()&lt;/code&gt; — without a corresponding values array as the second argument — is a SQL injection surface.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ Parameterized — stays silent&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM orders WHERE user_id = $1 AND status = $2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: a concatenation with a sanitization wrapper — &lt;code&gt;client.query("WHERE id = " + sanitize(userId))&lt;/code&gt; — is still flagged. The rule cannot verify that &lt;code&gt;sanitize()&lt;/code&gt; is pg-safe. Parameterization is always the fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pattern 3: Cross-line variable assignment
&lt;/h2&gt;

&lt;p&gt;This is the pattern that gets through code review most often.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Flagged at the assignment — variable is marked tainted&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM products WHERE category = '&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the &lt;code&gt;.query(sql)&lt;/code&gt; call, &lt;code&gt;sql&lt;/code&gt; looks like a named variable. Nothing at that call site suggests injection. The reviewer's eye is on the call — not on where &lt;code&gt;sql&lt;/code&gt; was built two lines earlier.&lt;/p&gt;

&lt;p&gt;The rule tracks this: when a SQL string is assigned via concatenation or template interpolation, the variable is tainted. If that variable is subsequently passed to &lt;code&gt;.query()&lt;/code&gt;, the rule fires at the assignment — where injection was introduced.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ✅ Safe — stays silent&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM products WHERE category = $1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The pentester's report? Pattern 3.&lt;/strong&gt; The &lt;code&gt;sql&lt;/code&gt; variable nobody traced back to &lt;code&gt;req.query&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Your AI assistant ships all three by default
&lt;/h2&gt;

&lt;p&gt;These three patterns predate AI. They got harder the moment a coding assistant joined the team — because the assistant was trained on the same corpus that produced them.&lt;/p&gt;

&lt;p&gt;Ask Claude, Gemini, or Copilot to "write a function that fetches orders for a user id from Postgres," and watch which form it reaches for. In my runs it lands on Pattern 1 or Pattern 2 more often than parameterized &lt;code&gt;$1&lt;/code&gt; — not because the model doesn't know parameterization, but because string-built SQL is the statistically dominant shape in its training data, and the prompt asked for a query, not for a &lt;em&gt;safe&lt;/em&gt; query. Parameterization is a constraint. The prompt described behavior, so the model fulfilled behavior. (Try it yourself — the output is non-deterministic, so re-run a few times and watch the failure &lt;em&gt;class&lt;/em&gt; stay constant even as the exact line changes.)&lt;/p&gt;

&lt;p&gt;This is the same negative-space failure I measured at scale. When I let &lt;a href="https://ofriperetz.dev/articles/i-let-claude-write-60-functions-65-75-had-security-vulnerabilities" rel="noopener noreferrer"&gt;Claude write 80 functions, 65–75% carried at least one security defect&lt;/a&gt;. And when I broke a &lt;a href="https://ofriperetz.dev/articles/aggregate-benchmarks-lie-heres-what-700-ai-functions-look-like-by-security-domain" rel="noopener noreferrer"&gt;700-function benchmark down by security domain across five Claude and Gemini models&lt;/a&gt;, database operations were a weak spot for every model — and, tellingly, the model that "won" generation did so by writing &lt;em&gt;simple, parameterized&lt;/em&gt; queries, while the ones that generated more elaborate, senior-looking database code triggered more pg rules. It's the database-layer cousin of the &lt;a href="https://ofriperetz.dev/articles/claude-wrote-nestjs-service-eslint-found-6-security-holes" rel="noopener noreferrer"&gt;NestJS service Claude shipped with six holes&lt;/a&gt;: correct, compiling, and quietly unsafe.&lt;/p&gt;

&lt;p&gt;The uncomfortable part for review: AI-generated SQL looks &lt;em&gt;more&lt;/em&gt; trustworthy than the human kind. It's clean, consistently formatted, and uses a tidy template literal. Pattern 2 — the template-literal form — is exactly what a reviewer skims past as "modern, readable code." The linter doesn't skim. It sees &lt;code&gt;${userId}&lt;/code&gt; inside the first argument to &lt;code&gt;.query()&lt;/code&gt; and fires, whether a human or a model typed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run it on your assistant's output before you run it on your colleague's.&lt;/strong&gt; Same rule, same install, no model-specific tuning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;eslint-plugin-pg &lt;span class="nt"&gt;--save-dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint-plugin-pg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pg/no-unsafe-query&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the rule is structural — not model-aware — the methodology transfers to any assistant. Paste the same prompt into Gemini via the Gemini CLI, scan the output with &lt;code&gt;pg/no-unsafe-query&lt;/code&gt;, and compare the count to Claude's. The model changes; the three patterns don't.&lt;/p&gt;




&lt;h2&gt;
  
  
  What about ORM escape hatches?
&lt;/h2&gt;

&lt;p&gt;Most production Node.js teams use Prisma, Drizzle, Knex, or TypeORM. Those ORMs parameterize by default — but they all have raw query escape hatches (&lt;code&gt;$queryRaw&lt;/code&gt;, &lt;code&gt;knex.raw&lt;/code&gt;, &lt;code&gt;sequelize.literal&lt;/code&gt;) where Pattern 1 and 2 reappear. A pg-specific rule won't catch those; the relevant rules are in the ORM's own lint ecosystem.&lt;/p&gt;

&lt;p&gt;For teams using pg directly — internal APIs, data pipelines, microservices — the three patterns above cover the injection surface. Prisma shops have different lint priorities.&lt;/p&gt;




&lt;h2&gt;
  
  
  The trade-offs (and the one false positive)
&lt;/h2&gt;

&lt;p&gt;The install and config are above — &lt;code&gt;pg/no-unsafe-query&lt;/code&gt; set to &lt;code&gt;error&lt;/code&gt; is the whole setup. Two things worth knowing before you turn it on in CI:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;vs. Semgrep/CodeQL:&lt;/strong&gt; Interprocedural SAST tools can trace taint across function boundaries. ESLint can't — it's intraprocedural. The trade-off: ESLint runs in your editor on every keystroke and in pre-commit hooks with no CI pipeline required. For a pg team that wants SQL injection feedback where they see TypeScript errors — including on the SQL an AI assistant just generated — that speed matters more than the wider taint scope.&lt;/p&gt;

&lt;p&gt;Known false positive: &lt;code&gt;client.query("SELECT * FROM " + SCHEMA_NAME)&lt;/code&gt; where &lt;code&gt;SCHEMA_NAME&lt;/code&gt; is a hardcoded constant. The rule fires because it can't distinguish constants from dynamic inputs. Workaround: use &lt;code&gt;pg-format&lt;/code&gt; for identifier quoting, or restructure to a parameterized form.&lt;/p&gt;

&lt;p&gt;Full rule docs and configuration: &lt;a href="https://eslint.interlace.tools/docs/security/plugin-pg/rules/no-unsafe-query" rel="noopener noreferrer"&gt;eslint.interlace.tools/docs/security/plugin-pg/rules/no-unsafe-query&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Has a parameterized query ever been "refactored" back into concatenation in your codebase — by a teammate who thought they were cleaning it up, or by an AI assistant that "simplified" the &lt;code&gt;$1&lt;/code&gt; away? Which pattern was it, and how far did it get before someone caught it?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;→ Related:&lt;/strong&gt; &lt;a href="https://ofriperetz.dev/articles/sql-injection-node-postgres-pattern" rel="noopener noreferrer"&gt;Your node-postgres Data Layer Fails 4 Ways in Production — SQL injection is only the first&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/articles/getting-started-eslint-plugin-pg" rel="noopener noreferrer"&gt;node-postgres will happily build a CVSS 9.8 SQL injection for you — 13 ESLint rules say no&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/articles/the-30-minute-security-audit-onboarding-a-new-codebase" rel="noopener noreferrer"&gt;30 minutes of ESLint found 26 critical bugs in an inherited codebase&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;a href="https://www.npmjs.com/package/eslint-plugin-pg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimg.shields.io%2Fnpm%2Fv%2Feslint-plugin-pg.svg" alt="npm" width="80" height="20"&gt;&lt;/a&gt; · &lt;a href="https://eslint.interlace.tools/docs/security/plugin-pg/rules/no-unsafe-query" rel="noopener noreferrer"&gt;Rule docs&lt;/a&gt; · &lt;a href="https://github.com/ofri-peretz/eslint" rel="noopener noreferrer"&gt;⭐ GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>node</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Claude vs Gemini Across 4 Security Domains: A Dead Heat — and the Hardening 63% of AI Code Skips</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 31 May 2026 03:39:06 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/claude-vs-gemini-across-4-security-domains-a-dead-heat-and-the-hardening-63-of-ai-code-skips-mpp</link>
      <guid>https://dev.to/ofri-peretz/claude-vs-gemini-across-4-security-domains-a-dead-heat-and-the-hardening-63-of-ai-code-skips-mpp</guid>
      <description>&lt;p&gt;The interesting result isn't who won. It's that across four security domains, Claude and Gemini missed &lt;strong&gt;the same hardening steps&lt;/strong&gt; — and if you've shipped AI-generated auth middleware this year, your code almost certainly has the same gaps, and your review didn't catch them either.&lt;/p&gt;

&lt;p&gt;For the record, the scoreboard: &lt;strong&gt;one Gemini win, two ties, one split — a statistical dead heat.&lt;/strong&gt; That's the last time the &lt;em&gt;winner&lt;/em&gt; matters in this article.&lt;/p&gt;

&lt;p&gt;Here's the number that should bother you more than any leaderboard: across 700 AI-generated functions scored by the rules I'm about to use, &lt;strong&gt;63% shipped a vulnerability&lt;/strong&gt;. So "which model writes more secure code?" is mostly the wrong question — I've &lt;a href="https://dev.to/ofri-peretz/we-ranked-5-ai-models-by-security-the-leaderboard-is-wrong-5a4o"&gt;run that leaderboard myself&lt;/a&gt; and argued it's the wrong frame. But people keep asking it, so I ran it properly — on the ESLint security plugins I wrote specifically to catch these bugs, each mapped to a CWE — to show you what actually matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Four domains, four of my plugins. For each, the &lt;em&gt;same&lt;/em&gt; feature-only prompt (no "make it secure" hint — that's how people actually use these tools), generated once by &lt;strong&gt;Gemini 2.5 Flash via the Gemini CLI&lt;/strong&gt; and once by &lt;strong&gt;Claude Sonnet 4.6 via the Claude CLI&lt;/strong&gt;, then linted with the domain's plugin on &lt;code&gt;recommended&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Method honesty: this is Gemini &lt;strong&gt;Flash&lt;/strong&gt; vs Claude &lt;strong&gt;Sonnet&lt;/strong&gt; — the comparable price/latency tier each vendor's CLI defaults to (Pro and Opus are a separate bracket; more on that below). It compares CLI tooling, system prompt included, not raw models under controlled decoding. n=1 per domain — but I re-ran the JWT round, and both models landed on 5 findings again with the same core misses, so treat these as directional with stable failure modes, not ±0 gospel.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The scorecard
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Domain&lt;/th&gt;
&lt;th&gt;Prompt&lt;/th&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;Gemini&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;NestJS&lt;/strong&gt; service&lt;/td&gt;
&lt;td&gt;users + auth + admin&lt;/td&gt;
&lt;td&gt;&lt;code&gt;nestjs-security&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;JWT&lt;/strong&gt; auth&lt;/td&gt;
&lt;td&gt;login + verify middleware&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jwt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;MongoDB&lt;/strong&gt; data layer&lt;/td&gt;
&lt;td&gt;Mongoose model + search&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mongodb-security&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;General API&lt;/strong&gt; (injection)&lt;/td&gt;
&lt;td&gt;import + search + reset&lt;/td&gt;
&lt;td&gt;&lt;code&gt;secure-coding&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;13*&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One Gemini win, two dead heats, one split. The frontier security gap is &lt;strong&gt;smaller than the discourse suggests&lt;/strong&gt; — and the count is the least interesting number here.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Table legend below: &lt;code&gt;✗&lt;/code&gt; = one violation of that rule, &lt;code&gt;✗✗&lt;/code&gt; = two, &lt;code&gt;✗✗✗&lt;/code&gt; = three, &lt;code&gt;—&lt;/code&gt; = rule didn't fire (clean).&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1 — NestJS: Gemini's idiomatic scaffolding wins
&lt;/h2&gt;

&lt;p&gt;The one clean win, &lt;a href="https://dev.to/ofri-peretz/i-ran-the-same-nestjs-prompt-on-claude-and-gemini-one-got-6-security-errors-heres-what-both-1fnf"&gt;written up in full separately&lt;/a&gt;. Short version: asked for a users service, Gemini's CLI reached for idiomatic NestJS — class-level &lt;code&gt;@UseGuards&lt;/code&gt;, &lt;code&gt;@Exclude()&lt;/code&gt; on the password field, &lt;code&gt;class-validator&lt;/code&gt; on every DTO. &lt;code&gt;nestjs-security&lt;/code&gt; found &lt;strong&gt;2&lt;/strong&gt; issues. Claude wrote functionally identical code with none of that scaffolding and drew &lt;strong&gt;6&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In an opinionated framework, Gemini defaults to the secure idiom. Hold that thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2 — JWT: a 5–5 tie, missing the identical RFC 8725 steps
&lt;/h2&gt;

&lt;p&gt;Both wrote clean &lt;code&gt;jsonwebtoken&lt;/code&gt; code: a signed login token, middleware that &lt;em&gt;verifies&lt;/em&gt; (no &lt;code&gt;jwt.decode&lt;/code&gt; shortcut, no &lt;code&gt;alg: none&lt;/code&gt;, no hardcoded secret — every catastrophic JWT footgun avoided by both). Then both stopped at exactly the same place:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;jwt&lt;/code&gt; rule&lt;/th&gt;
&lt;th&gt;CWE&lt;/th&gt;
&lt;th&gt;Gemini&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://eslint.interlace.tools/docs/security/plugin-jwt/rules/require-algorithm-whitelist" rel="noopener noreferrer"&gt;&lt;code&gt;require-algorithm-whitelist&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;CWE-757&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://eslint.interlace.tools/docs/security/plugin-jwt/rules/require-audience-validation" rel="noopener noreferrer"&gt;&lt;code&gt;require-audience-validation&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;CWE-287&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;require-issuer-validation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CWE-287&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;require-max-age&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CWE-294&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-sensitive-payload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CWE-359&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's &lt;em&gt;why it survives review&lt;/em&gt;: a reviewer reading &lt;code&gt;jwt.verify(token, secret)&lt;/code&gt; sees a verify call and ships it. Nobody asks the next question — verifies &lt;em&gt;for whom?&lt;/em&gt; Without an &lt;code&gt;audience&lt;/code&gt; option, a token your service minted for a &lt;em&gt;different&lt;/em&gt; API sails straight through. That blind spot is exactly what &lt;code&gt;require-audience-validation&lt;/code&gt; encodes, and it's why both models — and most human review — walk past it. Call the round 5–5.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 3 — MongoDB: both leaked passwords, neither got injected
&lt;/h2&gt;

&lt;p&gt;The finding that should make you check your own repo first: both models wrote the search to return &lt;strong&gt;whole documents — password hashes included — with no projection&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Both models, essentially:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ships passwordHash to the caller&lt;/span&gt;
&lt;span class="c1"&gt;// the fix neither wrote:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-passwordHash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lean&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;code&gt;require-projection&lt;/code&gt; (CWE-200) and &lt;code&gt;no-select-sensitive-fields&lt;/code&gt; firing on both sides. The pleasant surprise: the prompt hands a user-supplied search object straight into a Mongoose query — a textbook &lt;code&gt;$where&lt;/code&gt;/operator-injection trap — and &lt;strong&gt;both models sidestepped it.&lt;/strong&gt; Zero &lt;code&gt;no-operator-injection&lt;/code&gt;, zero &lt;code&gt;no-unsafe-where&lt;/code&gt;, zero &lt;code&gt;no-unsafe-query&lt;/code&gt; on either side. The frontier has internalized "don't interpolate untrusted input into a query." It just hasn't internalized "don't hand back the password column."&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;mongodb-security&lt;/code&gt; rule&lt;/th&gt;
&lt;th&gt;CWE&lt;/th&gt;
&lt;th&gt;Gemini&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;require-schema-validation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CWE-20&lt;/td&gt;
&lt;td&gt;✗✗✗&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://eslint.interlace.tools/docs/security/plugin-mongodb-security/rules/require-projection" rel="noopener noreferrer"&gt;&lt;code&gt;require-projection&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;CWE-200&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;require-lean-queries&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CWE-400&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-select-sensitive-fields&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CWE-200&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-unbounded-find&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CWE-400&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-bypass-middleware&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CWE-284&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Different distribution, same total (8–8) — but one cell deserves an honest call-out, because it cuts &lt;em&gt;against&lt;/em&gt; my own headline: &lt;code&gt;require-schema-validation&lt;/code&gt; fired &lt;strong&gt;three times on Gemini and once on Claude&lt;/strong&gt;. Here, Claude was the more disciplined one — it wired up more of Mongoose's schema-level validation, where Gemini leaned on looser typing. "Gemini is frontier-grade" doesn't mean "Gemini wins every cell"; this is a cell it lost. (And yes, &lt;code&gt;require-lean-queries&lt;/code&gt; is CWE-400, not classic injection — &lt;code&gt;.lean()&lt;/code&gt; returns plain objects instead of hydrated Mongoose documents, and on an unbounded search that's a real memory-exhaustion lever, which is why it's scored as a resource control, not a nice-to-have.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 4 — General injection: the count lies
&lt;/h2&gt;

&lt;p&gt;*The asterisk. On a raw injection-prone API (JSON/XML import, dynamic search, password reset), &lt;code&gt;secure-coding&lt;/code&gt; flagged Gemini &lt;strong&gt;9&lt;/strong&gt; and Claude &lt;strong&gt;13&lt;/strong&gt; — but that count is backwards. Claude's extra findings came from Claude &lt;em&gt;doing more&lt;/em&gt;: it explicitly rejected XML &lt;code&gt;DOCTYPE&lt;/code&gt;/&lt;code&gt;ENTITY&lt;/code&gt; (XXE-hardened), allowlisted the search field, and actually implemented token verification. And here's the honest part — it implemented some of that &lt;em&gt;insecurely&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Claude's reset flow — CWE-208, timing-unsafe:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;providedToken&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;storedToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ...reset... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// The fix — hash both to a fixed length first, then compare:&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;providedToken&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storedToken&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ...reset... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Direct timingSafeEqual(Buffer.from(a), Buffer.from(b)) throws if lengths differ,&lt;/span&gt;
&lt;span class="c1"&gt;// leaking token length to an attacker — always normalise lengths first.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude wrote that &lt;code&gt;===&lt;/code&gt; comparison &lt;strong&gt;five times&lt;/strong&gt; (&lt;code&gt;no-insecure-comparison&lt;/code&gt;, CWE-208). It's the one &lt;em&gt;real&lt;/em&gt; vulnerability either model introduced across this entire benchmark — and it exists precisely &lt;em&gt;because&lt;/em&gt; Claude built the verification surface at all. Gemini's leaner 97 lines issued a token and never compared one, so it had no surface to get wrong. Count favored Gemini; substance is genuinely mixed: Claude hardened more &lt;strong&gt;and&lt;/strong&gt; shipped the only real bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest caveat: task type changes everything
&lt;/h2&gt;

&lt;p&gt;Before anyone screenshots "Gemini ties Claude on security" — that holds for &lt;em&gt;realistic, structured&lt;/em&gt; tasks. On &lt;strong&gt;isolated, security-sensitive functions&lt;/strong&gt; it inverts. In a &lt;a href="https://dev.to/ofri-peretz/aggregate-benchmarks-lie-heres-what-700-ai-functions-look-like-by-security-domain-1hgj"&gt;separate 700-function run&lt;/a&gt; scored by these same plugins, the average vulnerability rate was &lt;strong&gt;63%&lt;/strong&gt; — and &lt;strong&gt;Gemini 2.5 Pro was the &lt;em&gt;most&lt;/em&gt; vulnerable model at 72.9%&lt;/strong&gt; (Flash sat mid-pack at 63.6%). Build a &lt;em&gt;service&lt;/em&gt; and Gemini's scaffolding shines; &lt;a href="https://dev.to/ofri-peretz/i-let-claude-write-60-functions-65-75-had-security-vulnerabilities-414o"&gt;ask for a stack of risky functions in isolation&lt;/a&gt; and every model — Gemini included — leaks. Context is the variable, not the logo.&lt;/p&gt;

&lt;p&gt;(The whole method rests on "scored by the plugins I wrote," so a fair question is whether the &lt;em&gt;scorer&lt;/em&gt; is trustworthy — &lt;a href="https://dev.to/ofri-peretz/what-ground-truth-caught-that-unit-tests-missed-3-real-bugs-in-9-flagship-lint-rules-o0b"&gt;here's what ground truth caught that my own unit tests missed&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually means
&lt;/h2&gt;

&lt;p&gt;Strip out the leaderboard and two things are left:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gemini is a frontier-grade secure default.&lt;/strong&gt; It tied or beat Claude in three of four domains, won the framework round outright, and never shipped a high-severity injection or auth-bypass bug — no NoSQL operator injection, no &lt;code&gt;alg: none&lt;/code&gt;, no &lt;code&gt;jwt.decode&lt;/code&gt;-without-verify, no &lt;code&gt;eval&lt;/code&gt;, no hardcoded credentials, in any domain. (The lone &lt;em&gt;introduced&lt;/em&gt; vulnerability was Claude's timing-unsafe token comparison — CWE-208. In fairness it's probably the &lt;em&gt;lower&lt;/em&gt;-risk finding here: a high-entropy token compared after a DB lookup is hard to attack through network jitter, and the &lt;em&gt;latent&lt;/em&gt; gap both models share — an unpinned JWT algorithm with no &lt;code&gt;aud&lt;/code&gt;/&lt;code&gt;iss&lt;/code&gt; validation — is the one most appsec engineers would patch first. "Hardening" undersells it; I'm flagging it as the missing control, not as harmless.) If you're building with Gemini, you're starting from a credible security baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No frontier model is security-&lt;em&gt;complete&lt;/em&gt;.&lt;/strong&gt; The misses weren't random — they were the &lt;em&gt;same&lt;/em&gt; negative-space hardening (algorithm allowlists, audience validation, query projections, schema validation, auth) that no model infers from a feature prompt, because the prompt never named it. That gap doesn't close with a better model. It closes with a tool that checks the constraints you didn't write down.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Which is the whole point of static analysis: it asks the questions your prompt didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config (runs on output from either model)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eslint-plugin-jwt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;mongodbSecurity&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eslint-plugin-mongodb-security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;nestjsSecurity&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eslint-plugin-nestjs-security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;secureCoding&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eslint-plugin-secure-coding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;tsParser&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@typescript-eslint/parser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="c1"&gt;// TypeScript parser so decorators and types resolve&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;languageOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tsParser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// Each plugin ships a flat `recommended` preset (plugin + rules)&lt;/span&gt;
  &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;mongodbSecurity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;nestjsSecurity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;secureCoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-jwt eslint-plugin-mongodb-security &lt;span class="se"&gt;\&lt;/span&gt;
  eslint-plugin-nestjs-security eslint-plugin-secure-coding
npx eslint src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every rule maps to a CWE so an AI agent and a human read the same signal. Full docs at &lt;a href="https://eslint.interlace.tools" rel="noopener noreferrer"&gt;eslint.interlace.tools&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Which hardening step does &lt;em&gt;your&lt;/em&gt; AI-generated code skip most — the algorithm allowlist, the audience check, or the query projection? Open the file and look. I'll bet it's at least two of the three. Tell me which ones — I'm collecting scorecards.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://dev.to/ofri-peretz/series/ai-security-benchmark-series"&gt;AI Security Benchmark Series&lt;/a&gt;:&lt;/em&gt;&lt;br&gt;
&lt;em&gt;← &lt;a href="https://dev.to/ofri-peretz/i-ran-the-same-nestjs-prompt-on-claude-and-gemini-one-got-6-security-errors-heres-what-both-1fnf"&gt;Same NestJS Prompt. Claude Got 6 Security Errors. Gemini Got 2.&lt;/a&gt; · &lt;strong&gt;Frontier Dead Heat (you are here)&lt;/strong&gt; · next → (coming soon)&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;📦 &lt;a href="https://www.npmjs.com/package/eslint-plugin-jwt" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-jwt&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/eslint-plugin-mongodb-security" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-mongodb-security&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/eslint-plugin-nestjs-security" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-nestjs-security&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/eslint-plugin-secure-coding" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-secure-coding&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://eslint.interlace.tools" rel="noopener noreferrer"&gt;Rule docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/eslint" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star on GitHub&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/ofri-peretz" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://x.com/ofriperetzdev" rel="noopener noreferrer"&gt;X&lt;/a&gt; | &lt;a href="https://linkedin.com/in/ofri-peretz" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/ofri-peretz"&gt;Dev.to&lt;/a&gt; | &lt;a href="https://ofriperetz.dev" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;👇 &lt;strong&gt;Drop your scorecard below&lt;/strong&gt; — algorithm allowlist, audience check, or query projection: which one does your AI-generated code skip? I'm collecting them.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>googleai</category>
      <category>eslint</category>
    </item>
    <item>
      <title>The Bug That Passes Every Toolchain Check: Circular Dependencies in JavaScript</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sat, 30 May 2026 21:46:36 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/what-are-circular-dependencies-in-javascript-and-why-they-break-things-51jd</link>
      <guid>https://dev.to/ofri-peretz/what-are-circular-dependencies-in-javascript-and-why-they-break-things-51jd</guid>
      <description>&lt;p&gt;A circular dependency is one of the few bugs that passes every check your toolchain runs.&lt;/p&gt;

&lt;p&gt;TypeScript compiles it cleanly. The tests pass. The build succeeds. The app ships. And somewhere deep in your import graph, a developer is staring at a &lt;code&gt;TypeError: X is not a constructor&lt;/code&gt; that disappears the moment they add a &lt;code&gt;console.log&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here are the three patterns that create them, what Node.js, webpack, Rollup, and esbuild actually do with them — they don't solve the problem, they each make a different tradeoff — and how to stop them from forming.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a circular dependency actually is
&lt;/h2&gt;

&lt;p&gt;A circular dependency exists when module A imports from module B, which imports — directly or transitively — from module A.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// user.service.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;formatUser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.utils&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// user.utils.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ← closes the loop&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither developer planned this. &lt;code&gt;user.service.ts&lt;/code&gt; needed a formatter. &lt;code&gt;user.utils.ts&lt;/code&gt; needed the service type for a helper added three sprints later. Nobody saw the cycle form — they just saw two reasonable imports.&lt;/p&gt;

&lt;p&gt;This is how every circular dependency is born: through incremental, individually sensible decisions.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 3 patterns that create them
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Barrel files (&lt;code&gt;index.ts&lt;/code&gt; re-exports)
&lt;/h3&gt;

&lt;p&gt;Barrel files are the biggest source of accidental cycles in TypeScript projects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// features/user/index.ts — re-exports everything in the feature&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserRepository&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.repository&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserController&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.controller&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;formatUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;validateUser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.utils&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every file in the &lt;code&gt;user&lt;/code&gt; feature imports from &lt;code&gt;../user&lt;/code&gt; (the barrel) for cleaner paths. And any utility the barrel re-exports cannot safely import anything else from the barrel without creating a cycle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// user.utils.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ← imports the barrel&lt;/span&gt;
&lt;span class="c1"&gt;// The barrel re-exports user.utils → user.utils imports the barrel → cycle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Teams adopt barrel files for developer convenience. They inadvertently create import graphs where everything is connected to everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Shared types modules
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;types.ts&lt;/code&gt; file that both sides of a feature boundary import looks harmless — it only contains type definitions, after all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// types.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// needed for a return type&lt;/span&gt;

&lt;span class="c1"&gt;// user.service.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UserOptions&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./types&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// cycle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TypeScript makes this worse. &lt;code&gt;import type&lt;/code&gt; declarations are erased at compile time, but the module graph your bundler or Node.js sees is determined at load time. Many cycle detectors skip &lt;code&gt;import type&lt;/code&gt; edges entirely — reporting 0 cycles on a graph that has real structural problems. The risk: the moment someone adds a value export to that same file, the type-only cycle becomes a value cycle, and that transition is invisible in code review.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Cross-feature imports
&lt;/h3&gt;

&lt;p&gt;As a codebase grows, features start borrowing from each other directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// orders/order.service.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserProfile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../users/user.service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// orders imports users&lt;/span&gt;

&lt;span class="c1"&gt;// users/user.service.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;OrderHistory&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../orders/order.service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// users imports orders → cycle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither import looks wrong in isolation. &lt;code&gt;OrderService&lt;/code&gt; needs the user's profile. &lt;code&gt;UserService&lt;/code&gt; needs to surface order history. Both make sense individually. Together they create an architectural cycle that neither domain should own.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Node.js, webpack, Rollup, and esbuild do with them
&lt;/h2&gt;

&lt;p&gt;Runtimes and bundlers don't solve circular dependencies — they each make a different tradeoff, each with its own failure mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node.js (CommonJS):&lt;/strong&gt; Returns a partially-constructed &lt;code&gt;module.exports&lt;/code&gt; for the module still being loaded. If module A is still evaluating when module B requires it, B gets an empty object &lt;code&gt;{}&lt;/code&gt; — the exports haven't been assigned yet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// a.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;A&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;B&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// b.js — requires a.js before a.js has finished evaluating&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// A is {} — not yet assigned&lt;/span&gt;
&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEFAULT_B&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// TypeError: A is not a constructor&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Node.js (ESM):&lt;/strong&gt; Uses live bindings and the Temporal Dead Zone. ESM hoists imports and evaluates modules in post-order — child before parent. When a cycle exists, the module being waited on hasn't finished evaluating yet. Reading a binding that hasn't been initialized throws &lt;code&gt;ReferenceError&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// a.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;B&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_A&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// top-level: runs at module load&lt;/span&gt;

&lt;span class="c1"&gt;// b.ts — evaluated before a.ts finishes&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_A&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a.ts is still loading&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;USES_A&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_A&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ReferenceError: Cannot access 'DEFAULT_A' before initialization&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Class instantiation in method bodies is fine (lazily resolved at call time, not load time). Only top-level evaluation — module-scope &lt;code&gt;const&lt;/code&gt;, &lt;code&gt;let&lt;/code&gt;, or class fields that run at class definition — breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rollup / esbuild:&lt;/strong&gt; Bundle all modules into a single file and try to reorder them to resolve the cycle. This works for many cycles, but when a true circular dependency exists — where A genuinely needs B to be initialized before A finishes — no linear ordering can satisfy both. Rollup inserts a wrapper; esbuild typically uses IIFEs. The result: the bundle may produce different initialization order than Node.js, meaning a bug that appeared in Node.js may not appear in the Rollup bundle, and vice versa. You cannot test your way out of a cycle by relying on one runtime's ordering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;webpack:&lt;/strong&gt; Behaves similarly to Node.js CJS semantics for CommonJS modules — partially-constructed exports at the point of the cycle. For ESM modules bundled by webpack, live bindings are preserved, and the same TDZ behavior applies.&lt;/p&gt;

&lt;p&gt;The diagnostic is the same in every runtime: &lt;code&gt;console.log&lt;/code&gt; changes execution timing just enough to alter the evaluation order — the bug appears or disappears based on load order, which changes when any import is added anywhere in the graph.&lt;/p&gt;




&lt;h2&gt;
  
  
  What circular dependencies silently break
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Bundle size at barrel boundaries
&lt;/h3&gt;

&lt;p&gt;Bundlers tree-shake based on export usage — but when a value-exporting barrel is part of a cycle, the barrel module becomes a side-effectful unit. Because the barrel is reachable through the cycle regardless of which specific exports are used, its siblings can get pulled in. The practical result: adding a single value import to a barrel file that participates in a cycle can expand your bundle without a warning. No error. No size diff in the PR. Just a larger bundle and slower load times at next deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test isolation and speed
&lt;/h3&gt;

&lt;p&gt;Unit tests work by loading a module and its dependencies in isolation. Circular dependencies make isolation impossible — loading module A loads module B loads module A, pulling in the entire graph.&lt;/p&gt;

&lt;p&gt;A test for a 50-line utility ends up loading the ORM, the auth layer, and the HTTP client. Test suites get slower with every feature added, and teams blame "the test framework" rather than the import graph.&lt;/p&gt;

&lt;h3&gt;
  
  
  Initialization order bugs that survive review
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;console.log&lt;/code&gt; disappearing-bug pattern above is the tell. Because each runtime handles cycles differently, the same cycle can produce different behavior depending on the bundler target, the build mode, and the import order — all of which can change between feature branches without touching the cyclic code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why they accumulate undetected
&lt;/h2&gt;

&lt;p&gt;Most teams have &lt;code&gt;eslint-plugin-import/no-cycle&lt;/code&gt; installed. Most of those teams have it reporting 0 cycles. &lt;a href="https://dev.to/ofri-peretz/no-cycle-finds-0-cycles-in-nextjs-and-other-lies-caches-tell-you-3ld8"&gt;This is not because their codebase is clean.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two reasons cycles hide:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type-only cycles are invisible at runtime.&lt;/strong&gt; A cycle that closes through &lt;code&gt;import type { Foo }&lt;/code&gt; is erased at compile time — no bundle edge, no module load edge. The risk: the moment someone adds a value export to that file, the type-only cycle becomes a value cycle silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Depth limits silently truncate the search.&lt;/strong&gt; Some detectors impose a &lt;code&gt;maxDepth&lt;/code&gt; on DFS traversal. Cycles longer than that limit are never reported — the rule exits clean and nobody knows. The &lt;a href="https://dev.to/ofri-peretz/no-cycle-finds-0-cycles-in-nextjs-and-other-lies-caches-tell-you-3ld8"&gt;cache bug we found in our own rule&lt;/a&gt; is a concrete example of this: the rule reported 0 cycles on 14,556 files while correctly finding 5 on a 33-file subset.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to fix them
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Barrel file cycles: use direct imports
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (causes cycles through the barrel):&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// After (direct import, no barrel in the path):&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../user/user.service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most impactful change for most codebases. Barrel files are a developer convenience that bundlers and linters pay the cost for.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared type cycles: extract a dedicated types layer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// domain-types.ts — zero imports from your own code&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UserRole&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;UserRole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// types.ts imports from domain-types.ts safely&lt;/span&gt;
&lt;span class="c1"&gt;// service.ts imports from domain-types.ts safely — no cycle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Types that need to be shared across a boundary belong in a module with zero imports from your own codebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cross-feature cycles: inversion of control
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: orders/ imports from users/, users/ imports from orders/ → architectural cycle&lt;/span&gt;

&lt;span class="c1"&gt;// After: orders/ defines an interface it needs&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;OrderUserAddress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;street&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// users/ implements the interface in its own adapter&lt;/span&gt;
&lt;span class="c1"&gt;// orders/ accepts the interface — no import of the User domain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How to detect and prevent them
&lt;/h2&gt;

&lt;p&gt;Two tools, two roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/pahen/madge" rel="noopener noreferrer"&gt;madge&lt;/a&gt;&lt;/strong&gt; for a one-time audit: see every cycle in your codebase today&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ESLint&lt;/strong&gt; for ongoing prevention: catches cycles as you create them, in CI on every PR
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Audit what you have today&lt;/span&gt;
npx madge &lt;span class="nt"&gt;--circular&lt;/span&gt; &lt;span class="nt"&gt;--extensions&lt;/span&gt; ts src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For CI prevention, &lt;code&gt;eslint-plugin-import-next&lt;/code&gt; is the fast-tier replacement for &lt;code&gt;eslint-plugin-import&lt;/code&gt; — it resolves each file's import graph once and caches globally, instead of re-traversing from every entry point. &lt;a href="https://dev.to/ofri-peretz/eslint-plugin-import-vs-eslint-plugin-import-next-up-to-100x-faster-1afa"&gt;The benchmark&lt;/a&gt; (M2 MacBook Pro, synthetic corpus): at 10K files, the original plugin was terminated after 10 minutes without completing; &lt;code&gt;import-next&lt;/code&gt; finishes in ~6 seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-import-next
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;importNext&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eslint-plugin-import-next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;tsParser&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@typescript-eslint/parser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;languageOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tsParser&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;import-next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;importNext&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;import-next/no-cycle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx eslint src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Madge tells you what you have. ESLint prevents new ones from forming.&lt;/p&gt;




&lt;p&gt;For the full data — cycle counts across Payload, Next.js, Medusa, Strapi, and Twenty, with per-project breakdowns of which pattern creates the most cycles — see the &lt;a href="https://dev.to/ofri-peretz/series/circular-dependencies"&gt;companion piece&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where do circular dependencies hide in your codebase — data layer, domain layer, or somewhere you didn't expect? The &lt;code&gt;console.log&lt;/code&gt; trick is usually the tell.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://dev.to/ofri-peretz/series/circular-dependencies"&gt;Circular Dependencies&lt;/a&gt; series. Next: &lt;a href="https://dev.to/ofri-peretz/series/circular-dependencies"&gt;We Scanned Payload, Next.js, and 3 More OSS Projects for Circular Dependencies →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;📦 &lt;a href="https://www.npmjs.com/package/eslint-plugin-import-next" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-import-next&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://eslint.interlace.tools/docs/imports/plugin-import-next" rel="noopener noreferrer"&gt;Rule docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/eslint" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star on GitHub&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/ofri-peretz" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://x.com/ofriperetzdev" rel="noopener noreferrer"&gt;X&lt;/a&gt; | &lt;a href="https://linkedin.com/in/ofri-peretz" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/ofri-peretz"&gt;Dev.to&lt;/a&gt; | &lt;a href="https://ofriperetz.dev" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>eslint</category>
      <category>node</category>
    </item>
    <item>
      <title>Payload CMS Has 508 Circular Dependencies. Next.js Has 17. Here's Why They Form in Every Large JS Codebase.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sat, 30 May 2026 19:19:59 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/payload-cms-has-508-circular-dependencies-nextjs-has-17-heres-why-they-form-in-every-large-js-41f5</link>
      <guid>https://dev.to/ofri-peretz/payload-cms-has-508-circular-dependencies-nextjs-has-17-heres-why-they-form-in-every-large-js-41f5</guid>
      <description>&lt;p&gt;We ran &lt;a href="https://github.com/pahen/madge" rel="noopener noreferrer"&gt;madge&lt;/a&gt; — a third-party, vendor-neutral cycle detector, not my plugin — across some of the most popular open-source JavaScript projects. Here is what we found:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;Stars&lt;/th&gt;
&lt;th&gt;Files analyzed&lt;/th&gt;
&lt;th&gt;Circular dependencies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/payloadcms/payload" rel="noopener noreferrer"&gt;Payload CMS&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;33K&lt;/td&gt;
&lt;td&gt;675&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;508&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/vercel/next.js" rel="noopener noreferrer"&gt;Next.js&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;131K&lt;/td&gt;
&lt;td&gt;14,556&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/medusajs/medusa" rel="noopener noreferrer"&gt;Medusa&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;27K&lt;/td&gt;
&lt;td&gt;803&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/strapi/strapi" rel="noopener noreferrer"&gt;Strapi&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;65K&lt;/td&gt;
&lt;td&gt;259&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/twentyhq/twenty" rel="noopener noreferrer"&gt;Twenty&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;25K&lt;/td&gt;
&lt;td&gt;5,702&lt;/td&gt;
&lt;td&gt;0 ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Payload CMS has 508 circular dependencies in 675 TypeScript files. That is not a typo.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Methodology: &lt;code&gt;npx madge --circular --extensions ts &amp;lt;path&amp;gt;&lt;/code&gt;, default config, run 2026-05-30 against each repo's then-current &lt;code&gt;main&lt;/code&gt; at the package root. Cycle counts drift as these repos and madge evolve — re-run the command to compare against the current tree.)&lt;/em&gt; And nobody who works on Payload wrote a single line with the intention of creating a cycle. Every one of those 508 paths through the import graph was the result of incremental, individually reasonable decisions.&lt;/p&gt;

&lt;p&gt;This is how circular dependencies work. They accumulate silently. The build succeeds. The tests pass. The app ships. And somewhere in that import graph, modules are pulling in code they do not need, tests are loading the entire dependency tree, and a developer is staring at an &lt;code&gt;undefined&lt;/code&gt; error that only happens during initialization and disappears the moment they add a &lt;code&gt;console.log&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Circular dependencies are the silent technical debt of every large JavaScript codebase. Here is why they form, what they quietly break, and how to find all of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  What a circular dependency actually is
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A circular dependency&lt;/strong&gt; exists when module A imports from module B, which imports (directly or transitively) from module A.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// user.service.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;formatUser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.utils&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// user.utils.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ← closes the loop&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither developer planned this. &lt;code&gt;user.service.ts&lt;/code&gt; needed a formatter. &lt;code&gt;user.utils.ts&lt;/code&gt; needed the service type for a helper function added three sprints later. Nobody saw the cycle form — they just saw two reasonable imports.&lt;/p&gt;

&lt;p&gt;This is how every circular dependency is born: through incremental, individually sensible decisions.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 3 patterns that create them in JavaScript and TypeScript
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Barrel files (&lt;code&gt;index.ts&lt;/code&gt; re-exports)
&lt;/h3&gt;

&lt;p&gt;Barrel files are the biggest source of accidental cycles in TypeScript projects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// features/user/index.ts — re-exports everything in the feature&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserRepository&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.repository&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserController&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.controller&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;formatUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;validateUser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.utils&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every file in the &lt;code&gt;user&lt;/code&gt; feature imports from &lt;code&gt;../user&lt;/code&gt; (the barrel) for convenience. And any utility that the barrel re-exports cannot safely import anything else from the barrel without creating a cycle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// user.utils.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ← imports the barrel&lt;/span&gt;
&lt;span class="c1"&gt;// The barrel re-exports user.utils → user.utils imports the barrel → cycle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Teams adopt barrel files for cleaner import paths. They inadvertently create import graphs where everything is connected to everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Shared types modules
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;types.ts&lt;/code&gt; or &lt;code&gt;interfaces.ts&lt;/code&gt; file that both sides of a feature boundary import seems harmless — it only contains type definitions, after all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// types.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./user.service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// needed for a type&lt;/span&gt;

&lt;span class="c1"&gt;// user.service.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UserOptions&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./types&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// and now we have a cycle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TypeScript makes this worse. &lt;code&gt;import type&lt;/code&gt; declarations are erased at compile time — they don't exist at runtime — but the module graph your bundler or Node.js sees is determined at load time, before the TypeScript compiler's type-erasure runs. Many cycle detectors (including &lt;code&gt;eslint-plugin-import/no-cycle&lt;/code&gt;) skip &lt;code&gt;import type&lt;/code&gt; edges entirely, so they may report 0 cycles on a graph that has real structural issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Cross-feature imports
&lt;/h3&gt;

&lt;p&gt;As a codebase grows, features start borrowing from each other. &lt;code&gt;OrderService&lt;/code&gt; needs a user's address, so it imports from the &lt;code&gt;User&lt;/code&gt; domain. &lt;code&gt;UserService&lt;/code&gt; tracks order history, so it imports from the &lt;code&gt;Order&lt;/code&gt; domain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// orders/order.service.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserAddress&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../users/user.types&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// users/user.service.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;OrderHistory&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../orders/order.types&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// cycle complete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is architecturally wrong (neither domain should own the other), but it emerges naturally as product requirements evolve. Nobody refactors the boundary; they just add the import and move on.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why famous projects have hundreds of them
&lt;/h2&gt;

&lt;p&gt;The table at the top is not an indictment — it is a pattern (get the per-project cycle paths with &lt;code&gt;madge --circular --json&lt;/code&gt;). Payload's 508 cycles are almost entirely the shared-types pattern at scale. With 675 source files, their &lt;code&gt;admin/types.ts&lt;/code&gt; participates in dozens of cycles because it imports from domain modules while being imported by nearly everything in the admin layer.&lt;/p&gt;

&lt;p&gt;Strapi's 5 cycles in their core package trace directly to barrel files: &lt;code&gt;Strapi.ts → configuration/index.ts&lt;/code&gt; closes a loop because the configuration barrel re-exports modules that import from &lt;code&gt;Strapi.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Medusa's 8 cycles in core-flows are the cross-feature pattern: customer steps import from customer workflows, and those workflows import from the step index — the classic &lt;code&gt;steps/index.ts → workflows/index.ts → steps/index.ts&lt;/code&gt; loop that barrel files create.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Twenty is the outlier at 0 cycles.&lt;/strong&gt; They enforce strict domain boundaries and avoid cross-domain barrel files. It is achievable from the start of a project. It becomes progressively harder to retroactively enforce as the codebase grows.&lt;/p&gt;




&lt;h2&gt;
  
  
  What circular dependencies silently break
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Bundle bloat
&lt;/h3&gt;

&lt;p&gt;Circular import graphs can defeat tree-shaking. When module A and B form a cycle, bundlers must conservatively include both — along with everything they depend on — because they cannot statically prove which exports are actually used in the presence of the cycle.&lt;/p&gt;

&lt;p&gt;In practice: adding a &lt;strong&gt;value import&lt;/strong&gt; (not a type import — those are erased at compile time) to a barrel file that is already part of a cycle can pull unexpected code into your bundle. No error. No warning. Just a larger bundle and slower load times. This is why bundle size regressions often appear with no obvious code change — the added import closed a cycle path that the bundler's tree-shaking couldn't see through.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test isolation and speed
&lt;/h3&gt;

&lt;p&gt;Unit tests work by loading a module and its dependencies in isolation. Circular dependencies make isolation impossible — loading module A loads module B loads module A, pulling in the entire graph.&lt;/p&gt;

&lt;p&gt;A test for a 50-line utility function ends up loading the ORM, the authentication layer, and the HTTP client. The test suite gets slower with every feature added, and teams blame "the test framework" or "the CI machine" rather than the import graph structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Initialization order bugs
&lt;/h3&gt;

&lt;p&gt;This is the most insidious failure mode. At runtime, Node.js resolves circular dependencies by returning an &lt;strong&gt;incomplete module&lt;/strong&gt; — an empty object or a partially initialized class — at the point of the cycle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// a.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;B&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// b.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;B&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// When b.ts loads, A is not yet defined — the cycle gives b.ts an empty object&lt;/span&gt;
  &lt;span class="nx"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// ← undefined at module initialization time&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result: &lt;code&gt;TypeError: A is not a constructor&lt;/code&gt;. Or worse — &lt;code&gt;parent&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt; silently, and the error surfaces 10 function calls later in code that has nothing to do with the import.&lt;/p&gt;

&lt;p&gt;These bugs are notoriously hard to reproduce. They appear and disappear based on load order, which can change when a new import is added anywhere in the graph. Adding a &lt;code&gt;console.log&lt;/code&gt; changes the timing and the bug disappears — classic heisenbug.&lt;/p&gt;

&lt;h3&gt;
  
  
  CommonJS vs ESM: how the failure differs
&lt;/h3&gt;

&lt;p&gt;Circular imports cause the most confusing bugs when top-level code in one module references something from a circular counterpart before that counterpart has finished evaluating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In CommonJS (&lt;code&gt;require&lt;/code&gt;):&lt;/strong&gt; Node.js returns a partially-constructed &lt;code&gt;module.exports&lt;/code&gt; for the module still being loaded. The result: you get &lt;code&gt;undefined&lt;/code&gt; where you expect a class — at the top level of the file, before anything instantiates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// a.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;A&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;B&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// b.js — loads a.js before a.js has finished&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;A&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// A is undefined — a.js hasn't exported yet&lt;/span&gt;
&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEFAULT_B&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// TypeError: A is not a constructor — at module load time&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;In ESM (&lt;code&gt;import&lt;/code&gt;):&lt;/strong&gt; Top-level code that references a circularly-imported value before its source module has finished evaluating throws &lt;code&gt;ReferenceError: Cannot access 'X' before initialization&lt;/code&gt; — the TDZ.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// a.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;B&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;B&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_A&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;A&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// ← top-level: runs at module load&lt;/span&gt;

&lt;span class="c1"&gt;// b.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_A&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a.ts is still loading — DEFAULT_A not yet evaluated&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;B&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;USES_A&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DEFAULT_A&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ReferenceError: Cannot access 'DEFAULT_A' before initialization&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Class instantiation in method bodies is fine (lazily resolved); it is only top-level evaluation — module-scope &lt;code&gt;const&lt;/code&gt;, &lt;code&gt;let&lt;/code&gt;, or inline class fields that execute at class &lt;em&gt;definition&lt;/em&gt; time — that breaks.&lt;/p&gt;

&lt;p&gt;The diagnostic in both cases: the error disappears when you add &lt;code&gt;console.log&lt;/code&gt; or reorder imports — that changes evaluation timing just enough to hide the cycle. Teams work around it with lazy imports or &lt;code&gt;setTimeout&lt;/code&gt; hacks that mask the structure problem rather than resolving it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why they accumulate undetected
&lt;/h2&gt;

&lt;p&gt;Plenty of teams have &lt;code&gt;eslint-plugin-import/no-cycle&lt;/code&gt; installed and see it reporting 0 cycles.&lt;/p&gt;

&lt;p&gt;There are two reasons they're still there:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason 1: Type-only cycles are architecturally circular but have zero runtime impact.&lt;/strong&gt; A cycle that closes through &lt;code&gt;import type { Foo }&lt;/code&gt; is erased at compile time — no bundle edge, no module load edge at runtime. Whether your detector flags it or not is a policy question: the cycle is real in the source graph but invisible in the runtime graph. The risk is that a type-only cycle becomes a value cycle the moment someone adds a value export to the same file — and that transition is invisible in code review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason 2: Depth limits silently truncate the search.&lt;/strong&gt; Some cycle detectors impose a &lt;code&gt;maxDepth&lt;/code&gt; limit on how deep the DFS traversal goes. Cycles longer than that limit are never found — the rule exits clean and nobody knows. (Both &lt;code&gt;eslint-plugin-import&lt;/code&gt; and &lt;code&gt;import-next&lt;/code&gt; default to unlimited depth; the truncation risk applies to any tool or config that sets a finite cap.)&lt;/p&gt;




&lt;h2&gt;
  
  
  How to find all of them
&lt;/h2&gt;

&lt;p&gt;If you've used &lt;code&gt;eslint-plugin-import/no-cycle&lt;/code&gt; and it reports 0, that may not mean your codebase is clean — it may mean the tool's defaults are hiding cycles from you. We found a specific cache bug in our own rule that caused the same behavior: &lt;a href="https://dev.to/ofri-peretz/no-cycle-finds-0-cycles-in-nextjs-and-other-lies-caches-tell-you-3ld8"&gt;0 cycles on 14,556 files, 5 on a 33-file subset&lt;/a&gt;. The number of cycles reported depends heavily on which tool you use and how it's configured.&lt;/p&gt;

&lt;p&gt;The standard &lt;code&gt;eslint-plugin-import/no-cycle&lt;/code&gt; rule is battle-tested and ubiquitous — the right default for most projects. It recomputes each file's import resolution per run, so the cost compounds as a monorepo grows, and cycle detection is often the first check teams drop from CI once a repo gets large enough.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure.&lt;/strong&gt; &lt;code&gt;eslint-plugin-import-next&lt;/code&gt; is the plugin I maintain (part of Interlace). The madge audit numbers above come from a third-party tool on public repos; the benchmark below is mine — harness linked so you can reproduce it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;eslint-plugin-import-next&lt;/code&gt; is built for scale: it persists the strongly-connected-components graph and resolution cache across the &lt;em&gt;entire&lt;/em&gt; lint run instead of recomputing per file — which is what makes it both faster and deterministic. Migration is mechanical (uninstall, install, rename the &lt;code&gt;import/*&lt;/code&gt; rule prefixes — covered below), not a rewrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Benchmark: &lt;code&gt;no-cycle&lt;/code&gt; rule only, synthetic corpus, M2 MacBook Pro (median of repeated runs):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Codebase size&lt;/th&gt;
&lt;th&gt;eslint-plugin-import&lt;/th&gt;
&lt;th&gt;eslint-plugin-import-next&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,000 files&lt;/td&gt;
&lt;td&gt;27ms&lt;/td&gt;
&lt;td&gt;1.05ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~26×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000 files&lt;/td&gt;
&lt;td&gt;149ms&lt;/td&gt;
&lt;td&gt;2.7ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~55×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000 files&lt;/td&gt;
&lt;td&gt;&amp;gt;10 min (terminated)&lt;/td&gt;
&lt;td&gt;~6s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;gt;100×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 10K files in this synthetic run, &lt;code&gt;eslint-plugin-import&lt;/code&gt; was terminated at an operator-imposed 10-minute cap (lower bound, not a measured completion); &lt;code&gt;import-next&lt;/code&gt; finished in ~6 seconds. That gap is why cycle detection gets dropped from CI on large repos — and why import-next is built so it doesn't have to be.&lt;/p&gt;

&lt;p&gt;The rule also fixes a cache-poisoning bug that caused non-deterministic results: the same repo, back-to-back runs, reported different cycle counts. &lt;a href="https://dev.to/ofri-peretz/no-cycle-finds-0-cycles-in-nextjs-and-other-lies-caches-tell-you-3ld8"&gt;We documented this in detail&lt;/a&gt; — the fix ensures consistent results whether you lint the full repo or a subset.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;eslint-plugin-import&lt;/th&gt;
&lt;th&gt;eslint-plugin-import-next&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Battle-tested, ubiquitous&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;— (newer)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drop-in compatible&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;oxlint plugin entry&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache-stable across subset/full runs (&lt;a href="https://dev.to/ofri-peretz/no-cycle-finds-0-cycles-in-nextjs-and-other-lies-caches-tell-you-3ld8"&gt;details&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tuned for 10K+ file monorepos&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Remove the old plugin first to avoid namespace conflicts&lt;/span&gt;
&lt;span class="c"&gt;# npm&lt;/span&gt;
npm uninstall eslint-plugin-import &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-import-next
&lt;span class="c"&gt;# yarn&lt;/span&gt;
yarn remove eslint-plugin-import &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; yarn add &lt;span class="nt"&gt;--dev&lt;/span&gt; eslint-plugin-import-next
&lt;span class="c"&gt;# pnpm&lt;/span&gt;
pnpm remove eslint-plugin-import &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pnpm add &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-import-next
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After uninstalling, rename your &lt;code&gt;import/*&lt;/code&gt; rule prefixes to &lt;code&gt;import-next/*&lt;/code&gt; — the rule names and options are identical, only the namespace changes, so it's a find-and-replace (otherwise those rules silently stop running). Requires Node ≥ 18 and ESLint 8, 9, or 10. The &lt;code&gt;@typescript-eslint/parser&lt;/code&gt; below is only needed if you lint &lt;code&gt;.ts&lt;/code&gt;/&lt;code&gt;.tsx&lt;/code&gt;; a pure-JS project can drop the &lt;code&gt;parser&lt;/code&gt; line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs — uses 'import-next' namespace to avoid conflicts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;importNext&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eslint-plugin-import-next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;tsParser&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@typescript-eslint/parser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;languageOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tsParser&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;import-next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;importNext&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;import-next/no-cycle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On &lt;strong&gt;&lt;a href="https://oxc.rs" rel="noopener noreferrer"&gt;Oxlint&lt;/a&gt;&lt;/strong&gt;? The plugin ships an &lt;code&gt;/oxlint&lt;/code&gt; entry — register it in &lt;code&gt;.oxlintrc.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"jsPlugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"eslint-plugin-import-next/oxlint"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ESLint:&lt;/span&gt;
npx eslint src/
&lt;span class="c"&gt;# Oxlint:&lt;/span&gt;
npx oxlint src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why ESLint over madge?&lt;/strong&gt; Madge is a great audit tool — run it once, see the landscape. ESLint with &lt;code&gt;no-cycle&lt;/code&gt; runs on every file save, in CI on every PR, and integrates with your editor to flag cycles as you create them. Madge tells you what you have; ESLint prevents new ones from forming. The two complement each other: use madge to see the full picture, add &lt;code&gt;import-next/no-cycle&lt;/code&gt; to your ESLint config to enforce going forward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnostic test:&lt;/strong&gt; Run on a complex subdirectory and compare to the full-repo result. If the subset finds more cycles — your current tool's cache or depth is hiding cycles. This is a reproducible signal that predates any particular tool; the subset test reveals what the full run misses.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to fix them
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fix barrel file cycles: explicit imports
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (causes cycles through the barrel):&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// After (direct import, no barrel in the path):&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../user/user.service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the most impactful change for most codebases. Barrel files are a developer convenience that bundlers and linters pay the cost for.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix shared type cycles: extract a dedicated types layer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before:&lt;/span&gt;
&lt;span class="c1"&gt;// types.ts imports from service.ts → service.ts imports from types.ts → cycle&lt;/span&gt;

&lt;span class="c1"&gt;// After:&lt;/span&gt;
&lt;span class="c1"&gt;// domain-types.ts — no imports from your own code, only external packages&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UserRole&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;UserRole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// types.ts can import from domain-types.ts safely&lt;/span&gt;
&lt;span class="c1"&gt;// service.ts can import from domain-types.ts safely&lt;/span&gt;
&lt;span class="c1"&gt;// No cycle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern: types that need to be shared across a boundary belong in a module with zero imports from your own codebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix cross-feature cycles: inversion of control
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before:&lt;/span&gt;
&lt;span class="c1"&gt;// orders/order.service.ts imports from users/&lt;/span&gt;
&lt;span class="c1"&gt;// users/user.service.ts imports from orders/&lt;/span&gt;
&lt;span class="c1"&gt;// → architectural cycle&lt;/span&gt;

&lt;span class="c1"&gt;// After: neither domain imports the other&lt;/span&gt;
&lt;span class="c1"&gt;// orders/ defines an interface it needs:&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;OrderUserAddress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;street&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// users/ implements the interface in its own adapter&lt;/span&gt;
&lt;span class="c1"&gt;// orders/ accepts the interface — no import of the User domain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a domain boundary fix. It takes more work but removes the architectural coupling that causes the cycle.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;Run the linter against your codebase and sort findings by how many files are in each cycle. Fix the largest cycles first — they're the ones with the most shared state and the most bundling impact.&lt;/p&gt;

&lt;p&gt;A codebase with 50 circular dependencies usually has 3–5 that are responsible for most of the blast radius. Fix those and the bundler, the tests, and the initialization order bugs often improve measurably.&lt;/p&gt;




&lt;p&gt;If your codebase has grown past 10K files and you've never run a cycle detector, start with the subset test: run &lt;code&gt;no-cycle&lt;/code&gt; on one complex subdirectory and compare to the full repo. If the subset finds more — your tool has a depth or cache issue, like the one &lt;a href="https://dev.to/ofri-peretz/no-cycle-finds-0-cycles-in-nextjs-and-other-lies-caches-tell-you-3ld8"&gt;we found and fixed in our own rule&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For context on ESLint cycle detection at scale — including a 100x speedup benchmark and the cache bug we fixed — see &lt;a href="https://dev.to/ofri-peretz/eslint-plugin-import-vs-eslint-plugin-import-next-up-to-100x-faster-1afa"&gt;Engineering the 100x Speedup: A Static Analysis Performance Report&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run &lt;code&gt;npx madge --circular --extensions ts&lt;/code&gt; on your own repo and drop the number in the comments — I'll bet someone beats 508.&lt;/strong&gt; And what's the most surprising place you've found a cycle: the data layer, the domain layer, or somewhere you never expected? If you swap in &lt;code&gt;import-next/no-cycle&lt;/code&gt; and it surfaces cycles your old config reported as 0, that's the &lt;a href="https://dev.to/ofri-peretz/no-cycle-finds-0-cycles-in-nextjs-and-other-lies-caches-tell-you-3ld8"&gt;cache bug&lt;/a&gt; — tell me your before/after count.&lt;/p&gt;




&lt;p&gt;📦 &lt;a href="https://www.npmjs.com/package/eslint-plugin-import-next" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-import-next&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://eslint.interlace.tools/docs/quality/plugin-import-next" rel="noopener noreferrer"&gt;Rule docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/ofri-peretz/eslint" rel="noopener noreferrer"&gt;⭐ Star on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/ofri-peretz" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://x.com/ofriperetzdev" rel="noopener noreferrer"&gt;X&lt;/a&gt; | &lt;a href="https://linkedin.com/in/ofri-peretz" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/ofri-peretz"&gt;Dev.to&lt;/a&gt; | &lt;a href="https://ofriperetz.dev" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>eslint</category>
      <category>javascript</category>
      <category>typescript</category>
      <category>node</category>
    </item>
    <item>
      <title>Math.random() Is Not Secure. I Found It Generating API Keys in a 44K-Star Repo.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sat, 30 May 2026 05:05:10 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/mathrandom-is-not-random-enough-i-found-it-building-api-keys-in-a-57k-star-repo-2pl1</link>
      <guid>https://dev.to/ofri-peretz/mathrandom-is-not-random-enough-i-found-it-building-api-keys-in-a-57k-star-repo-2pl1</guid>
      <description>&lt;p&gt;The top Stack Overflow answer for "generate a unique token in JavaScript" returns this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It appears in password reset tokens, session IDs, invite codes, and API keys across the JavaScript ecosystem. I found it verbatim in &lt;a href="https://github.com/calcom/cal.diy" rel="noopener noreferrer"&gt;calcom/cal.diy&lt;/a&gt; (~44K stars) — the MIT open-source edition of Cal.com:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`cal_live_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Math.random()&lt;/code&gt; is a PRNG, not a CSPRNG. An attacker who observes consecutive outputs from a server-side process can recover the internal state and predict every future token. Here is the attack, the ESLint rule that catches this pattern automatically, and the one-line fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Math.random() is dangerous for tokens
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Math.random()&lt;/code&gt; is a pseudo-random number generator — fast, but deterministic. Given V8's initial seed, its entire output sequence is fixed. An attacker who observes enough outputs can recover the 128-bit internal state and predict every future call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What you write:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// V8 uses xorshift128+ under the hood.&lt;/span&gt;
&lt;span class="c1"&gt;// After observing a handful of consecutive Math.random() calls from the same process:&lt;/span&gt;
&lt;span class="c1"&gt;// → internal state recoverable (see d0nutptr/v8_rand_buster)&lt;/span&gt;
&lt;span class="c1"&gt;// → all future Math.random() outputs predictable&lt;/span&gt;
&lt;span class="c1"&gt;// → all future tokens predictable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;CWE-338: Use of Cryptographically Weak Pseudo-Random Number Generator.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The exploit
&lt;/h3&gt;

&lt;p&gt;xorshift128+ is algebraically invertible. Given full-precision doubles (52 bits each), the 128-bit internal state can be recovered. The &lt;a href="https://github.com/d0nutptr/v8_rand_buster" rel="noopener noreferrer"&gt;v8_rand_buster&lt;/a&gt; implementation recovers state from 3–4 consecutive &lt;code&gt;Math.random()&lt;/code&gt; outputs. The following shows the attack flow — the concrete values are illustrative; v8_rand_buster handles the actual algebra.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Illustrative pseudocode — attacker collects consecutive Math.random() outputs:&lt;/span&gt;
&lt;span class="c1"&gt;//   cal_live_k7f2m9p8x3z  →  Math.random() returned 0.38914728471823...&lt;/span&gt;
&lt;span class="c1"&gt;//   cal_live_hq5r1n6wzt   →  Math.random() returned 0.72342901847612...&lt;/span&gt;
&lt;span class="c1"&gt;//   cal_live_p9x4b2m7vy   →  Math.random() returned 0.15678234019876...&lt;/span&gt;

&lt;span class="c1"&gt;// Feed full-precision doubles to state-recovery:&lt;/span&gt;
&lt;span class="c1"&gt;// → internal xorshift128+ state reconstructed (s0, s1 recovered algebraically)&lt;/span&gt;

&lt;span class="c1"&gt;// Predict the next Math.random() call:&lt;/span&gt;
&lt;span class="c1"&gt;// → 0.59123847561029...  →  "cal_live_s2v8n3k1xq"&lt;/span&gt;
&lt;span class="c1"&gt;// Attacker knows the next key before it is issued.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;xorshift128+ is algebraically invertible: given output values (full 52-bit precision), the 128-bit internal state can be solved. v8_rand_buster recovers state from 3–4 consecutive outputs. The practical requirement is consecutive outputs from the same process — which is achievable when key generation is observable and isolated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two attack surfaces, depending on where the code runs:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Client-side (the calcom/cal.diy case):&lt;/em&gt; The key is generated in the user's own browser. Client-side key generation exposes the credential in the client's environment before it reaches the server — visible in network logs, browser devtools, extensions, or XSS. The PRNG weakness is secondary to the architectural problem of minting a secret in the browser at all.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Server-side (the more general case):&lt;/em&gt; If &lt;code&gt;Math.random()&lt;/code&gt; generates tokens in a server process, an attacker who observes multiple consecutive tokens (e.g., via multiple signups, password resets, or API requests) can recover internal state and predict the next token — including tokens for other users. This is the scenario where state recovery attacks apply most cleanly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this pattern appears
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Top Stack Overflow pattern for "generate unique token":&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Session ID (with Date.now() — adds a predictable timestamp, not additional unpredictability):&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Invite code:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inviteToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// The calcom/cal.diy pattern:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`cal_live_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first three are less exploitable than the calcom/cal.diy pattern — partial substrings leak less state per observation. But all four use a PRNG for a value that should be a CSPRNG.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why it survives code review
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Math.random()&lt;/code&gt; literally has "random" in the name&lt;/li&gt;
&lt;li&gt;The output looks unpredictable: &lt;code&gt;k7f2m9p8x3z&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;No runtime errors — tokens generate correctly, tests pass&lt;/li&gt;
&lt;li&gt;Reviewers focus on the business logic, not PRNG security properties&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nobody reviews token generation and asks "is this the right &lt;em&gt;kind&lt;/em&gt; of random?" The ESLint rule asks it for you.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Source context:&lt;/strong&gt; calcom/cal.diy is the MIT open-source edition of Cal.com (the enterprise codebase runs separately under AGPL as &lt;code&gt;calcom/cal.com&lt;/code&gt;). The &lt;code&gt;Math.random()&lt;/code&gt; line is in a client-side React component. For client-side code, the architectural problem — generating a secret in the browser — is the primary concern. The PRNG weakness is secondary, but it compounds. The state-recovery attack in the Exploit section applies directly to server-side equivalents of this pattern.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The ESLint rule that catches it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;eslint-plugin-node-security/no-math-random-crypto&lt;/code&gt; fires when &lt;code&gt;Math.random()&lt;/code&gt; is assigned to a variable whose name matches any of 18+ security-sensitive patterns: &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;key&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;session&lt;/code&gt;, &lt;code&gt;auth&lt;/code&gt;, &lt;code&gt;csrf&lt;/code&gt;, &lt;code&gt;nonce&lt;/code&gt;, &lt;code&gt;otp&lt;/code&gt;, &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;verify&lt;/code&gt;, and more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On false positives:&lt;/strong&gt; React's list reconciliation &lt;code&gt;key&lt;/code&gt; prop doesn't trigger this — the rule checks &lt;code&gt;Math.random()&lt;/code&gt; assignments, not JSX attributes. &lt;code&gt;code&lt;/code&gt; for HTTP status codes or country codes won't trigger either, because the rule only fires when &lt;code&gt;Math.random()&lt;/code&gt; is the value being assigned. If you have legitimate non-security uses of a &lt;code&gt;key&lt;/code&gt; variable fed by &lt;code&gt;Math.random()&lt;/code&gt; (rare but possible), configure &lt;code&gt;allowInTests: true&lt;/code&gt; or use an ESLint disable comment with a note explaining why.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node-security/no-math-random-crypto
CWE-338 | Math.random() used in cryptographic context 'apiKey'
Use crypto.randomBytes() or crypto.randomUUID() instead
  apps/web/components/apps/make/Setup.tsx (line varies by version)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fires on the calcom/cal.diy line.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix — server-side vs client-side
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Server-side (Node.js API route, Express, NestJS):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Opaque token — hex string, 48 characters:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`cal_live_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// UUID format:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Client-side / browser (the deeper issue in the calcom/cal.diy case):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Generating secret API keys client-side is itself the architectural problem — the key is visible in client memory and potentially in browser devtools. The right fix is to move key generation server-side and return the key via an authenticated API call. If you must generate randomness in the browser, use Web Crypto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Web Crypto — available in all modern browsers and Node.js 19+:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;array&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;array&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;array&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;globalThis.crypto.getRandomValues()&lt;/code&gt; uses the OS CSPRNG and is safe in both browser and Node.js environments.&lt;/p&gt;




&lt;h2&gt;
  
  
  The config
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;nodeSecurity&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eslint-plugin-node-security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;tsParser&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@typescript-eslint/parser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.tsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.jsx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;languageOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tsParser&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node-security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nodeSecurity&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node-security/no-math-random-crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-node-security
npx eslint src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full rule documentation at &lt;a href="https://eslint.interlace.tools/docs/security/plugin-node-security/rules/no-math-random-crypto" rel="noopener noreferrer"&gt;eslint.interlace.tools&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Note: this rule catches the PRNG problem. It won't flag client-side key generation as an architectural issue — that's a separate concern for code review.&lt;/p&gt;

&lt;p&gt;If you're auditing older code for this class of vulnerability more broadly, the &lt;a href="https://dev.to/ofri-peretz/the-30-minute-security-audit-onboarding-a-new-codebase-4f91"&gt;30-minute security audit protocol&lt;/a&gt; covers Math.random() alongside credential handling, JWT configuration, and input validation in a single pass.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Have you run this against your codebase yet? I'm specifically curious where it shows up — expected places like token generation, or somewhere that surprised you.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://dev.to/ofri-peretz/series/exploit-analysis"&gt;Exploit Analysis&lt;/a&gt; series. See also:&lt;/em&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://dev.to/ofri-peretz/the-jwt-algorithm-none-attack-the-vulnerability-in-1-line-of-code-d9g"&gt;Exploit Analysis: The JWT Algorithm 'none' Attack (And the Guard)&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;📦 &lt;a href="https://www.npmjs.com/package/eslint-plugin-node-security" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-node-security&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://eslint.interlace.tools/docs/security/plugin-node-security/rules/no-math-random-crypto" rel="noopener noreferrer"&gt;Rule docs&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/eslint" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star on GitHub&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/ofri-peretz" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://x.com/ofriperetzdev" rel="noopener noreferrer"&gt;X&lt;/a&gt; | &lt;a href="https://linkedin.com/in/ofri-peretz" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/ofri-peretz"&gt;Dev.to&lt;/a&gt; | &lt;a href="https://ofriperetz.dev" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>node</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>Same NestJS Prompt. Claude Got 6 Security Errors. Gemini Got 2. Here's What Both Got Wrong.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sat, 30 May 2026 01:36:35 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/i-ran-the-same-nestjs-prompt-on-claude-and-gemini-one-got-6-security-errors-heres-what-both-1fnf</link>
      <guid>https://dev.to/ofri-peretz/i-ran-the-same-nestjs-prompt-on-claude-and-gemini-one-got-6-security-errors-heres-what-both-1fnf</guid>
      <description>&lt;p&gt;Same prompt, same plugin, same machine. Claude shipped 6 security errors. Gemini shipped 2. The four-error gap is entirely about &lt;em&gt;which&lt;/em&gt; security patterns each toolchain treats as part of "what a NestJS service is" — but the one error they &lt;em&gt;share&lt;/em&gt; is the one that gets you breached.&lt;/p&gt;

&lt;p&gt;Neither AI toolchain added rate limiting to the login endpoint. Without &lt;code&gt;@Throttle()&lt;/code&gt; or a &lt;code&gt;ThrottlerGuard&lt;/code&gt;, an attacker can enumerate passwords at full network speed against any deployment that doesn't have upstream rate limiting — and many don't, especially in early development, internal services, and misconfigured ingress paths.&lt;/p&gt;

&lt;p&gt;That's the shared finding. Everything else differed — by 4 errors. And the difference matters: if your team scaffolds with Anthropic's API, your default NestJS service starts with 6 security gaps from this plugin. If you use Google's Gemini CLI, you start with 2. The toolchain you pick changes the security posture you inherit before a human writes a line.&lt;/p&gt;

&lt;p&gt;This is the part most "AI writes good code now" takes skip: a model that compiles clean and a model that's &lt;em&gt;secure&lt;/em&gt; are different claims, and the gap between them is invisible until something runs static analysis over the output. So I did. I gave Claude Sonnet 4.6 and Gemini 2.5 Flash the identical prompt: &lt;em&gt;"Build a NestJS users service. Authentication, registration, login, profile endpoint, admin panel."&lt;/em&gt; Then I ran both outputs through &lt;code&gt;eslint-plugin-nestjs-security&lt;/code&gt; — the same plugin I built to catch exactly these patterns. (I've run this experiment across &lt;a href="https://ofriperetz.dev/articles/i-let-claude-write-60-functions-65-75-had-security-vulnerabilities" rel="noopener noreferrer"&gt;80 Claude-written functions&lt;/a&gt;, where 65–75% carried at least one vulnerability — this NestJS run is the same methodology, narrowed to one framework and two vendors.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Sonnet 4.6: 6 errors.&lt;/strong&gt; (Consistent with prior runs — see &lt;a href="https://dev.to/ofri-peretz/claude-wrote-a-nestjs-service-typescript-was-happy-eslint-found-6-security-holes-51nj"&gt;the companion article&lt;/a&gt;)&lt;br&gt;
&lt;strong&gt;Gemini 2.5 Flash via Gemini CLI: 2 errors.&lt;/strong&gt; The default output from Google's standard developer tooling shipped structurally more secure code than Claude's.&lt;/p&gt;

&lt;p&gt;Here's what each got right, what each got wrong, and why the one finding they share is the one that matters most.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Note: this compares each vendor's standard developer tooling (Anthropic API vs Gemini CLI), not isolated models under controlled conditions. The Gemini CLI ships its own system prompt; the raw API may produce different output. n=1 per toolchain. Run it yourself — see the closing question.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The prompt
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build a NestJS users service. Authentication, registration, login, profile endpoint, admin panel.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;No security requirements. No constraints. Just functionality. This is how most developers use AI code generation in practice.&lt;/p&gt;
&lt;h3&gt;
  
  
  Methodology (so you can reproduce it)
&lt;/h3&gt;

&lt;p&gt;This is the part most AI-vs-AI posts leave out. Here is exactly what produced the error counts below:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Pinned value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generator A&lt;/td&gt;
&lt;td&gt;Claude Sonnet 4.6 (Anthropic API, default settings, no system prompt)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generator B&lt;/td&gt;
&lt;td&gt;Gemini 2.5 Flash via Gemini CLI (CLI's own default system prompt)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linter&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;eslint-plugin-nestjs-security@1.2.3&lt;/code&gt; + &lt;code&gt;eslint-plugin-secure-coding@3.2.0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parser&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@typescript-eslint/parser&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config&lt;/td&gt;
&lt;td&gt;the exact block at the end of this article — six &lt;code&gt;nestjs-security&lt;/code&gt; rules at &lt;code&gt;error&lt;/code&gt;, &lt;code&gt;no-missing-validation-pipe&lt;/code&gt; with &lt;code&gt;assumeGlobalPipes: true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Command&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npx eslint src/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs&lt;/td&gt;
&lt;td&gt;n=1 per toolchain (see the honesty note below)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What I have &lt;strong&gt;not&lt;/strong&gt; pinned, and what you should record before treating any rerun as a controlled benchmark: the exact Gemini CLI build string, the dated model snapshots, and the Node/OS the original generation ran on. Those move the &lt;em&gt;absolute&lt;/em&gt; counts; they do not move the structural finding (the shared &lt;code&gt;require-throttler&lt;/code&gt; miss), which is why the load-bearing claim below rests on that, not on 6-vs-2. Pin those four and you have a fully controlled rerun — I'd take the issue.&lt;/p&gt;


&lt;h2&gt;
  
  
  What Claude Sonnet 4.6 generated
&lt;/h2&gt;

&lt;p&gt;Claude produced a structurally correct NestJS service with properly wired decorators and typed DTOs. It compiled clean. TypeScript was happy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;register&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CreateUserDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoginDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin/users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;listAllUsers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// returns the raw User entity — password + refreshToken included&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Req&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// same leak on the single-user path&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// { id, email, password, refreshToken, ... }&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The User entity Claude returned has no serialization guard on the secret fields — no &lt;code&gt;@Exclude()&lt;/code&gt;, no &lt;code&gt;ClassSerializerInterceptor&lt;/code&gt; — so every handler that returns it leaks them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// hashed, but still in every response body&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// long-lived credential, serialized as-is&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That entity, returned directly from &lt;code&gt;listAllUsers()&lt;/code&gt; and &lt;code&gt;profile()&lt;/code&gt;, is what trips &lt;code&gt;no-exposed-private-fields&lt;/code&gt; (CWE-200) — the secret fields cross the API boundary with nothing stripping them.&lt;/p&gt;

&lt;p&gt;ESLint found &lt;strong&gt;6 errors. 0 warnings. 3 seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The findings: no auth guards on any route, no rate limiting on login, &lt;code&gt;password&lt;/code&gt; and &lt;code&gt;refreshToken&lt;/code&gt; in every API response, no &lt;code&gt;ValidationPipe&lt;/code&gt;, bare &lt;code&gt;role: string&lt;/code&gt; with no &lt;code&gt;@IsEnum&lt;/code&gt;, and a debug endpoint returning &lt;code&gt;DATABASE_URL&lt;/code&gt; unauthenticated.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Gemini 2.5 Flash generated
&lt;/h2&gt;

&lt;p&gt;Gemini's output looked different from the first line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JwtAuthGuard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RolesGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// ← class-level guard, correctly applied&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Roles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UserRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ADMIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Roles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UserRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ADMIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemini applied &lt;code&gt;@UseGuards(JwtAuthGuard, RolesGuard)&lt;/code&gt; at the class level. It decorated the &lt;code&gt;password&lt;/code&gt; field with &lt;code&gt;@Exclude()&lt;/code&gt; from &lt;code&gt;class-transformer&lt;/code&gt;. It put &lt;code&gt;@IsEmail()&lt;/code&gt;, &lt;code&gt;@IsString()&lt;/code&gt;, &lt;code&gt;@MinLength(6)&lt;/code&gt;, and &lt;code&gt;@IsEnum(UserRole)&lt;/code&gt; on the DTO fields. It did not generate a debug endpoint.&lt;/p&gt;

&lt;p&gt;ESLint found &lt;strong&gt;2 errors.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both were on the auth controller — the register and login routes lacked &lt;code&gt;@Throttle()&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Claude&lt;/th&gt;
&lt;th&gt;Gemini&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;require-guards&lt;/code&gt; (CWE-284)&lt;/td&gt;
&lt;td&gt;❌ No guards anywhere&lt;/td&gt;
&lt;td&gt;✅ Class-level guards on UserController&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;no-exposed-private-fields&lt;/code&gt; (CWE-200)&lt;/td&gt;
&lt;td&gt;❌ &lt;code&gt;password&lt;/code&gt; in every response&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;@Exclude()&lt;/code&gt; on password + &lt;code&gt;ClassSerializerInterceptor&lt;/code&gt; registered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;require-throttler&lt;/code&gt; (CWE-770)&lt;/td&gt;
&lt;td&gt;❌ No throttling on login&lt;/td&gt;
&lt;td&gt;❌ No throttling on login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;no-missing-validation-pipe&lt;/code&gt; (CWE-20)&lt;/td&gt;
&lt;td&gt;❌ No ValidationPipe&lt;/td&gt;
&lt;td&gt;✅ Global &lt;code&gt;ValidationPipe&lt;/code&gt; in main.ts (config: &lt;code&gt;assumeGlobalPipes: true&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;require-class-validator&lt;/code&gt; (CWE-20)&lt;/td&gt;
&lt;td&gt;❌ &lt;code&gt;role: string&lt;/code&gt; with no &lt;code&gt;@IsEnum&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;@IsEmail()&lt;/code&gt;, &lt;code&gt;@IsString()&lt;/code&gt;, &lt;code&gt;@IsEnum(UserRole)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;no-exposed-debug-endpoints&lt;/code&gt; (CWE-489)&lt;/td&gt;
&lt;td&gt;❌ &lt;code&gt;DATABASE_URL&lt;/code&gt; in response&lt;/td&gt;
&lt;td&gt;✅ No debug endpoint generated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Why the gap
&lt;/h2&gt;

&lt;p&gt;Claude fulfilled the prompt precisely. "Build a users service" describes features. Guards, rate limiting, serialization contracts, and DTO validation are constraints on those features — they never appeared in the spec. Claude generated code that does exactly what it says it does.&lt;/p&gt;

&lt;p&gt;Gemini produced the same functional code but included structural security patterns Claude skipped. In this run: guards on the controller, &lt;code&gt;@Exclude()&lt;/code&gt; on sensitive fields, class-validator on every DTO field. Claude, across multiple documented runs: zero guards, no &lt;code&gt;@Exclude()&lt;/code&gt;, bare DTO fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The observable difference:&lt;/strong&gt; for a prompt that includes an admin panel, Gemini inferred that admin routes need authorization. Claude did not. We can observe the behavior; we can't see why from outside the model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this survives code review
&lt;/h3&gt;

&lt;p&gt;The uncomfortable part isn't that Claude wrote insecure code. It's how easily that code clears a human reviewer.&lt;/p&gt;

&lt;p&gt;Open the PR. The controller compiles. TypeScript is green. The DTOs are typed, the routes are named sensibly, the diff reads like a complete, competent users service. A reviewer scanning for what's &lt;em&gt;there&lt;/em&gt; finds nothing wrong — because the vulnerabilities are all absences. A missing &lt;code&gt;@UseGuards()&lt;/code&gt; is invisible: there's no red line, no failing test, no symbol to hover over. You can't review a decorator that was never written. The reviewer would have to hold the entire NestJS security checklist in their head and walk every route asking "what's &lt;em&gt;not&lt;/em&gt; here?" — and on a Friday-afternoon PR for a service that "works," nobody does.&lt;/p&gt;

&lt;p&gt;That's the failure mode static analysis is built for. A linter doesn't review what's present; it asserts what must exist. &lt;code&gt;require-guards&lt;/code&gt; doesn't care that the controller looks finished — it fails because a route handler has no guard, the same way every time, in 3 seconds, before the PR ever reaches a human. The negative-space check is exactly the check a tired reviewer can't reliably perform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gemini's structure created its own finding
&lt;/h3&gt;

&lt;p&gt;There's a twist worth noting before the shared finding, because it cuts against the "Gemini just wrote safer code" reading. Gemini generated an explicit &lt;code&gt;jwt.constants.ts&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jwtConstants&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;superSecretKey&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Replace with a strong, environment-variable-based secret in production&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude wrote inline configuration without an explicit secret. Gemini added a constants file — better architecture — and then hardcoded the secret into it. The comment acknowledges the risk; the code ships it anyway. &lt;code&gt;eslint-plugin-secure-coding/no-hardcoded-credentials&lt;/code&gt; (CWE-798) catches this. It's a different plugin from the one driving the main comparison, but the lesson is the point: Gemini's &lt;em&gt;more&lt;/em&gt; structured output surfaced a class of finding Claude avoided only by omission. "More secure by default" is the wrong frame — each toolchain's habits open and close different holes. That asymmetry is exactly why you run the lint over whichever one you used, instead of trusting a vendor reputation. Which brings us to the one hole neither closed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The finding both got wrong: rate limiting
&lt;/h2&gt;

&lt;p&gt;Neither model added &lt;code&gt;@Throttle()&lt;/code&gt; to the auth endpoints.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What both generated (auth controller):&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoginDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;ThrottlerGuard&lt;/code&gt;. No rate limit. An attacker can enumerate passwords at full network speed against the login endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why both models miss this:&lt;/strong&gt; rate limiting is a &lt;em&gt;rate-at-which&lt;/em&gt; constraint, not a &lt;em&gt;what-does-it-do&lt;/em&gt; constraint. "Build a login endpoint" describes a function. The spec says nothing about how fast it can be called. Neither model inferred the constraint. Neither will, unless you say so.&lt;/p&gt;

&lt;p&gt;The fix is identical regardless of model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// requires @nestjs/throttler@^5&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ThrottlerGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Throttle&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// 5 per minute&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoginDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But you shouldn't have to remember to look. That's the whole point — the gap is invisible to review, so the check has to be automatic. Install the plugin and run it over whatever your model just generated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-nestjs-security
npx eslint src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop in the config block at the end of this article — the six rules set to &lt;code&gt;error&lt;/code&gt; — and &lt;code&gt;require-throttler&lt;/code&gt; fails on the unguarded login route from &lt;em&gt;either&lt;/em&gt; model's output: Claude's and Gemini's both fail this rule identically. That's the config that produces the error counts above. If you've ever shipped an AI-scaffolded service, point it at that codebase before you read further. I'll wait.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this means
&lt;/h2&gt;

&lt;p&gt;Neither toolchain produces security-complete NestJS code from a feature-only prompt. They differ on &lt;em&gt;which&lt;/em&gt; security features they include by default.&lt;/p&gt;

&lt;p&gt;In this run, Gemini treated guards, validators, and serialization exclusion as part of "what a NestJS service is." Claude generated the same features without the security scaffolding — correct code, incomplete security posture.&lt;/p&gt;

&lt;p&gt;Both will add throttling, env-variable JWT secrets, and explicit guard wiring if you ask for them. The question is whether you know to ask — and whether you know what you're not asking for. And it doesn't stop at greenfield code: the same blind spots show up when AI tools &lt;em&gt;edit&lt;/em&gt; an existing service, where a fix for one finding quietly reintroduces another — the pattern I call &lt;a href="https://ofriperetz.dev/articles/the-ai-hydra-problem-fix-one-ai-bug-get-two-more" rel="noopener noreferrer"&gt;the AI hydra problem&lt;/a&gt;. The lint gate is what keeps that loop honest.&lt;/p&gt;

&lt;p&gt;The rate limiting gap is the finding that answers that question. Gemini passed &lt;code&gt;require-guards&lt;/code&gt;, &lt;code&gt;no-exposed-private-fields&lt;/code&gt;, &lt;code&gt;require-class-validator&lt;/code&gt;; Claude failed all three — but both failed &lt;code&gt;require-throttler&lt;/code&gt; the same way. That's not a tooling difference. That's a prompt difference. Neither spec said "prevent brute-force attacks on login." So neither output did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be precise about which claim is load-bearing.&lt;/strong&gt; The 6-vs-2 count is one generation per toolchain — directionally consistent with my &lt;a href="https://ofriperetz.dev/articles/i-let-claude-write-60-functions-65-75-had-security-vulnerabilities" rel="noopener noreferrer"&gt;80-function Claude run&lt;/a&gt; and the &lt;a href="https://ofriperetz.dev/articles/claude-wrote-nestjs-service-eslint-found-6-security-holes" rel="noopener noreferrer"&gt;single-model NestJS run&lt;/a&gt;, but still n=1 here, and I won't pretend a single sample settles a vendor ranking. The claim that &lt;em&gt;doesn't&lt;/em&gt; depend on sample size is the shared one: a feature-only prompt encodes no rate-at-which constraint, so &lt;code&gt;require-throttler&lt;/code&gt; fails on auth endpoints regardless of which model wrote them. That follows from how the prompt is shaped, not from how many times I ran it — which is why it's the part I'd stake the article on, and the 6-vs-2 the part I'm asking you to help replicate.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(For teams that rate-limit at the edge: app-layer &lt;code&gt;@Throttle()&lt;/code&gt; is defense-in-depth, not redundant. Internal callers, misconfigured ingress, and direct-to-pod paths bypass edge rules. The rule fires on the generated code — what you add upstream is a separate layer.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Static analysis asks the negative-space questions your prompt didn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  The config (runs on output from either model)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;nestjsSecurity&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eslint-plugin-nestjs-security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;secureCoding&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eslint-plugin-secure-coding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;tsParser&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@typescript-eslint/parser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/*.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;languageOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tsParser&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// Required to parse NestJS decorators&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nestjsSecurity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;secure-coding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;secureCoding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/require-guards&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/no-exposed-private-fields&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/require-throttler&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// Use assumeGlobalPipes: true if you register ValidationPipe in main.ts&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/no-missing-validation-pipe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;assumeGlobalPipes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/require-class-validator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/no-exposed-debug-endpoints&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;secure-coding/no-hardcoded-credentials&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-nestjs-security eslint-plugin-secure-coding
npx eslint src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full rule documentation at &lt;a href="https://eslint.interlace.tools/docs/security/plugin-nestjs-security" rel="noopener noreferrer"&gt;eslint.interlace.tools&lt;/a&gt;. If you want the per-rule walkthrough — what each of the six rules catches and why NestJS leaves the gap open in the first place — start with &lt;a href="https://ofriperetz.dev/articles/getting-started-eslint-plugin-nestjs-security" rel="noopener noreferrer"&gt;the rule-by-rule guide&lt;/a&gt;. For the single-model version of this run with the failing code shown in full, see &lt;a href="https://ofriperetz.dev/articles/claude-wrote-nestjs-service-eslint-found-6-security-holes" rel="noopener noreferrer"&gt;Claude Wrote a NestJS Service. ESLint Found 6 Security Holes&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Run the same prompt on whichever model you use, then run the lint over the output — two commands, three seconds. What's the worst thing an AI assistant scaffolded into your codebase that compiled clean, passed review, and only got caught later? Drop the rule it would have failed in the comments. I'm specifically curious whether Gemini's CLI result holds across runs — this is one data point and I want more.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://dev.to/ofri-peretz/series/ai-security-benchmark-series"&gt;AI Security Benchmark Series&lt;/a&gt;:&lt;/em&gt;&lt;br&gt;
&lt;em&gt;← &lt;a href="https://dev.to/ofri-peretz/claude-wrote-a-nestjs-service-typescript-was-happy-eslint-found-6-security-holes-51nj"&gt;Claude Wrote a NestJS Service. TypeScript Was Happy. ESLint Found 6 Security Holes.&lt;/a&gt; | &lt;a href="https://dev.to/ofri-peretz/aggregate-benchmarks-lie-heres-what-700-ai-functions-look-like-by-security-domain-1hgj"&gt;Aggregate Benchmarks Lie →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;📦 &lt;a href="https://www.npmjs.com/package/eslint-plugin-nestjs-security" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-nestjs-security&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://eslint.interlace.tools/docs/security/plugin-nestjs-security" rel="noopener noreferrer"&gt;Rule docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/eslint" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star on GitHub&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/ofri-peretz" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://x.com/ofriperetzdev" rel="noopener noreferrer"&gt;X&lt;/a&gt; | &lt;a href="https://linkedin.com/in/ofri-peretz" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/ofri-peretz"&gt;Dev.to&lt;/a&gt; | &lt;a href="https://ofriperetz.dev" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>node</category>
      <category>geminichallenge</category>
    </item>
    <item>
      <title>5 Cycles Invisible in 14,556 Files. The Cache Bug That Hid Them.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Fri, 29 May 2026 18:04:24 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/import-nextno-cycle-reported-0-cycles-on-nextjs-we-found-why-and-fixed-it-ln2</link>
      <guid>https://dev.to/ofri-peretz/import-nextno-cycle-reported-0-cycles-on-nextjs-we-found-why-and-fixed-it-ln2</guid>
      <description>&lt;p&gt;Run &lt;code&gt;no-cycle&lt;/code&gt; on your full monorepo, then run it again on a known-complex subdirectory. If the subset finds more cycles than the full run — you have the same class of bug we had.&lt;/p&gt;

&lt;p&gt;We found 5 import-graph cycles in 33 files that were invisible in 14,556 — next.js, 131K stars. The cause: a 10-hop depth limit that wrote false "non-cyclic" entries into a shared cache, poisoning later traversals. Large scope → more files processed before the subset → more false cache entries → more cycles hidden. Small scope → clean cache → same cycles visible.&lt;/p&gt;

&lt;p&gt;The cache bug is confirmed in source; the fix shipped in &lt;code&gt;eslint-plugin-import-next@2.3.6&lt;/code&gt;. This is the run-it-yourself half of a two-part story — the &lt;a href="https://ofriperetz.dev/articles/no-cycle-cache-poisoning-at-scale" rel="noopener noreferrer"&gt;full root-cause walkthrough is here&lt;/a&gt;. If you only read one section, read the diagnostic test: it works on any cycle detector, not just ours.&lt;/p&gt;

&lt;p&gt;One thing this is &lt;strong&gt;not&lt;/strong&gt;: a type-import disagreement. Our rule and &lt;code&gt;eslint-plugin-import/no-cycle&lt;/code&gt; use the &lt;em&gt;same&lt;/em&gt; edge policy — both exclude &lt;code&gt;import type&lt;/code&gt; edges, because type-only imports are erased at compile time and can't form a runtime cycle (&lt;code&gt;eslint-plugin-import&lt;/code&gt; v2.32.0 does it at &lt;code&gt;importer.importKind === 'type'&lt;/code&gt;, no-cycle.js line 93; we do it both in the AST visitor and in the SCC graph builder, so a type-only back-edge never even enters the graph). The two tools agreed on next.js: &lt;strong&gt;0 cycles each&lt;/strong&gt;. The gap that mattered was ours-vs-ours — the full run returned 0, the 33-file subset returned 5 — and that gap is the cache bug, end to end.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bug: a 10-hop depth limit that silenced 12-hop cycles — and poisoned the cache
&lt;/h2&gt;

&lt;p&gt;The original default in &lt;code&gt;import-next/no-cycle&lt;/code&gt; was &lt;code&gt;maxDepth: 10&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Next.js's &lt;code&gt;webpack-config.ts&lt;/code&gt; has an import cycle approximately 12 hops deep. With &lt;code&gt;maxDepth: 10&lt;/code&gt;, the DFS reaches hop 10, stops, marks those boundary files as explored, and exits without finding the cycle. The closing import — the one that would have revealed it — is never reached.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What happens at maxDepth: 10&lt;/span&gt;
&lt;span class="c1"&gt;// A → B → C → D → E → F → G → H → I → J → [depth exceeded — stop]&lt;/span&gt;
&lt;span class="c1"&gt;//                                                  K → L → A  ← never reached&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// Result: 0 cycles. The A→…→L→A cycle disappears.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure is invisible. The rule runs, reports no violations, exits 0. No warning that it stopped early. No indication that part of the graph wasn't examined.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two-part fix, in order of importance:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 1 (the real bug): only cache a node as "acyclic" when the DFS was complete.&lt;/strong&gt; The depth cap itself isn't wrong — it's a legitimate performance escape hatch. What's wrong is marking a depth-truncated node as unconditionally acyclic. From the source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only cache as non-cyclic when the DFS was complete AND found no cycles.&lt;/span&gt;
&lt;span class="c1"&gt;// A depth-truncated DFS cannot prove acyclicity (the cycle may exist past&lt;/span&gt;
&lt;span class="c1"&gt;// the depth limit), and caching it poisons every future lint pass that&lt;/span&gt;
&lt;span class="c1"&gt;// traverses the same subtree.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allCycles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;depthLimitHit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonCyclicFiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetFile&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means: if you use a finite &lt;code&gt;maxDepth&lt;/code&gt; after the fix, cycles deeper than your limit still won't be detected — but they also won't poison the cache for other traversals. The scope-dependent failure mode is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 2 (the default): raise &lt;code&gt;maxDepth&lt;/code&gt; to &lt;code&gt;Number.MAX_SAFE_INTEGER&lt;/code&gt;.&lt;/strong&gt; The 10-hop default silenced the 12-hop &lt;code&gt;webpack-config.ts&lt;/code&gt; cycle entirely. The new default matches &lt;code&gt;eslint-plugin-import&lt;/code&gt; v2.32.0 (&lt;code&gt;Infinity&lt;/code&gt;). oxlint v1.65.0 ships &lt;code&gt;import/no-cycle&lt;/code&gt; under &lt;code&gt;--import-plugin&lt;/code&gt; and traverses without an explicit depth cap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On stack safety:&lt;/strong&gt; The DFS is recursive. Recursion depth is bounded by the traversal path length — which on a dense 14K-file graph can reach hundreds of frames before hitting a leaf or cycle, well short of V8's ~10K-frame limit in practice. For very dense dependency graphs (generated code, large barrel-file trees), a finite &lt;code&gt;maxDepth&lt;/code&gt; is a legitimate performance and stack-safety guard. Fix 1 ensures that finite cap no longer poisons the cache — you get a depth-limited result without false negatives cascading to other traversals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the fix changes in &lt;code&gt;eslint-plugin-import-next@2.3.6&lt;/code&gt;:&lt;/strong&gt; The ~12-hop cycle in &lt;code&gt;webpack-config.ts&lt;/code&gt; is now caught. The 33-file router-reducer subset returns 5 cycles whether run in isolation or as part of the full 14,556-file repo. The gap that produced 0 on the full run is closed. Whether the fixed rule finds all 17 cycles oxlint reports is tracked via our &lt;a href="https://ofriperetz.dev/articles/what-ground-truth-caught-that-unit-tests-missed" rel="noopener noreferrer"&gt;ground-truth corpus&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;eslint-plugin-import&lt;/code&gt; reported 0 too — and why that's a different story.&lt;/strong&gt; &lt;code&gt;eslint-plugin-import/no-cycle&lt;/code&gt; already defaults to &lt;code&gt;maxDepth: Infinity&lt;/code&gt;, so it isn't subject to our depth bug. Its 0 on next.js is, as far as we can tell from the benchmark, a &lt;em&gt;correct-for-its-design&lt;/em&gt; result given the same compile-time-edge policy we use — both tools drop &lt;code&gt;import type&lt;/code&gt; edges before traversal. The number that exposed our bug wasn't theirs; it was oxlint's native Rust port reporting 17, which gave us an independent reference to measure against. The 0-vs-5 we had to explain was internal: same rule, same config, same files, different scope. That is the cache bug, not a tooling disagreement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On type-only imports — what the rule actually does.&lt;/strong&gt; &lt;code&gt;import-next/no-cycle&lt;/code&gt; does &lt;em&gt;not&lt;/em&gt; count &lt;code&gt;import type&lt;/code&gt; edges. The AST visitor returns early on &lt;code&gt;node.importKind === 'type'&lt;/code&gt;, and the dependency-graph builder skips type-only and dynamic edges entirely (&lt;code&gt;if (imp.dynamic || imp.typeOnly) continue;&lt;/code&gt;) — so a &lt;code&gt;import type { Foo }&lt;/code&gt; back-reference never even enters the SCC graph. That is deliberate: a type-only edge is erased by the compiler and cannot produce a runtime cycle, and including it would generate false positives. If your architectural review &lt;em&gt;wants&lt;/em&gt; to treat type-only back-references as cycles, that is a real position — but it is not what this rule (or &lt;code&gt;eslint-plugin-import&lt;/code&gt;) does today, and there is no &lt;code&gt;ignoreTypeImports&lt;/code&gt;-style toggle to flip it on. The cycles we report are runtime edges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it shipped with the wrong default:&lt;/strong&gt; Unit tests use small, controlled graphs — never 12 hops deep. CI stayed green. The benchmark against next.js was what surfaced it, and only because we had oxlint's count as a reference. Without an independent comparison, the silence would have looked like a clean result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this survived our own review — the uncomfortable part.&lt;/strong&gt; A depth cap of 10 &lt;em&gt;looks&lt;/em&gt; like the conservative choice. In code review, "bound the DFS so it can't blow the stack on a pathological graph" reads as defensive engineering, and a 10-hop default reads as generous — most real cycles are 2–4 hops. The reviewer's mental model was "the cap only ever skips cycles deeper than 10, and those are rare." What that model missed is the second-order effect: a depth-truncated node was &lt;em&gt;also&lt;/em&gt; being written to the acyclic cache, so the cap didn't just skip deep cycles — it taught the cache a lie that then suppressed shallow cycles elsewhere. Nobody waves through &lt;code&gt;add(file)&lt;/code&gt; as dangerous. It's one line, it's in the happy path, and it's the line that turned a local performance guard into a global correctness bug. If a senior engineer has ever approved a memoization write without asking "is this result actually &lt;em&gt;final&lt;/em&gt;?", they've approved this bug.&lt;/p&gt;

&lt;p&gt;If you're already running &lt;code&gt;import-next&lt;/code&gt;, the fix is an upgrade — no config change needed (the new default is &lt;code&gt;maxDepth: Number.MAX_SAFE_INTEGER&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-import-next@^2.3.6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full config block and the run-it-yourself diagnostic are below.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the subset found more than the full repo
&lt;/h2&gt;

&lt;p&gt;The counterintuitive part: the 33-file subset found 5 cycles that the 14,556-file run missed.&lt;/p&gt;

&lt;p&gt;The depth-limit bug explains it precisely. When the full-repo run processes files outside the 33-file subset first, it encounters some at hop 10 — the old depth limit. Without the cache fix, those files were incorrectly added to &lt;code&gt;nonCyclicFiles&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// OLD behavior (before fix): depth-truncated nodes marked as non-cyclic&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allCycles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonCyclicFiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetFile&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ← wrong — subtree wasn't fully explored&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some of those falsely-marked files are &lt;strong&gt;intermediate nodes&lt;/strong&gt; in cycles that pass through the 33-file subset. When the rule later encounters those cycles from files inside the subset, it hits line 975:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonCyclicFiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetFile&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// ← early exit because the intermediate was wrongly marked clean&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DFS exits. The cycle disappears.&lt;/p&gt;

&lt;p&gt;The 33-file subset run starts with a fresh &lt;code&gt;nonCyclicFiles&lt;/code&gt; cache — none of the full-repo's false entries are present. Every path is traversed in full. The cycles surface.&lt;/p&gt;

&lt;p&gt;Smaller scope → no false &lt;code&gt;nonCyclicFiles&lt;/code&gt; entries from other traversals → cycles found. That is the exact signature of the premature-memoization bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache lifetime:&lt;/strong&gt; &lt;code&gt;nonCyclicFiles&lt;/code&gt; is a module-level object shared across every file processed in a single &lt;code&gt;npx eslint&lt;/code&gt; invocation. It is not reset between files. File ordering follows ESLint's glob expansion (roughly lexical). Files earlier in the scan can poison entries for files processed later — reproducibly, not randomly.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this means for your own cycle detector
&lt;/h2&gt;

&lt;p&gt;The test that exposed our bug works on any implementation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;no-cycle&lt;/code&gt; on your full monorepo — note the count&lt;/li&gt;
&lt;li&gt;Pick a known-complex directory (dependency-heavy, many cross-imports) and run on just that subtree&lt;/li&gt;
&lt;li&gt;If the subset finds cycles the full run doesn't, you have a cache or depth interaction affecting the broader scope&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We found 5 in 33 files that were invisible in 14,556. The smaller scope, because it has a cleaner traversal state, shows you what the larger scope buried. Most teams never run this check because they assume "fewer files = fewer findings." For cycle detection with caching, the opposite can be true.&lt;/p&gt;

&lt;p&gt;To run this on &lt;code&gt;eslint-plugin-import-next@2.3.6&lt;/code&gt; (which has the fix):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-import-next@2.3.6

&lt;span class="c"&gt;# Full monorepo&lt;/span&gt;
npx eslint src/ &lt;span class="nt"&gt;--rule&lt;/span&gt; &lt;span class="s1"&gt;'{"import-next/no-cycle": "error"}'&lt;/span&gt;

&lt;span class="c"&gt;# Specific subdirectory (the diagnostic test)&lt;/span&gt;
npx eslint src/components/ &lt;span class="nt"&gt;--rule&lt;/span&gt; &lt;span class="s1"&gt;'{"import-next/no-cycle": "error"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs — using 'import-next' namespace to distinguish from eslint-plugin-import&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;importNext&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eslint-plugin-import-next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;import-next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;importNext&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;import-next/no-cycle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// maxDepth defaults to Number.MAX_SAFE_INTEGER — don't lower it&lt;/span&gt;
      &lt;span class="c1"&gt;// unless you understand the cache-poisoning tradeoff&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The pattern — and what it means for any cycle detector
&lt;/h2&gt;

&lt;p&gt;Unit tests on small graphs won't catch this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 6-file test cycle never exercises a 10-hop depth limit&lt;/li&gt;
&lt;li&gt;No unit test validates "subset finds ≥ full-repo count"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only thing that caught it: a large real-world repo measured against an independent reference tool, plus running the subset test that makes the paradox visible. If your cycle detector reports silence on a large monorepo, run it on a known-complex subdirectory. The silence might be correct. Or it might be a depth limit and a poisoned cache you didn't know you had.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the cycles actually are
&lt;/h2&gt;

&lt;p&gt;The 5 cycles in the router-reducer subset are &lt;strong&gt;runtime cycles&lt;/strong&gt; — every edge is a value import or a re-export (&lt;code&gt;export { X } from '...'&lt;/code&gt;), the kind that survives compilation. That matters because it's the reason this is a real architectural finding and not a type-graph curiosity: these are modules that genuinely depend on each other at runtime, and the only reason the full-repo run reported them as clean was the poisoned cache.&lt;/p&gt;

&lt;p&gt;Re-exports are the easy ones to miss. A cycle that closes through &lt;code&gt;export { foo } from './bar'&lt;/code&gt; is invisible to a detector that only hooks &lt;code&gt;import&lt;/code&gt; statements — so the graph builder treats &lt;code&gt;export … from&lt;/code&gt; as a first-class edge. The next.js &lt;code&gt;client/router.ts&lt;/code&gt; ↔ &lt;code&gt;client/with-router.tsx&lt;/code&gt; cycle propagates &lt;em&gt;entirely&lt;/em&gt; through re-exports; without that edge the DFS never sees it. (Type-only edges are the opposite case — deliberately excluded, as covered above.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Benchmark harness variance.&lt;/strong&gt; During post-fix validation, back-to-back runs produced 218→255→301. These are total violation rows (one per file-import pair), not distinct cycles. The variance was a harness bug: &lt;code&gt;pendingCycleReports&lt;/code&gt; accumulated across runs in our test tooling, not in production ESLint. Fixed by resetting state between runs. The lesson generalizes: when your &lt;em&gt;measurement&lt;/em&gt; tool has state, "the rule is flaky" and "my harness is flaky" look identical until you reset between runs and watch the number stop moving.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this bites hardest: AI-generated code
&lt;/h2&gt;

&lt;p&gt;The depth-and-cache bug needs two ingredients to hurt you: import graphs that are &lt;em&gt;deep&lt;/em&gt; (so the depth cap fires) and &lt;em&gt;dense&lt;/em&gt; (so falsely-cached intermediates sit on many paths). Hand-written code trends shallow — humans feel the pain of a 12-hop import chain and refactor it. AI-generated code does not.&lt;/p&gt;

&lt;p&gt;Ask Claude, Gemini, or Copilot to "add a barrel file," "re-export everything from this feature folder," or "wire these modules together," and you get exactly the shape that triggers this class of bug:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Barrel-file sprawl.&lt;/strong&gt; An &lt;code&gt;index.ts&lt;/code&gt; that re-exports a folder, imported by another &lt;code&gt;index.ts&lt;/code&gt; that re-exports &lt;em&gt;its&lt;/em&gt; folder, is how a 3-hop logical dependency becomes a 12-hop physical one. Assistants reach for barrels by default because they read as "clean public API."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-export round-trips.&lt;/strong&gt; Generated code frequently has module A re-export a symbol that ultimately re-imports from A — the runtime cycle that closes through &lt;code&gt;export … from&lt;/code&gt;, the exact edge naive detectors miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confident silence.&lt;/strong&gt; When you ask an assistant "are there circular dependencies here?", it will reason over the snippet in front of it — it cannot see the 14K-file graph, and it has no depth cap to warn you about. A green &lt;code&gt;no-cycle&lt;/code&gt; run becomes the evidence the assistant's "looks clean to me" was right, when both were blind to the same deep cycle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same thesis as the rest of this series: AI assistants don't invent new failure modes, they &lt;em&gt;industrialize old ones&lt;/em&gt; by generating the structures (deep barrels, re-export chains) that the tooling's blind spots were waiting for. The fix is the same regardless of who wrote the code — run &lt;code&gt;import-next/no-cycle&lt;/code&gt; (with the depth/cache fix) in CI, and run the subset diagnostic on any folder an assistant just scaffolded. If the subset finds cycles the full run doesn't, you have the bug this article is about. For the broader pattern — fix one AI-generated bug, surface two more — see &lt;a href="https://ofriperetz.dev/articles/the-ai-hydra-problem-fix-one-ai-bug-get-two-more" rel="noopener noreferrer"&gt;The AI Hydra Problem&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's the bug that turned out to be a cache lying to you — where the tool wasn't wrong, it was confidently remembering something it never actually verified? And did you find it by accident, or did an independent number force you to look? Drop the story below — the "we trusted the consensus" ones are the best.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://dev.to/ofri-peretz/series/inside-our-linter-benchmarks"&gt;Inside our linter benchmarks&lt;/a&gt; series:&lt;/em&gt;&lt;br&gt;
&lt;em&gt;← &lt;a href="https://ofriperetz.dev/articles/no-cycle-cache-poisoning-at-scale" rel="noopener noreferrer"&gt;Our cycle detector reported 0. The real number was 245 files.&lt;/a&gt; | &lt;a href="https://ofriperetz.dev/articles/what-ground-truth-caught-that-unit-tests-missed" rel="noopener noreferrer"&gt;What Ground Truth Caught That Unit Tests Missed →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;📦 &lt;a href="https://www.npmjs.com/package/eslint-plugin-import-next" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-import-next&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://eslint.interlace.tools/docs/imports/plugin-import-next" rel="noopener noreferrer"&gt;Rule docs&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/eslint" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star on GitHub&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/ofri-peretz" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://x.com/ofriperetzdev" rel="noopener noreferrer"&gt;X&lt;/a&gt; | &lt;a href="https://linkedin.com/in/ofri-peretz" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/ofri-peretz"&gt;Dev.to&lt;/a&gt; | &lt;a href="https://ofriperetz.dev" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>eslint</category>
      <category>node</category>
      <category>ai</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Our no-cycle Rule Reported 0 Cycles on Next.js. oxlint Found 17. Here's the Bug.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Fri, 29 May 2026 17:51:30 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/eslint-plugin-import-has-38m-weekly-downloads-heres-what-it-still-gets-wrong-c94</link>
      <guid>https://dev.to/ofri-peretz/eslint-plugin-import-has-38m-weekly-downloads-heres-what-it-still-gets-wrong-c94</guid>
      <description>&lt;p&gt;We ran &lt;code&gt;import-next/no-cycle&lt;/code&gt; against &lt;code&gt;eslint-plugin-import/no-cycle&lt;/code&gt; and oxlint on next.js — 131K stars, 14,556 source files. Both ESLint plugins agreed: &lt;strong&gt;0 cycles&lt;/strong&gt;. oxlint disagreed: &lt;strong&gt;17 cycles&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We trusted the consensus. Then we scoped our rule to a small subset of the same repo and ran it again. It started reporting cycles — on files a whole-repo run had called clean.&lt;/p&gt;

&lt;p&gt;Same config. Same files. Different scope. Different answer.&lt;/p&gt;

&lt;p&gt;That's not a fluke — it's a symptom. We audited the rule and found two bugs, &lt;strong&gt;both now fixed and shipped&lt;/strong&gt;. This post is the post-mortem on those two fixes. (The full 0-vs-17 reconciliation against oxlint is a separate, still-open piece of work; I'll be precise below about which part is shipped and which part is still measurement.)&lt;/p&gt;

&lt;p&gt;This matters more now than it did two years ago. Circular imports used to creep in slowly, one careless &lt;code&gt;index.ts&lt;/code&gt; re-export at a time. Now an AI assistant generates a barrel file in seconds — and the rule that's supposed to catch the cycle is the one quietly lying to you. If your cycle detector is wrong, the tool meant to be a backstop against AI-generated import graphs is the weakest link in the chain. (More on that below.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 1: A depth limit of 10 that silently swallowed deep cycles
&lt;/h2&gt;

&lt;p&gt;The original default in &lt;code&gt;import-next/no-cycle&lt;/code&gt; was &lt;code&gt;maxDepth: 10&lt;/code&gt;. Reasonable assumption: most import chains are shallow. Real codebases disagree.&lt;/p&gt;

&lt;p&gt;Next.js has import chains that close into a cycle deeper than 10 hops (the one we traced through &lt;code&gt;webpack-config.ts&lt;/code&gt; was around a dozen hops — I'm giving you the order of magnitude, not a benchmarked constant). With &lt;code&gt;maxDepth: 10&lt;/code&gt;, the DFS stops at hop 10, marks those files as explored, and reports no cycle. The traversal never reaches the closing edge that would have revealed it.&lt;/p&gt;

&lt;p&gt;The failure mode is invisible. The rule runs, finds no violations, exits 0. No error. No warning. No indication that it stopped early and left part of the graph unexamined.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Old behavior (maxDepth: 10)&lt;/span&gt;
&lt;span class="c1"&gt;// File A → B → C → D → E → F → G → H → I → J → [STOP — depth exceeded]&lt;/span&gt;
&lt;span class="c1"&gt;//                                                       ↑&lt;/span&gt;
&lt;span class="c1"&gt;//                                               K → L → A  ← never reached&lt;/span&gt;
&lt;span class="c1"&gt;// Result: 0 cycles reported. The A→…→L→A cycle doesn't exist as far as the rule knows.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Make the default effectively unlimited, matching &lt;code&gt;eslint-plugin-import&lt;/code&gt;'s default of &lt;code&gt;Infinity&lt;/code&gt; and oxlint's &lt;code&gt;u32::MAX&lt;/code&gt;. The rule now traverses the full graph unless you explicitly set a lower limit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In import-next/no-cycle, after the fix.&lt;/span&gt;
&lt;span class="c1"&gt;// defaultOptions uses Infinity; the JSON-schema default is&lt;/span&gt;
&lt;span class="c1"&gt;// Number.MAX_SAFE_INTEGER (a finite stand-in ESLint's schema validation&lt;/span&gt;
&lt;span class="c1"&gt;// accepts) — both mean "don't cap traversal".&lt;/span&gt;
&lt;span class="nx"&gt;maxDepth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="c1"&gt;// schema default — quoted verbatim from the rule source:&lt;/span&gt;
&lt;span class="c1"&gt;// "Lower values are a performance escape hatch — but with our nonCyclicFiles&lt;/span&gt;
&lt;span class="c1"&gt;// cache, traversal cost is amortized, and a low cap silently misses cycles&lt;/span&gt;
&lt;span class="c1"&gt;// deeper than the limit. Set to a finite number only on huge graphs where&lt;/span&gt;
&lt;span class="c1"&gt;// the bench latency hurts you."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it survived for months:&lt;/strong&gt; The rule shipped with green tests. Unit tests use small, controlled graphs — never 12 hops deep. CI passed. The benchmark against next.js was what surfaced it, and only because we had oxlint's output as a reference. Without a ground-truth comparison, the silence would have looked like a passing grade.&lt;/p&gt;

&lt;p&gt;If you're running any &lt;code&gt;no-cycle&lt;/code&gt; rule today, the first thing worth checking is your effective &lt;code&gt;maxDepth&lt;/code&gt;. Here's the config that traverses the full graph instead of stopping early:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; eslint-plugin-import-next
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.js (flat config)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;importNext&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint-plugin-import-next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;import-next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;importNext&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Default is now Number.MAX_SAFE_INTEGER. Pin it explicitly so a future&lt;/span&gt;
      &lt;span class="c1"&gt;// default change can't silently cap traversal on your monorepo.&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;import-next/no-cycle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxDepth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MAX_SAFE_INTEGER&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on &lt;code&gt;eslint-plugin-import&lt;/code&gt;, the equivalent is &lt;code&gt;"import/no-cycle": ["error", { maxDepth: Infinity }]&lt;/code&gt; — its default already is &lt;code&gt;Infinity&lt;/code&gt;, so Bug 1 doesn't apply to it (more on that below). Either way, do not ship a finite &lt;code&gt;maxDepth&lt;/code&gt; you can't justify against your deepest real import chain.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 2: Cache contamination that made results non-deterministic
&lt;/h2&gt;

&lt;p&gt;The second bug was subtler and harder to reproduce: back-to-back runs of the same rule on the same files returned different cycle counts. The exact counts drifted run to run — same config, same files, same machine, different answer each time. (I'm describing the shape of the failure, not citing a committed benchmark number — the determinism work predates the result files I'd be willing to point you at, so treat the drift as the symptom, and the source diff below as the evidence.)&lt;/p&gt;

&lt;p&gt;The discrepancy traced to the &lt;code&gt;nonCyclicFiles&lt;/code&gt; cache — a shared set that records files already confirmed acyclic, allowing O(1) rejection on repeat visits. The intent is performance, and it's the whole reason this rule is fast: in our committed no-cycle benchmark it runs &lt;strong&gt;25.7x faster than &lt;code&gt;eslint-plugin-import&lt;/code&gt; at 1,000 files and 54.9x faster at 5,000&lt;/strong&gt; (Node v20.19.5, M1; &lt;code&gt;results/import-no-cycle/2026-01-02.json&lt;/code&gt; in the benchmark repo). Once a file's import graph is known clean, don't re-traverse it. That speed is exactly what made the fix delicate — the naive determinism patch throws it away.&lt;/p&gt;

&lt;p&gt;The original implementation populated that set from a depth-first traversal as it ran. The problem: a DFS from one file could mark a file non-cyclic &lt;em&gt;before&lt;/em&gt; another traversal had finished exploring the edges that closed a cycle through it. When a later visit checked the cache, it got a hit on a file it would otherwise have walked — and skipped it. If that skipped file sat on a cycle path, the cycle disappeared from the report. Because the order files get walked in isn't fixed, the set of cycles you "kept" shifted from run to run.&lt;/p&gt;

&lt;p&gt;Non-deterministic lint output on the same unchanged codebase is a trust-destroying result for a tool whose purpose is CI enforcement.&lt;/p&gt;

&lt;p&gt;The wrong fix — the one I tried first, and the one you'd reach for instinctively — is to clear the cache per file so a stale entry can't leak across traversals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ What I tried first — and why it's wrong.&lt;/span&gt;
&lt;span class="c1"&gt;// Clearing nonCyclicFiles per file does make output deterministic,&lt;/span&gt;
&lt;span class="c1"&gt;// but it destroys the O(1) fast path: every file re-walks the graph&lt;/span&gt;
&lt;span class="c1"&gt;// from scratch, and on a 14K-file repo that's the whole reason the&lt;/span&gt;
&lt;span class="c1"&gt;// rule was fast in the first place. Determinism bought with an O(n²)&lt;/span&gt;
&lt;span class="c1"&gt;// regression isn't a fix — it's a different bug.&lt;/span&gt;
&lt;span class="nx"&gt;sharedCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonCyclicFiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual fix makes the cache &lt;strong&gt;correct-by-construction&lt;/strong&gt; instead of resetting it. We stopped populating &lt;code&gt;nonCyclicFiles&lt;/code&gt; from an in-progress DFS and started populating it from Tarjan's strongly-connected-components result, which is computed once per connected component and is authoritative: a file in a singleton SCC is &lt;em&gt;provably&lt;/em&gt; non-cyclic, so it can be cached for the whole run; a file in a multi-file SCC is on a cycle, so it's removed from the set if an earlier incomplete pass ever added it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint-devkit/src/resolver/dependency-analysis.ts — computeSCCsFromFile()&lt;/span&gt;
&lt;span class="c1"&gt;// Populate nonCyclicFiles from the authoritative SCC, not a partial DFS:&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;newResults&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasCycle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Multi-file SCC → these files ARE on a cycle. Undo any earlier&lt;/span&gt;
    &lt;span class="c1"&gt;// incomplete-DFS guess that wrongly marked them clean.&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonCyclicFiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Singleton SCC → provably non-cyclic → safe to cache for the run.&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nonCyclicFiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// no-cycle.ts — per file, we deliberately do NOT clear nonCyclicFiles:&lt;/span&gt;
&lt;span class="c1"&gt;// it's now correct-by-construction from the SCC, so clearing it would&lt;/span&gt;
&lt;span class="c1"&gt;// only throw away the O(1) fast path. Only the per-file report-dedup&lt;/span&gt;
&lt;span class="c1"&gt;// state resets.&lt;/span&gt;
&lt;span class="nx"&gt;sharedCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pendingCycleReports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// sharedCache.nonCyclicFiles, sccs, sccIndex — intentionally NOT reset.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The determinism comes from the source of truth, not from a reset: SCC membership doesn't depend on file-walk order, so two runs over the same files now agree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters for ground-truth benchmarking:&lt;/strong&gt; a tool that reports different cycle counts on the same codebase across runs can't serve as a benchmark reference — it can't tell you whether a discrepancy against oxlint is a real false negative or just scheduling noise. Making the output deterministic was a prerequisite for trusting any correctness comparison at all. You can read the determinism story in more depth in &lt;a href="https://ofriperetz.dev/articles/no-cycle-cache-poisoning-at-scale" rel="noopener noreferrer"&gt;no-cycle: cache poisoning at scale&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the two fixes change, and what's still open
&lt;/h2&gt;

&lt;p&gt;After both fixes — unlimited depth, deterministic traversal — &lt;code&gt;import-next/no-cycle&lt;/code&gt; now traverses the deep chain in next.js that the old &lt;code&gt;maxDepth: 10&lt;/code&gt; silently truncated, and it returns the same answer on every run instead of a different count each time. The scoped-subset run that first tipped us off now reproduces cleanly: the cycles it surfaces are stable, and they're the ones the depth limit had been hiding.&lt;/p&gt;

&lt;p&gt;That's the part I'm willing to put my name on as &lt;strong&gt;done and shipped&lt;/strong&gt;. Here's the part that is honestly &lt;em&gt;not&lt;/em&gt; done: I can't yet tell you the rule returns exactly &lt;strong&gt;17&lt;/strong&gt; on whole-repo next.js and matches oxlint cycle-for-cycle. Establishing that requires a ground-truth corpus — a set of cycles verified by hand, independent of any single tool's output — so that a disagreement can be adjudicated as a real false negative rather than a difference in how two tools define a "cycle" (re-export edges, type-only imports, and dynamic imports are all places two correct implementations can legitimately disagree). That corpus work is tracked as part of the &lt;a href="https://dev.to/ofri-peretz/what-ground-truth-caught-that-unit-tests-missed-3-real-bugs-in-9-flagship-lint-rules-o0b"&gt;ILB flagship benchmark&lt;/a&gt; (&lt;a href="https://ofriperetz.dev/articles/what-ground-truth-caught-that-unit-tests-missed" rel="noopener noreferrer"&gt;blog mirror&lt;/a&gt;). I'd rather ship the two fixes I can defend than claim a reconciliation I haven't earned.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this rule matters more in the AI era
&lt;/h2&gt;

&lt;p&gt;Here's the part that turns this from a niche linter bug into something you should care about today.&lt;/p&gt;

&lt;p&gt;Circular imports are exactly the kind of defect an AI assistant introduces without noticing. Ask Copilot or Claude to "add a helper that formats the user object," and a very common move is to drop it into the nearest barrel file — &lt;code&gt;utils/index.ts&lt;/code&gt; — which re-exports a module that, three hops away, already imports &lt;code&gt;utils&lt;/code&gt;. The model is optimizing for "this line type-checks and reads naturally in isolation." It has no global view of the import graph. It cannot see the cycle it just closed, because the cycle only exists across files it never had in context at once.&lt;/p&gt;

&lt;p&gt;I've watched this happen repeatedly while reviewing AI-generated code: each individual edit is locally reasonable, and the cycle emerges from the &lt;em&gt;combination&lt;/em&gt;. It's the same dynamic I wrote about in &lt;a href="https://ofriperetz.dev/articles/the-ai-hydra-problem-fix-one-ai-bug-get-two-more" rel="noopener noreferrer"&gt;the AI hydra problem&lt;/a&gt; — you fix one structural issue, the assistant's next suggestion quietly reintroduces it somewhere adjacent. Import cycles are structural, invisible per-edit, and accumulate fastest precisely when code is being generated fast.&lt;/p&gt;

&lt;p&gt;That makes the &lt;code&gt;no-cycle&lt;/code&gt; rule a backstop for AI-assisted development — and a backstop that returns &lt;code&gt;0&lt;/code&gt; when the real answer is &lt;code&gt;17&lt;/code&gt; is worse than no backstop at all. It doesn't just miss the cycle; it actively tells the reviewer the import graph is clean. A human waving through an AI-authored PR now has a green check confirming a property that isn't true.&lt;/p&gt;

&lt;p&gt;So the practical test, if you're letting an assistant write code into a real monorepo: scope the rule to the directory the AI just touched and run it in isolation. Don't trust a whole-repo &lt;code&gt;0&lt;/code&gt;. The scoped subset that tipped us off is the same trick — a small, focused scope exercises traversal paths a whole-repo run can silently skip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Want to take this further than I have?&lt;/strong&gt; The natural next experiment is to make the AI the independent variable: ask Gemini, Claude, and Copilot each to add a helper into a barrel file in the same fixture repo, then run the scope-isolation test above and count which models close a cycle and how deep it goes. That turns this post's qualitative observation into a reproducible model-vs-model benchmark — and with a Gemini model as one of the arms plus this correctness framing, it slots straight into the &lt;a href="https://dev.to/challenges"&gt;Build with Gemini&lt;/a&gt; challenge under &lt;code&gt;#googleai #geminichallenge&lt;/code&gt;. I'm flagging the adaptation rather than claiming it: this article is the bug post-mortem; the model leaderboard is a separate piece I haven't run yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this means for &lt;code&gt;eslint-plugin-import&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;eslint-plugin-import/no-cycle&lt;/code&gt; defaults to &lt;code&gt;maxDepth: Infinity&lt;/code&gt; — so Bug 1 (the 10-hop limit) doesn't apply. Their rule already traverses the full graph. Whatever produced &lt;em&gt;their&lt;/em&gt; 0-cycle result on next.js, it isn't the depth limit we fixed in ours.&lt;/p&gt;

&lt;p&gt;I want to be careful here: I have not audited &lt;code&gt;eslint-plugin-import&lt;/code&gt;'s internals, so I'm making &lt;strong&gt;no claim&lt;/strong&gt; about why it returns 0 — not that it shares Bug 2, not that it's wrong in the same way, nothing. Diagnosing another maintainer's rule from the outside, on the strength of one disagreeing tool, is exactly the kind of unevidenced claim this whole post is arguing against. The only honest statement I can make about it is the one below.&lt;/p&gt;

&lt;p&gt;What I can say: two ESLint implementations, same config, same files, both disagreeing with oxlint's 17. At least one of the three is wrong on at least some cycles. I know precisely which two bugs I fixed in mine, and I've shown you the source for both — that's the standard the rest of the comparison has to meet before I'll publish a verdict on it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reproduce it yourself
&lt;/h2&gt;

&lt;p&gt;I'd rather hand you the machinery than ask you to trust a screenshot. Two things are reproducible today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. The speed delta that motivated the rewrite (committed result + runner).&lt;/span&gt;
git clone https://github.com/ofri-peretz/eslint-benchmark-suite.git
&lt;span class="nb"&gt;cd &lt;/span&gt;eslint-benchmark-suite &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run benchmark:import          &lt;span class="c"&gt;# no-cycle config: benchmarks/import/configs/import-next-no-cycle.config.js&lt;/span&gt;
&lt;span class="c"&gt;# Committed run (Node v20.19.5, M1): 25.7x at 1K files, 54.9x at 5K — results/import-no-cycle/&lt;/span&gt;

&lt;span class="c"&gt;# 2. The depth bug, on any repo with a deep import chain:&lt;/span&gt;
npx eslint &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--rule&lt;/span&gt; &lt;span class="s1"&gt;'{"import-next/no-cycle":["error",{"maxDepth":10}]}'&lt;/span&gt;   &lt;span class="c"&gt;# old default — may report 0&lt;/span&gt;
npx eslint &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--rule&lt;/span&gt; &lt;span class="s1"&gt;'{"import-next/no-cycle":["error",{"maxDepth":1e15}]}'&lt;/span&gt; &lt;span class="c"&gt;# full traversal — finds the deep cycle&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I am &lt;strong&gt;not&lt;/strong&gt; handing you yet is a committed &lt;code&gt;0-vs-17&lt;/code&gt; corpus result — that file doesn't exist, because the ground-truth corpus that would make it authoritative isn't built. I'm being explicit about that gap on purpose: the speed numbers and the depth behavior are reproducible from committed artifacts; the cross-tool cycle count is an honest open item, not a published claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson from both bugs
&lt;/h2&gt;

&lt;p&gt;Unit tests with small graphs miss both of these. A 6-file test graph never builds a chain deep enough to hit a depth limit, and it never builds an import graph big enough for a partial DFS to cache a wrong answer before the cycle-closing edge is walked.&lt;/p&gt;

&lt;p&gt;The only thing that caught them was a large, real-world repo compared against an independent reference tool. That's the ground-truth methodology — not test coverage on controlled inputs, but measurement against real codebases where the correct answer is known independently. It's the same lesson from a different angle in &lt;a href="https://ofriperetz.dev/articles/what-ground-truth-caught-that-unit-tests-missed" rel="noopener noreferrer"&gt;what ground truth caught that unit tests missed&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your lint rule reports silence on a 10K-file monorepo, it's worth asking whether it's doing the same work it does on 100 files. Sometimes the silence is correct. Sometimes it's a depth limit you didn't know you hit.&lt;/p&gt;

&lt;p&gt;For the determinism half of this story in more depth — why a performance optimization made the rule lie, and the full cache-poisoning failure mode — see &lt;a href="https://ofriperetz.dev/articles/no-cycle-cache-poisoning-at-scale" rel="noopener noreferrer"&gt;no-cycle: cache poisoning at scale&lt;/a&gt;. If you're weighing &lt;code&gt;import-next&lt;/code&gt; against the original on a large repo, &lt;a href="https://ofriperetz.dev/articles/eslint-plugin-import-vs-eslint-plugin-import-next-up-to-100x-faster" rel="noopener noreferrer"&gt;the speed comparison is here&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's the worst false &lt;code&gt;0&lt;/code&gt; a tool has ever handed you — a linter, a test suite, a coverage report that said everything was fine on code you later found was broken? And if you're shipping AI-generated code right now: have you actually scoped your structural rules to what the assistant touched, or are you trusting a whole-repo green check? Tell me below — I collect these.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://dev.to/ofri-peretz/series/inside-our-linter-benchmarks"&gt;Inside our linter benchmarks&lt;/a&gt; series:&lt;/em&gt;&lt;br&gt;
&lt;em&gt;← &lt;a href="https://dev.to/ofri-peretz/what-ground-truth-caught-that-unit-tests-missed-3-real-bugs-in-9-flagship-lint-rules-o0b"&gt;What Ground Truth Caught That Unit Tests Missed&lt;/a&gt; | &lt;a href="https://dev.to/ofri-peretz/no-cycle-finds-0-cycles-in-nextjs-and-other-lies-caches-tell-you-3ld8"&gt;no-cycle Finds 0 Cycles in Next.js (And Other Lies Caches Tell You) →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;📦 &lt;a href="https://www.npmjs.com/package/eslint-plugin-import-next" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-import-next&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://eslint.interlace.tools/docs/imports/plugin-import-next" rel="noopener noreferrer"&gt;Rule docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/eslint" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star on GitHub&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/ofri-peretz" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://x.com/ofriperetzdev" rel="noopener noreferrer"&gt;X&lt;/a&gt; | &lt;a href="https://linkedin.com/in/ofri-peretz" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/ofri-peretz"&gt;Dev.to&lt;/a&gt; | &lt;a href="https://ofriperetz.dev" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>eslint</category>
      <category>node</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>Claude Wrote a NestJS Service. TypeScript Was Happy. ESLint Found 6 Security Holes.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Fri, 29 May 2026 04:52:38 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/claude-wrote-a-nestjs-service-typescript-was-happy-eslint-found-6-security-holes-51nj</link>
      <guid>https://dev.to/ofri-peretz/claude-wrote-a-nestjs-service-typescript-was-happy-eslint-found-6-security-holes-51nj</guid>
      <description>&lt;p&gt;TypeScript passed it clean. The code ran. I would have approved it in review. Then I ran the linter.&lt;/p&gt;

&lt;p&gt;I gave Claude Sonnet 4.6 a single prompt: &lt;em&gt;"Build a NestJS users service. Authentication, registration, login, profile endpoint, admin panel."&lt;/em&gt; 90 seconds later I had 200 lines of NestJS. Decorators in the right places, DTOs typed correctly, dependency injection wired. It looked like code written by a developer who knew NestJS.&lt;/p&gt;

&lt;p&gt;I ran &lt;code&gt;eslint-plugin-nestjs-security&lt;/code&gt; — a plugin I built to catch exactly these patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6 errors. 0 warnings. 3 seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In every AI-generated NestJS service I've personally scanned, the response body ships &lt;code&gt;password&lt;/code&gt;. This run was no different — it also shipped an admin endpoint with no auth guard, a login route with no rate limit, and a debug endpoint returning &lt;code&gt;DATABASE_URL&lt;/code&gt;. Those are the six findings below.&lt;/p&gt;

&lt;p&gt;This isn't a one-off. In a &lt;a href="https://dev.to/ofri-peretz/aggregate-benchmarks-lie-heres-what-700-ai-functions-look-like-by-security-domain-1hgj"&gt;700-function benchmark across 5 AI models&lt;/a&gt;, Claude's vulnerability rate was 65–75%. The specific count in your run will vary — LLM output is non-deterministic — but the failure &lt;em&gt;classes&lt;/em&gt; are consistent. The missing-guard pattern does not disappear on a retry.&lt;/p&gt;

&lt;p&gt;If you want to run this against your own AI-generated controllers before reading further, it's one install — full config is below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-nestjs-security
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What Claude generated
&lt;/h2&gt;

&lt;p&gt;The prompt was intentionally minimal. No security requirements — just functionality. This is how most developers prompt AI assistants: describe what the code should &lt;em&gt;do&lt;/em&gt;, not what it should &lt;em&gt;prevent&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UsersService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;register&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CreateUserDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoginDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile/:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getProfile&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin/users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;listAllUsers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude also generated the entity and DTOs referenced below — all from the same single prompt.&lt;/p&gt;

&lt;p&gt;TypeScript: ✅ Clean.&lt;br&gt;
Runtime: ✅ Would work.&lt;br&gt;
ESLint: ❌ 6 errors.&lt;/p&gt;

&lt;p&gt;Each finding follows the same structure: what ESLint caught, why AI generates this pattern, and why it survives code review. The second question is the one worth sitting with.&lt;/p&gt;


&lt;h2&gt;
  
  
  Finding 1: No auth guards (CWE-284)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://eslint.interlace.tools/docs/security/plugin-nestjs-security/rules/require-guards" rel="noopener noreferrer"&gt;&lt;code&gt;nestjs-security/require-guards&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nestjs-security/require-guards
Controller 'UsersController' lacks @UseGuards for access control
  /src/users/users.controller.ts:2:1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GET /users/admin/users&lt;/code&gt; returns every user in the database. No authentication required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why AI generates this:&lt;/strong&gt; Authorization is a &lt;em&gt;constraint&lt;/em&gt;, not a feature. AI models optimize for completing described behavior, not for restrictions the prompt didn't mention. "List all users" is a valid feature. "Only admins can list users" is a negation of default behavior that requires explicit intent. Claude Sonnet 4.6 fulfilled exactly what it was asked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it survives review:&lt;/strong&gt; Reviewers know the team has &lt;code&gt;JwtAuthGuard&lt;/code&gt; registered — or think they do. The guard is off the mental stack when reading route logic. Nobody scans a controller and asks "is there a guard here?" They ask "does the logic look right?" So would anyone on your team reviewing typed DTOs returning from a named service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The rule fires at class scope (2:1) but is satisfied by @UseGuards at either&lt;/span&gt;
&lt;span class="c1"&gt;// class or method level. Method-level is correct here — this controller also&lt;/span&gt;
&lt;span class="c1"&gt;// handles unauthenticated routes (login, register). Class-level would 401 them.&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UsersController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// intentionally unauthenticated&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoginDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin/users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JwtAuthGuard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RolesGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// satisfies require-guards; RolesGuard reads @Roles metadata&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Roles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;listAllUsers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;False-positive note for CI:&lt;/strong&gt; Teams registering &lt;code&gt;JwtAuthGuard&lt;/code&gt; as an &lt;code&gt;APP_GUARD&lt;/code&gt; globally can set &lt;code&gt;assumeGlobalGuards: true&lt;/code&gt; to suppress false positives on controllers that inherit protection. The rule also handles guards applied via inheritance and re-exported consts — it reads the decorator tree, not just immediate decorators on the class.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;See also: &lt;a href="https://dev.to/ofri-peretz/i-inherited-a-nestjs-codebase-the-first-lint-run-found-6-vulnerabilities-55ma"&gt;the same missing-guard pattern in a 2-year-old production codebase, and why every PR approved it&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 2: No rate limiting on auth endpoints (CWE-770)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://eslint.interlace.tools/docs/security/plugin-nestjs-security/rules/require-throttler" rel="noopener noreferrer"&gt;&lt;code&gt;nestjs-security/require-throttler&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nestjs-security/require-throttler
Route 'login' lacks @Throttle or ThrottlerGuard — brute-force exposure
  /src/users/users.controller.ts:10:3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An attacker can enumerate passwords against the login endpoint at full network speed.&lt;/p&gt;

&lt;p&gt;The rule tags this &lt;strong&gt;CWE-770&lt;/strong&gt; (Allocation of Resources Without Limits or Throttling) — the missing control is a rate limit, full stop. The downstream consequence on an &lt;em&gt;auth&lt;/em&gt; route is brute-force / credential stuffing (CWE-307), so you'll see this finding cross-referenced either way. The rule fires on the absent throttler, not on the route's purpose, which is why it reports the more general CWE-770.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why AI generates this:&lt;/strong&gt; Brute-force protection is a &lt;em&gt;rate-at-which&lt;/em&gt; constraint, not a &lt;em&gt;what-does-it-do&lt;/em&gt; constraint — those never appear in feature prompts. "Build a login endpoint" describes a function, not a limit on how fast it can be called. Claude Sonnet 4.6 knows &lt;code&gt;@Throttle&lt;/code&gt; exists; it will add it if you ask. The prompt didn't ask.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it survives review:&lt;/strong&gt; Reviewers look at handler logic (correct), DTO types (correct), error handling (present). Rate limiting reads as an infra concern — the assumption is nginx handles it. Two sprints later, someone updates the route prefix. The nginx rule stops matching. Nobody cross-references the two PRs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// requires @nestjs/throttler@^5 — ttl is in milliseconds (v4 and earlier used seconds)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ThrottlerGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Throttle&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// 60 seconds&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoginDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Necessary, not sufficient:&lt;/strong&gt; Per-IP throttling raises the cost of single-source enumeration. It does not stop distributed credential-stuffing from rotating source IPs. That requires anomaly detection at a different layer — &lt;code&gt;@Throttle&lt;/code&gt; is the floor, not the ceiling.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Finding 3: Sensitive fields in API responses (CWE-200)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://eslint.interlace.tools/docs/security/plugin-nestjs-security/rules/no-exposed-private-fields" rel="noopener noreferrer"&gt;&lt;code&gt;nestjs-security/no-exposed-private-fields&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nestjs-security/no-exposed-private-fields
Property 'password' in User entity not excluded from serialization
  /src/users/user.entity.ts:8:3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every API response from this service included &lt;code&gt;password&lt;/code&gt; in the JSON body. Not &lt;em&gt;could&lt;/em&gt; include under certain conditions. Every single response. This is the one finding I've never seen miss — I've yet to run this against an AI-generated NestJS service where it doesn't fire.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;PrimaryGeneratedColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uuid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// hashed — still in every API response&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why AI generates this:&lt;/strong&gt; AI models the entity as a data structure, not as a serialization contract. &lt;code&gt;@Exclude()&lt;/code&gt; from &lt;code&gt;class-transformer&lt;/code&gt; is only meaningful within NestJS's HTTP response lifecycle — invisible to a model focused on making the class definition correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it survives review:&lt;/strong&gt; The entity type is &lt;code&gt;User&lt;/code&gt;. The controller returns &lt;code&gt;User&lt;/code&gt;. TypeScript shows no errors. Reviewers see typed, structured data. What they don't see is the JSON shape at runtime, because they're reading code, not running &lt;code&gt;curl&lt;/code&gt; against staging. I would have approved this — the type system looked correct because it was.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;Exclude&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;class-transformer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;PrimaryGeneratedColumn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uuid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Exclude&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Exclude&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Two implementation approaches:&lt;/strong&gt; &lt;code&gt;@Exclude()&lt;/code&gt; on entities (shown here) vs. dedicated response DTOs that only expose what you intend. The DTO approach is architecturally cleaner — returning entity classes from controllers is the smell; the decorator is the patch. Either way, register the interceptor or &lt;code&gt;@Exclude()&lt;/code&gt; does nothing:&lt;/p&gt;


&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useGlobalInterceptors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ClassSerializerInterceptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Reflector&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Finding 4: No runtime input validation (CWE-20)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://eslint.interlace.tools/docs/security/plugin-nestjs-security/rules/no-missing-validation-pipe" rel="noopener noreferrer"&gt;&lt;code&gt;nestjs-security/no-missing-validation-pipe&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nestjs-security/no-missing-validation-pipe
@Body() parameter 'dto' in 'register' lacks ValidationPipe — runtime types not enforced
  /src/users/users.controller.ts:6:20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude generated typed DTOs. TypeScript enforces the shape at compile time. At runtime — without a &lt;code&gt;ValidationPipe&lt;/code&gt; — those types don't exist. Any JSON shape passes through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why AI generates this:&lt;/strong&gt; TypeScript types disappear at runtime. &lt;code&gt;ValidationPipe&lt;/code&gt; re-enforces them on the way in. Claude Sonnet 4.6 generates correct TypeScript — it doesn't model the gap between compile-time types and runtime validation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it survives review:&lt;/strong&gt; The DTO is typed. The parameter is typed. TypeScript shows no errors. This requires knowing what NestJS &lt;em&gt;doesn't&lt;/em&gt; do automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In main.ts — global is recommended over per-parameter&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useGlobalPipes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ValidationPipe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;whitelist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// strip properties with no class-validator decorator&lt;/span&gt;
    &lt;span class="na"&gt;forbidNonWhitelisted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// throw on unexpected properties&lt;/span&gt;
    &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// coerce to class instances; without this, instanceof checks fail&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The hole no linter catches — and the most important paragraph in this article:&lt;/strong&gt; Claude also omits &lt;code&gt;@ValidateNested()&lt;/code&gt; + &lt;code&gt;@Type(() =&amp;gt; NestedDto)&lt;/code&gt; on nested DTO objects. Without them, nested objects skip validation entirely — the class-validator decorators on the nested class are ignored at runtime. This is the single most frequent &lt;code&gt;ValidationPipe&lt;/code&gt; hole I see in AI-generated NestJS code, and it has &lt;strong&gt;no ESLint error&lt;/strong&gt;: TypeScript compiles, the pipe is registered, validation &lt;em&gt;appears&lt;/em&gt; to run, and the nested object passes through unchecked. Static analysis can flag the missing pipe (Finding 4) and the missing decorator (Finding 5); it cannot yet prove that a present decorator actually recurses. The lint gate narrows the gap — it does not close it, and pretending otherwise is how the nested hole survives. If you read one fix in this piece twice, make it this one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 5: DTO fields without enum constraints (CWE-915)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://eslint.interlace.tools/docs/security/plugin-nestjs-security/rules/require-class-validator" rel="noopener noreferrer"&gt;&lt;code&gt;nestjs-security/require-class-validator&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nestjs-security/require-class-validator
Property 'role' in CreateUserDto has no class-validator decorator
  /src/users/dto/create-user.dto.ts:5:3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateUserDto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsEmail&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nl"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no validator&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is mass assignment — CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes). The distinction from Finding 4 matters: Finding 4 is about missing runtime enforcement; Finding 5 is about missing value constraints that survive runtime enforcement.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;ValidationPipe({ whitelist: true })&lt;/code&gt;, an undecorated &lt;code&gt;role&lt;/code&gt; field is stripped — which sounds safe. It isn't, for a specific reason: &lt;strong&gt;developers add decorators later&lt;/strong&gt;. When someone adds &lt;code&gt;@IsString()&lt;/code&gt; to &lt;code&gt;role&lt;/code&gt; to pass it through the whitelist (a natural refactor), &lt;code&gt;role: 'admin'&lt;/code&gt; becomes a valid payload. &lt;code&gt;@IsString()&lt;/code&gt; doesn't constrain the value — only &lt;code&gt;@IsEnum(SelfAssignableRole)&lt;/code&gt; does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why AI generates this:&lt;/strong&gt; Claude adds validation for fields where the constraint is obvious from the semantic type (&lt;code&gt;email&lt;/code&gt; → &lt;code&gt;@IsEmail()&lt;/code&gt;). For &lt;code&gt;role&lt;/code&gt;, valid values are a domain-specific enum with no tutorial default. The model can't infer the allowed values from an unspecified domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it survives review:&lt;/strong&gt; Reviewers see &lt;code&gt;@IsEmail()&lt;/code&gt; on &lt;code&gt;email&lt;/code&gt; and pattern-match "this DTO is validated." They don't audit field by field for the one bare property. &lt;code&gt;role&lt;/code&gt; typically arrives as a quick patch after the initial commit — nobody circles back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Findings 4 and 5 are coupled:&lt;/strong&gt; &lt;code&gt;whitelist: true&lt;/code&gt; strips unknown &lt;em&gt;keys&lt;/em&gt;. It doesn't constrain &lt;em&gt;values&lt;/em&gt; on known keys. You need both: the pipe (Finding 4) and enum decorators (Finding 5). Either without the other leaves a privilege escalation path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;IsEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IsString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MaxLength&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IsEnum&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;class-validator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Separate from UserRole — admin is not self-assignable at registration.&lt;/span&gt;
&lt;span class="c1"&gt;// Using UserRole here would allow role: 'admin' since it's a valid member.&lt;/span&gt;
&lt;span class="kr"&gt;enum&lt;/span&gt; &lt;span class="nx"&gt;SelfAssignableRole&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;moderator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;moderator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// admin intentionally absent&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateUserDto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsEmail&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;MaxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsEnum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SelfAssignableRole&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// rejects 'admin' — not because it's unknown, but because it's not in this enum&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SelfAssignableRole&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Finding 6: Debug endpoint exposing credentials (CWE-489)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://eslint.interlace.tools/docs/security/plugin-nestjs-security/rules/no-exposed-debug-endpoints" rel="noopener noreferrer"&gt;&lt;code&gt;nestjs-security/no-exposed-debug-endpoints&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nestjs-security/no-exposed-debug-endpoints
Controller path 'debug/config' returns process.env — information disclosure
  /src/users/users.controller.ts:25:3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;curl&lt;/code&gt; to &lt;code&gt;/users/debug/config&lt;/code&gt;. Your &lt;code&gt;DATABASE_URL&lt;/code&gt; — hostname, port, username, password — serialized as JSON, no authentication. I found this exact pattern live in a staging environment in under 60 seconds. It had been live for four months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why AI generates this:&lt;/strong&gt; Claude added this as a diagnostic helper. It's genuinely useful during development. AI generates code for the specification given to it and has no concept of a production boundary. "Useful during development" and "never deploy this" are the same to a model that doesn't model deployment environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it survives review:&lt;/strong&gt; Debug endpoints arrive via two routes: AI generates them unguarded (this case), or a developer adds one temporarily and forgets to remove it. Either way, review approves it for the same reason — the code does what it says, the name implies "development only," and nothing breaks when it ships. The linter doesn't assume intent. It sees &lt;code&gt;process.env&lt;/code&gt; in a response and fires.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Guarding is not a fix.&lt;/strong&gt; A guarded endpoint returning &lt;code&gt;DATABASE_URL&lt;/code&gt; is still a credential leak waiting for a token to be compromised. Remove the sensitive values from the response entirely.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fix: environment-gated module — never conditionally guard a live endpoint&lt;/span&gt;
&lt;span class="c1"&gt;// In app.module.ts:&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;imports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;DebugModule&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppModule&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="c1"&gt;// In debug.module.ts — completely absent in production builds&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JwtAuthGuard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AdminGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DebugController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;getConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// never return DATABASE_URL&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The pattern: AI optimizes for compilation, not for absence
&lt;/h2&gt;

&lt;p&gt;All six findings share a root cause: &lt;strong&gt;the AI fulfilled the prompt, and the prompt didn't specify a security constraint.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;TypeScript can't catch any of these. They compile, run, and do exactly what the code says. What's missing in each case isn't behavior — it's the &lt;em&gt;absence&lt;/em&gt; of something: a decorator, a pipe, a guard, an enum constraint, an environment check.&lt;/p&gt;

&lt;p&gt;The question that surfaces all six: &lt;em&gt;"What happens when someone who isn't supposed to use this endpoint tries?"&lt;/em&gt; That's a negative-space question. AI doesn't ask it unless you do. Code reviewers often don't either — we're trained to verify correctness, not the absence of unauthorized access.&lt;/p&gt;

&lt;p&gt;Static analysis asks it on every file, every run. &lt;a href="https://dev.to/ofri-peretz/the-ai-hydra-problem-fix-one-ai-bug-get-two-more-5g1l"&gt;The Hydra Problem&lt;/a&gt; shows what happens when you try to fix AI omissions one at a time in review: fixing one surfaces others. The 65–75% rate held &lt;a href="https://dev.to/ofri-peretz/aggregate-benchmarks-lie-heres-what-700-ai-functions-look-like-by-security-domain-1hgj"&gt;across every security domain we tested&lt;/a&gt;. NestJS is no exception.&lt;/p&gt;

&lt;h3&gt;
  
  
  This isn't only a Claude problem — it's a prompt problem
&lt;/h3&gt;

&lt;p&gt;The natural objection: maybe six is a Claude-specific weakness, and another toolchain gets it right. The count &lt;em&gt;does&lt;/em&gt; move with the toolchain — but the root cause doesn't. These aren't bugs the model got wrong; they're constraints the prompt never stated. Change assistants and the count changes; the negative-space class survives.&lt;/p&gt;

&lt;p&gt;I ran the identical prompt through Gemini 2.5 Flash via the Gemini CLI and scanned the output with the same plugin: &lt;a href="https://ofriperetz.dev/articles/claude-vs-gemini-nestjs-security-same-prompt-different-errors" rel="noopener noreferrer"&gt;Same NestJS Prompt. Claude Got 6 Errors. Gemini Got 2.&lt;/a&gt; Gemini's default scaffolding was structurally tighter — it got guards, validators, and serialization right where Claude didn't. But both toolchains shipped the same Finding 2: &lt;strong&gt;no rate limiting on the login endpoint.&lt;/strong&gt; The one class that survived the model swap is the one neither prompt thought to constrain.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Running this against Gemini?&lt;/strong&gt; That companion article is the Gemini-CLI run of this exact methodology — same prompt, same plugin, scored against Gemini 2.5 Flash — and it's the version positioned for the &lt;a href="https://dev.to/challenges"&gt;Build with Gemini XPRIZE&lt;/a&gt; challenge. If you want to reproduce the experiment on a Gemini model rather than Claude, start there; the adaptation is a one-line model swap in the prompt and a re-run of the config below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can verify the whole thing yourself in three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Paste the same prompt — &lt;em&gt;"Build a NestJS users service. Authentication, registration, login, profile endpoint, admin panel."&lt;/em&gt; — into whatever assistant you use (Claude, Gemini, GPT-4, Copilot).&lt;/li&gt;
&lt;li&gt;Run the config below on the output.&lt;/li&gt;
&lt;li&gt;Count the findings by &lt;em&gt;class&lt;/em&gt;, not by line. The total drifts by toolchain; the rate-limit, missing-guard, and exposed-&lt;code&gt;password&lt;/code&gt; classes keep recurring. The rules read the decorator tree, not the git blame.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The config
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;nestjsSecurity&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eslint-plugin-nestjs-security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nestjsSecurity&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/require-guards&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;assumeGlobalGuards&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/no-exposed-private-fields&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/require-throttler&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/no-missing-validation-pipe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/require-class-validator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/no-exposed-debug-endpoints&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-nestjs-security
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; NestJS is always TypeScript. Add these rules to your existing &lt;code&gt;typescript-eslint&lt;/code&gt; configuration — the config above assumes &lt;code&gt;languageOptions.parser&lt;/code&gt; and &lt;code&gt;parserOptions.project&lt;/code&gt; are already set. Running &lt;code&gt;eslint src/&lt;/code&gt; without the TS parser will fail on decorators.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Full rule documentation at &lt;a href="https://eslint.interlace.tools/docs/security/plugin-nestjs-security" rel="noopener noreferrer"&gt;eslint.interlace.tools&lt;/a&gt;. New to the plugin? &lt;a href="https://dev.to/ofri-peretz/getting-started-with-eslint-plugin-nestjs-security-32ic"&gt;Architectural Security: The NestJS Static Analysis Standard&lt;/a&gt; covers the full rule set end to end.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's the most embarrassing thing a debug endpoint or an unguarded route has leaked in a codebase you inherited — and how long had it been live before anyone noticed?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://dev.to/ofri-peretz/series/ai-security-benchmark-series"&gt;AI Security Benchmark Series&lt;/a&gt;:&lt;/em&gt;&lt;br&gt;
&lt;em&gt;← &lt;a href="https://dev.to/ofri-peretz/i-let-claude-write-60-functions-65-75-had-security-vulnerabilities-414o"&gt;I Let Claude Write 80 Functions. 65-75% Had Security Vulnerabilities.&lt;/a&gt; | &lt;strong&gt;Claude Wrote a NestJS Service (you are here)&lt;/strong&gt; | &lt;a href="https://dev.to/ofri-peretz/aggregate-benchmarks-lie-heres-what-700-ai-functions-look-like-by-security-domain-1hgj"&gt;Aggregate Benchmarks Lie →&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;📦 &lt;a href="https://www.npmjs.com/package/eslint-plugin-nestjs-security" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-nestjs-security&lt;/code&gt;&lt;/a&gt; · &lt;a href="https://eslint.interlace.tools/docs/security/plugin-nestjs-security" rel="noopener noreferrer"&gt;Rule docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/eslint" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star on GitHub&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/ofri-peretz" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://x.com/ofriperetzdev" rel="noopener noreferrer"&gt;X&lt;/a&gt; | &lt;a href="https://linkedin.com/in/ofri-peretz" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/ofri-peretz"&gt;Dev.to&lt;/a&gt; | &lt;a href="https://ofriperetz.dev" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>node</category>
      <category>eslint</category>
    </item>
    <item>
      <title>I Inherited a NestJS Codebase. 12 Seconds of ESLint Found 47 Violations Across 6 Vulnerability Classes.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Thu, 28 May 2026 07:46:05 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/i-inherited-a-nestjs-codebase-the-first-lint-run-found-6-vulnerabilities-55ma</link>
      <guid>https://dev.to/ofri-peretz/i-inherited-a-nestjs-codebase-the-first-lint-run-found-6-vulnerabilities-55ma</guid>
      <description>&lt;p&gt;Code review checks for what's there. Static analysis checks for what's missing.&lt;/p&gt;

&lt;p&gt;That asymmetry is why a codebase can have CI, tests, TypeScript strict mode, and two years of feature PRs — and still ship 6 distinct vulnerability classes that no reviewer caught. Not because reviewers were careless. Because every one of these bugs required noticing the &lt;em&gt;absence&lt;/em&gt; of something: a missing decorator, a missing pipe, a missing guard. That's off the mental stack when you're reading route logic.&lt;/p&gt;

&lt;p&gt;The first run of &lt;code&gt;eslint-plugin-nestjs-security&lt;/code&gt; on a 40K-line production codebase took 12 seconds. It found 47 violations across 6 distinct vulnerability classes — auth bypass, sensitive field leaks, brute-force exposure, and three more. Nobody had touched a security tool in two years. Twelve seconds.&lt;/p&gt;

&lt;p&gt;If you just inherited a NestJS service and your stomach is now in a knot, run it on yours before reading further — it's one install, full config is below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-nestjs-security
npx eslint src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here are all 6 — and exactly why each one survived code review.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Unguarded Controllers (CWE-284)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the code looked like:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AdminController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getAllUsers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user/:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;deleteUser&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it survived review:&lt;/strong&gt; The team believed a global &lt;code&gt;JwtAuthGuard&lt;/code&gt; was configured in &lt;code&gt;main.ts&lt;/code&gt;. It was configured on the AppModule — but a 6-month-old refactor broke the middleware ordering. No test caught this because the test suite mocked the guard globally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the lint rule catches:&lt;/strong&gt; &lt;code&gt;require-guards&lt;/code&gt; fires on any &lt;code&gt;@Controller&lt;/code&gt; class or route handler that lacks &lt;code&gt;@UseGuards(...)&lt;/code&gt; or a &lt;code&gt;@Public()&lt;/code&gt; opt-out. No type inference needed — pure structural analysis.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fix: explicit guard at the controller level&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JwtAuthGuard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RolesGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AdminController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Roles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getAllUsers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  2. Sensitive Fields Leaking in Responses (CWE-200)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the code looked like:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// hashed, but still in the response&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// full token, rotated monthly&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usersService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// entity returned directly&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it survived review:&lt;/strong&gt; The entity was consumed exclusively by an internal gRPC service that deserialized it into a typed struct — stripping unknown fields silently on the client side. No API log, no Datadog response capture, no staging curl that would surface &lt;code&gt;password&lt;/code&gt; in the body. The data left the server but never appeared anywhere the team looked. A penetration tester found it by running a raw HTTP client against the REST endpoint that was added three months later and never audited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the lint rule catches:&lt;/strong&gt; &lt;code&gt;no-exposed-private-fields&lt;/code&gt; scans class properties for sensitive field name patterns (&lt;code&gt;password&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;apiKey&lt;/code&gt;, &lt;code&gt;refreshToken&lt;/code&gt;, &lt;code&gt;ssn&lt;/code&gt;, &lt;code&gt;creditCard&lt;/code&gt;, ...) and flags any that aren't decorated with &lt;code&gt;@Exclude()&lt;/code&gt; from &lt;code&gt;class-transformer&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;Exclude&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;class-transformer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Exclude&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Exclude&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// In main.ts:&lt;/span&gt;
&lt;span class="c1"&gt;// app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  3. Auth Endpoints Without Rate Limiting (CWE-307)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the code looked like:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoginDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reset-password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;resetPassword&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ResetPasswordDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resetPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it survived review:&lt;/strong&gt; The infra team planned to add rate limiting at the nginx layer. The nginx config was updated for &lt;code&gt;/api/v1/auth/login&lt;/code&gt; — but the app prefix was changed to &lt;code&gt;/api/v2&lt;/code&gt; in the same sprint. Nobody cross-referenced the two PRs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the lint rule catches:&lt;/strong&gt; &lt;code&gt;require-throttler&lt;/code&gt; flags any &lt;code&gt;@Controller&lt;/code&gt; or route handler that doesn't have &lt;code&gt;@Throttle(...)&lt;/code&gt; or &lt;code&gt;ThrottlerGuard&lt;/code&gt; in its guard chain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// requires @nestjs/throttler@^4&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ThrottlerGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Throttle&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// 5 per minute&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LoginDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  4. Unvalidated DTO Inputs (CWE-20)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the code looked like:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;typed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;createPost&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CreatePostDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postsService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it survived review:&lt;/strong&gt; &lt;code&gt;CreatePostDto&lt;/code&gt; is typed. TypeScript enforces the shape at compile time. Reviewers saw a typed DTO and assumed validation was running. It wasn't — without a &lt;code&gt;ValidationPipe&lt;/code&gt;, the TypeScript types are compile-time only. At runtime, any shape passes through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the lint rule catches:&lt;/strong&gt; &lt;code&gt;no-missing-validation-pipe&lt;/code&gt; flags &lt;code&gt;@Body()&lt;/code&gt; parameters that lack &lt;code&gt;new ValidationPipe()&lt;/code&gt; at the parameter level, and verifies that a global &lt;code&gt;ValidationPipe&lt;/code&gt; is registered.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Option 1: global (recommended) — in main.ts&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useGlobalPipes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ValidationPipe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;whitelist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;forbidNonWhitelisted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Option 2: per-parameter&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;createPost&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ValidationPipe&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CreatePostDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postsService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. DTO Properties Without Validation Decorators (CWE-20)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the code looked like:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateUserDto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsEmail&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// no validator — accepts any length, any encoding&lt;/span&gt;

  &lt;span class="nl"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// no validator — accepts 'admin' as easily as 'user'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it survived review:&lt;/strong&gt; &lt;code&gt;email&lt;/code&gt; had a decorator, so the reviewer's eye treated the DTO as validated and moved on. One decorated field gave the whole class a passing grade. The &lt;code&gt;role&lt;/code&gt; field was added three weeks later in a quick patch, never circled back to, and accepted because the surrounding context looked safe. When &lt;code&gt;whitelist: true&lt;/code&gt; wasn't enforced at runtime, &lt;code&gt;role: 'admin'&lt;/code&gt; passed through unchecked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the lint rule catches:&lt;/strong&gt; &lt;code&gt;require-class-validator&lt;/code&gt; verifies that every property in a DTO class has at least one &lt;code&gt;class-validator&lt;/code&gt; decorator.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;IsEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IsString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MaxLength&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IsEnum&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;class-validator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateUserDto&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsEmail&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;MaxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;IsEnum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;UserRole&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// 'user' | 'moderator' — not 'admin'&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UserRole&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  6. Debug Endpoints Left in Production (CWE-215)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What the code looked like:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;debug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DebugController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;getConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// DATABASE_URL, JWT_SECRET, STRIPE_SECRET_KEY, all of it&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it survived review:&lt;/strong&gt; It was protected by &lt;code&gt;@UseGuards(JwtAuthGuard)&lt;/code&gt; — in staging. A &lt;code&gt;NODE_ENV === 'production'&lt;/code&gt; check was meant to disable it but the condition was inverted. Deployed to production in a Friday afternoon push. Found by a user who noticed &lt;code&gt;/debug/config&lt;/code&gt; returned valid JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the lint rule catches:&lt;/strong&gt; &lt;code&gt;no-exposed-debug-endpoints&lt;/code&gt; flags controllers with paths matching &lt;code&gt;debug&lt;/code&gt;, &lt;code&gt;internal&lt;/code&gt;, or &lt;code&gt;_health&lt;/code&gt; that lack auth guards, and any endpoint that returns &lt;code&gt;process.env&lt;/code&gt; directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fix: remove the controller entirely.&lt;/span&gt;
&lt;span class="c1"&gt;// If you need a health check for load balancers / k8s probes,&lt;/span&gt;
&lt;span class="c1"&gt;// use a dedicated module that never touches process.env:&lt;/span&gt;
&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HealthController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Note: health endpoints are intentionally public (LBs can't auth).&lt;/span&gt;
&lt;span class="c1"&gt;// The rule won't fire here because the path is 'health', not 'debug'.&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;a id="the-config"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The config that catches all 6 in one pass
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;nestjsSecurity&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;eslint-plugin-nestjs-security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nestjsSecurity&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/require-guards&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/no-exposed-private-fields&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/require-throttler&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/no-missing-validation-pipe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/require-class-validator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nestjs-security/no-exposed-debug-endpoints&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; eslint-plugin-nestjs-security
npx eslint src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The connection between #4 and #5
&lt;/h2&gt;

&lt;p&gt;Bugs 4 and 5 interact. &lt;code&gt;whitelist: true&lt;/code&gt; on the &lt;code&gt;ValidationPipe&lt;/code&gt; strips the &lt;code&gt;role: 'admin'&lt;/code&gt; attack — but only if the pipe is actually registered (bug #4). Without it, even a perfectly decorated DTO is runtime-permissive. The two rules catch the issues independently; fixing one without the other leaves a gap. Run both checks.&lt;/p&gt;




&lt;h2&gt;
  
  
  These aren't legacy bugs. Your AI assistant writes them today.
&lt;/h2&gt;

&lt;p&gt;Here's the part that turned this from a one-off cleanup into a rule set I now run on everything: these six patterns are not artifacts of 2018 NestJS or a junior who didn't know better. They are the &lt;em&gt;default output of a competent developer moving fast&lt;/em&gt; — which is exactly what a coding assistant emulates.&lt;/p&gt;

&lt;p&gt;I gave Claude Sonnet 4.6 a single prompt — "Build a NestJS users service. Authentication, registration, login, profile endpoint, admin panel." — and ran the same plugin on the result. It produced 200 lines of clean, TypeScript-passing NestJS, and &lt;strong&gt;6 errors in 3 seconds&lt;/strong&gt;: the same unguarded admin controller, the same &lt;code&gt;password&lt;/code&gt; in the response body, the same unthrottled login route, the same debug endpoint returning &lt;code&gt;DATABASE_URL&lt;/code&gt;. Not similar bugs — the same six classes. I wrote that up in &lt;a href="https://ofriperetz.dev/articles/claude-wrote-nestjs-service-eslint-found-6-security-holes" rel="noopener noreferrer"&gt;Claude Wrote a NestJS Service. TypeScript Was Happy. ESLint Found 6 Security Holes&lt;/a&gt;. Running the same prompt through Gemini 2.5 Flash got the count down to 2 — but it still shipped auth endpoints with no rate limiting (&lt;a href="https://ofriperetz.dev/articles/claude-vs-gemini-nestjs-security-same-prompt-different-errors" rel="noopener noreferrer"&gt;Claude vs Gemini, same prompt, different errors&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The reason is the same reason these survived human review: an LLM optimizes for the route logic you asked for, and the security boundary is the &lt;em&gt;absence&lt;/em&gt; of something it was never prompted to add. A guard that isn't there doesn't show up in a diff, doesn't fail a type check, and doesn't fail a unit test that mocked it. It only shows up in structural analysis — which is the entire premise of &lt;a href="https://ofriperetz.dev/articles/i-let-claude-write-60-functions-65-75-had-security-vulnerabilities" rel="noopener noreferrer"&gt;I Let Claude Write 80 Functions. 65-75% Had Security Vulnerabilities&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So the inherited codebase and the AI-generated one converge on the same lint config. Whether the &lt;code&gt;password&lt;/code&gt; leak came from a 2-year-old PR or from yesterday's autocomplete, &lt;code&gt;no-exposed-private-fields&lt;/code&gt; fires identically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this survived review (the pattern underneath all six)
&lt;/h2&gt;

&lt;p&gt;Look back at the six "why it survived review" notes and the failure mode is identical every time: &lt;strong&gt;the security control existed somewhere the reviewer trusted, so the reviewer stopped looking.&lt;/strong&gt; The global guard "was in &lt;code&gt;main.ts&lt;/code&gt;." Rate limiting "was at the nginx layer." The debug endpoint "was disabled in production." The DTO "was typed." In each case a senior engineer waved the PR through not out of negligence but because the diff in front of them was locally correct — and the broken assumption lived in a different file, a different sprint, or a different team's config. Code review verifies the lines that changed. None of these bugs were in the lines that changed.&lt;/p&gt;

&lt;p&gt;That's why a structural linter is not a downgrade from human review — it's the half of the review that humans are structurally bad at. For the full protocol I run on day one of any inherited service — the three plugins, the &lt;code&gt;jq&lt;/code&gt; one-liner that ranks findings by rule, and how to read the heatmap — see &lt;a href="https://ofriperetz.dev/articles/the-30-minute-security-audit-onboarding-a-new-codebase" rel="noopener noreferrer"&gt;I Inherited a 3,000-Line Codebase. One ESLint Run Found 26 Critical Security Bugs&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Which of these six hit production before anyone noticed — and what was the moment you found out: a pentest, a 2 a.m. page, or a user emailing you a screenshot of &lt;code&gt;/debug/config&lt;/code&gt;? Drop the worst one below.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;📦 &lt;a href="https://www.npmjs.com/package/eslint-plugin-nestjs-security" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-nestjs-security&lt;/code&gt;&lt;/a&gt; — 6 security rules for NestJS · &lt;a href="https://eslint.interlace.tools" rel="noopener noreferrer"&gt;rule docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/eslint" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star on GitHub&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Companion pieces:&lt;/strong&gt; the same six classes in &lt;a href="https://ofriperetz.dev/articles/claude-wrote-nestjs-service-eslint-found-6-security-holes" rel="noopener noreferrer"&gt;AI-generated code&lt;/a&gt; · the &lt;a href="https://ofriperetz.dev/articles/claude-vs-gemini-nestjs-security-same-prompt-different-errors" rel="noopener noreferrer"&gt;Claude vs Gemini&lt;/a&gt; head-to-head · the full &lt;a href="https://ofriperetz.dev/articles/the-30-minute-security-audit-onboarding-a-new-codebase" rel="noopener noreferrer"&gt;day-one audit protocol&lt;/a&gt; for an inherited codebase.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/ofri-peretz" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://x.com/ofriperetzdev" rel="noopener noreferrer"&gt;X&lt;/a&gt; | &lt;a href="https://linkedin.com/in/ofri-peretz" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/ofri-peretz"&gt;Dev.to&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>The #1 ESLint Security Plugin Has 1.5M Downloads and Caught 0 of My 40 Vulnerabilities</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Mon, 25 May 2026 14:27:23 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/i-benchmarked-17-eslint-security-plugins-only-one-found-every-vulnerability-c83</link>
      <guid>https://dev.to/ofri-peretz/i-benchmarked-17-eslint-security-plugins-only-one-found-every-vulnerability-c83</guid>
      <description>&lt;p&gt;&lt;strong&gt;Skip to:&lt;/strong&gt; Full Results | Category Breakdown | The Leaderboard | Methodology&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built a benchmark suite with &lt;strong&gt;40 vulnerable code patterns&lt;/strong&gt; across 14 CWE categories and &lt;strong&gt;38 verified-safe patterns&lt;/strong&gt;. Then I ran &lt;strong&gt;17 ESLint plugins&lt;/strong&gt; against them — every major security, quality, and framework plugin in the ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One plugin achieved a perfect score. Most others detected under 50% of patterns.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;Rules&lt;/th&gt;
&lt;th&gt;TP&lt;/th&gt;
&lt;th&gt;FP&lt;/th&gt;
&lt;th&gt;F1 Score&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🥇&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Interlace Ecosystem&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40/40&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥈&lt;/td&gt;
&lt;td&gt;eslint-plugin-sonarjs&lt;/td&gt;
&lt;td&gt;269&lt;/td&gt;
&lt;td&gt;14/40&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;47.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥉&lt;/td&gt;
&lt;td&gt;eslint-plugin-unicorn&lt;/td&gt;
&lt;td&gt;144&lt;/td&gt;
&lt;td&gt;22/40&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;51.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;@microsoft/eslint-plugin-sdl&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;4/40&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;17.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;eslint-plugin-security †&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;0/40&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0% (crash)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;† &lt;code&gt;eslint-plugin-security&lt;/code&gt; crashes on ESLint 9.39.2 with &lt;code&gt;TypeError: context.getScope is not a function&lt;/code&gt;, so the bench records 0 detections on the standard test environment. On ESLint 8.57.0 it detects 11/40 (recall 27.5%) but with an equal number of false positives (a 1:1 TP:FP ratio).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The incumbent security plugin — &lt;code&gt;eslint-plugin-security&lt;/code&gt;, with 1.5M+ weekly downloads — &lt;strong&gt;detects zero vulnerabilities on modern ESLint&lt;/strong&gt; because it hasn't been updated for the flat-config API.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;Most Node.js teams rely on a security linter they've never benchmarked. They install &lt;code&gt;eslint-plugin-security&lt;/code&gt; or enable SonarJS security rules and assume they're covered.&lt;/p&gt;

&lt;p&gt;They're not.&lt;/p&gt;

&lt;p&gt;The data shows a &lt;strong&gt;massive detection gap&lt;/strong&gt; across the entire ecosystem. Plugins that claim security coverage miss 60–100% of standard vulnerability patterns. And some of the highest-downloaded plugins aren't security tools at all — they detected zero issues from our suite.&lt;/p&gt;

&lt;p&gt;This isn't theoretical. These are OWASP Top 10 patterns that ship to production every day.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this survives code review
&lt;/h3&gt;

&lt;p&gt;Here's the part that should make you uncomfortable: the &lt;em&gt;name&lt;/em&gt; of a security plugin in your config is evidence to a reviewer. When &lt;code&gt;eslint-plugin-security&lt;/code&gt; is listed in &lt;code&gt;eslint.config.js&lt;/code&gt;, the pull request reads as covered — the reviewer sees "security linter: present" and approves. Nobody re-reads the SQL string concatenation, because the tooling is supposed to have looked at it. But on ESLint 9 the plugin's rules crash (see the &lt;code&gt;context.getScope&lt;/code&gt; error above) and contribute &lt;strong&gt;zero&lt;/strong&gt; detections; the config still claims coverage it can no longer deliver.&lt;/p&gt;

&lt;p&gt;I've watched this exact failure on real teams. The linter isn't lying on purpose — it's a tool the team adopted years ago, pinned, and never re-benchmarked across a major ESLint upgrade. The config still &lt;em&gt;says&lt;/em&gt; &lt;code&gt;eslint-plugin-security&lt;/code&gt;. The coverage left two ESLint majors ago. The &lt;em&gt;appearance&lt;/em&gt; of a security gate became a false sense of security, which is worse than no linter at all, because no linter at least keeps a human paranoid.&lt;/p&gt;

&lt;p&gt;That's the difference between "we run a security linter" and "we measured what our security linter catches." This benchmark is the second one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Benchmark Suite
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Test Environment
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Node.js&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;v20.19.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ESLint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;9.39.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Platform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;macOS (darwin/arm64)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Date&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;February 8, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Vulnerable Patterns (40 cases, 14 CWE categories)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Cases&lt;/th&gt;
&lt;th&gt;CWEs&lt;/th&gt;
&lt;th&gt;Real-World Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQL Injection&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;CWE-89&lt;/td&gt;
&lt;td&gt;Data exfiltration, auth bypass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Command Injection&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;CWE-78&lt;/td&gt;
&lt;td&gt;Remote code execution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path Traversal&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;CWE-22&lt;/td&gt;
&lt;td&gt;Arbitrary file read/write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardcoded Credentials&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;CWE-798&lt;/td&gt;
&lt;td&gt;Account takeover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JWT Vulnerabilities&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;CWE-757, CWE-347&lt;/td&gt;
&lt;td&gt;Auth bypass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XSS / Code Execution&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;CWE-79, CWE-94&lt;/td&gt;
&lt;td&gt;Session hijack, RCE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prototype Pollution&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;CWE-1321&lt;/td&gt;
&lt;td&gt;DoS, property injection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insecure Randomness&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;CWE-330&lt;/td&gt;
&lt;td&gt;Predictable tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weak Cryptography&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;CWE-328, CWE-327&lt;/td&gt;
&lt;td&gt;Credential exposure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timing Attacks&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;CWE-208&lt;/td&gt;
&lt;td&gt;Secret extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NoSQL Injection&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;CWE-943&lt;/td&gt;
&lt;td&gt;Data exfiltration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSRF&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;CWE-918&lt;/td&gt;
&lt;td&gt;Internal network access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open Redirect&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;CWE-601&lt;/td&gt;
&lt;td&gt;Phishing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReDoS&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;CWE-1333&lt;/td&gt;
&lt;td&gt;Denial of service&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Safe Patterns (38 cases)
&lt;/h3&gt;

&lt;p&gt;These are &lt;strong&gt;correctly-implemented secure patterns&lt;/strong&gt; that should NOT trigger warnings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Parameterized SQL queries (Prisma, TypeORM, pg)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;execFile&lt;/code&gt; with validated arguments&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;path.resolve&lt;/code&gt; with &lt;code&gt;startsWith&lt;/code&gt; validation&lt;/li&gt;
&lt;li&gt;Environment variables for credentials&lt;/li&gt;
&lt;li&gt;JWT with explicit algorithm restriction&lt;/li&gt;
&lt;li&gt;DOMPurify sanitization&lt;/li&gt;
&lt;li&gt;Allowlist validation before object access&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;crypto.randomBytes&lt;/code&gt; for tokens&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; for comparisons&lt;/li&gt;
&lt;li&gt;URL allowlists for SSRF prevention&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any warnings on these patterns are &lt;strong&gt;false positives&lt;/strong&gt; — noise that creates alert fatigue and trains developers to ignore real issues.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Leaderboard
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;Plugin download counts cited throughout this article are weekly figures snapshotted on 2026-02-08 from &lt;a href="https://npm-stat.com" rel="noopener noreferrer"&gt;npm-stat.com&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Rules&lt;/th&gt;
&lt;th&gt;TP&lt;/th&gt;
&lt;th&gt;FP&lt;/th&gt;
&lt;th&gt;FN&lt;/th&gt;
&lt;th&gt;Precision&lt;/th&gt;
&lt;th&gt;Recall&lt;/th&gt;
&lt;th&gt;F1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🥇&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Interlace Ecosystem&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.0.2&lt;/td&gt;
&lt;td&gt;201&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥈&lt;/td&gt;
&lt;td&gt;eslint-plugin-sonarjs&lt;/td&gt;
&lt;td&gt;3.0.6&lt;/td&gt;
&lt;td&gt;269&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;73.7%&lt;/td&gt;
&lt;td&gt;35.0%&lt;/td&gt;
&lt;td&gt;47.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🥉&lt;/td&gt;
&lt;td&gt;eslint-plugin-unicorn&lt;/td&gt;
&lt;td&gt;62.0.0&lt;/td&gt;
&lt;td&gt;144&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;48.9%&lt;/td&gt;
&lt;td&gt;55.0%&lt;/td&gt;
&lt;td&gt;51.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;@microsoft/eslint-plugin-sdl&lt;/td&gt;
&lt;td&gt;1.1.0&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;80.0%&lt;/td&gt;
&lt;td&gt;10.0%&lt;/td&gt;
&lt;td&gt;17.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;eslint-plugin-no-secrets&lt;/td&gt;
&lt;td&gt;2.2.1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;5.0%&lt;/td&gt;
&lt;td&gt;9.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;eslint-plugin-no-unsanitized&lt;/td&gt;
&lt;td&gt;4.1.4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;66.7%&lt;/td&gt;
&lt;td&gt;5.0%&lt;/td&gt;
&lt;td&gt;9.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;eslint-plugin-n&lt;/td&gt;
&lt;td&gt;17.23.2&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;td&gt;40.0%&lt;/td&gt;
&lt;td&gt;5.0%&lt;/td&gt;
&lt;td&gt;8.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;eslint-plugin-regexp&lt;/td&gt;
&lt;td&gt;3.0.0&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;33.3%&lt;/td&gt;
&lt;td&gt;2.5%&lt;/td&gt;
&lt;td&gt;4.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;eslint-plugin-security †&lt;/td&gt;
&lt;td&gt;2.1.1&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;eslint-plugin-react&lt;/td&gt;
&lt;td&gt;7.37.5&lt;/td&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;eslint-plugin-jsx-a11y&lt;/td&gt;
&lt;td&gt;6.10.2&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;eslint-plugin-import&lt;/td&gt;
&lt;td&gt;2.32.0&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;eslint-plugin-promise&lt;/td&gt;
&lt;td&gt;7.2.1&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;eslint-plugin-jest&lt;/td&gt;
&lt;td&gt;29.12.2&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;eslint-plugin-vue&lt;/td&gt;
&lt;td&gt;10.7.0&lt;/td&gt;
&lt;td&gt;250&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;@angular-eslint/eslint-plugin&lt;/td&gt;
&lt;td&gt;21.2.0&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; &lt;code&gt;eslint-plugin-jsdoc&lt;/code&gt; (38 TP / 37 FP / F1=66.1%) was excluded from the leaderboard. Its detections are incidental — it flags every function missing JSDoc, not security issues. A 97.4% false positive rate is unusable for security.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Visual Detection Rates
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Vulnerable Code Detections (out of 40 patterns):

Interlace Ecosystem:       ████████████████████████████████████████  40 (100%)
eslint-plugin-unicorn:     ██████████████████████░░░░░░░░░░░░░░░░░░  22 (55%)
eslint-plugin-sonarjs:     ██████████████░░░░░░░░░░░░░░░░░░░░░░░░░░  14 (35%)
@microsoft/eslint-plugin:  ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   4 (10%)
eslint-plugin-security:    ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   0 (0%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The plugins behind "Interlace Ecosystem"
&lt;/h2&gt;

&lt;p&gt;The "Interlace Ecosystem" row in the leaderboard is the combined output of 10 ESLint plugins running together against the same fixture suite — 201 rules in total:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;eslint-plugin-secure-coding&lt;/code&gt; · &lt;code&gt;eslint-plugin-node-security&lt;/code&gt; · &lt;code&gt;eslint-plugin-browser-security&lt;/code&gt; · &lt;code&gt;eslint-plugin-pg&lt;/code&gt; · &lt;code&gt;eslint-plugin-jwt&lt;/code&gt; · &lt;code&gt;eslint-plugin-mongodb-security&lt;/code&gt; · &lt;code&gt;eslint-plugin-vercel-ai-security&lt;/code&gt; · &lt;code&gt;eslint-plugin-lambda-security&lt;/code&gt; · &lt;code&gt;eslint-plugin-express-security&lt;/code&gt; · &lt;code&gt;eslint-plugin-nestjs-security&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Per-plugin rule counts and focus areas are in Specialization vs. one-size-fits-all below.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Security Plugins: Deep Dive
&lt;/h2&gt;

&lt;h3&gt;
  
  
  eslint-plugin-security (1.5M+ downloads) — BROKEN
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;F1 Score: 0%&lt;/strong&gt; | Zero detections&lt;/p&gt;

&lt;p&gt;The most widely-installed ESLint security plugin detected nothing. It crashes on ESLint 9 with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: context.getScope is not a function
Rule: "security/detect-child-process"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is due to the deprecated &lt;code&gt;context.getScope()&lt;/code&gt; API removed in ESLint 9. The plugin hasn't been updated since 2024. &lt;strong&gt;If you're using ESLint 9 with flat config, this plugin provides zero security coverage.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;📖 &lt;em&gt;Deep dive: &lt;a href="https://ofriperetz.dev/articles/eslint-plugin-security-is-unmaintained-heres-what-nobody-tells-you-96h" rel="noopener noreferrer"&gt;eslint-plugin-security Is Unmaintained — Here's What Nobody Tells You&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  eslint-plugin-sonarjs (3M+ downloads) — 35% Recall
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;F1 Score: 47.5%&lt;/strong&gt; | 14 detected, 26 missed, 5 false positives&lt;/p&gt;

&lt;p&gt;SonarJS found issues across a few categories but missed the majority:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;SonarJS&lt;/th&gt;
&lt;th&gt;What It Missed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQL Injection&lt;/td&gt;
&lt;td&gt;2/4&lt;/td&gt;
&lt;td&gt;Template literal patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Command Injection&lt;/td&gt;
&lt;td&gt;2/4&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;execSync&lt;/code&gt;, &lt;code&gt;spawn&lt;/code&gt; with shell&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XSS&lt;/td&gt;
&lt;td&gt;2/4&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;document.write&lt;/code&gt;, &lt;code&gt;new Function&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardcoded Credentials&lt;/td&gt;
&lt;td&gt;2/4&lt;/td&gt;
&lt;td&gt;AWS keys, JWT secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path Traversal&lt;/td&gt;
&lt;td&gt;0/4&lt;/td&gt;
&lt;td&gt;❌ All&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JWT&lt;/td&gt;
&lt;td&gt;0/3&lt;/td&gt;
&lt;td&gt;❌ All&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timing Attacks&lt;/td&gt;
&lt;td&gt;0/2&lt;/td&gt;
&lt;td&gt;❌ All&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NoSQL Injection&lt;/td&gt;
&lt;td&gt;0/2&lt;/td&gt;
&lt;td&gt;❌ All&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSRF&lt;/td&gt;
&lt;td&gt;0/2&lt;/td&gt;
&lt;td&gt;❌ All&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Despite having &lt;strong&gt;269 rules&lt;/strong&gt; (the most of any plugin tested), SonarJS missed 65% of vulnerabilities. Many of its rules target code quality, not security.&lt;/p&gt;

&lt;p&gt;📖 &lt;em&gt;Deep dive: &lt;a href="https://ofriperetz.dev/articles/benchmark-sonarjs-vs-interlace" rel="noopener noreferrer"&gt;SonarJS vs Interlace: 269 Rules, 65% Missed&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  @microsoft/eslint-plugin-sdl — 10% Recall
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;F1 Score: 17.8%&lt;/strong&gt; | 4 detected, 36 missed, 1 false positive&lt;/p&gt;

&lt;p&gt;Microsoft's SDL (Security Development Lifecycle) plugin found XSS via &lt;code&gt;innerHTML&lt;/code&gt;/&lt;code&gt;document.write&lt;/code&gt; and &lt;code&gt;eval&lt;/code&gt; patterns, but missed everything else. Its 17 rules focus narrowly on browser-side injection.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Microsoft SDL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;XSS&lt;/td&gt;
&lt;td&gt;2/4 ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code Execution&lt;/td&gt;
&lt;td&gt;2/4 ⚠️&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everything else&lt;/td&gt;
&lt;td&gt;0/32 ❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;📖 &lt;em&gt;Deep dive: &lt;a href="https://ofriperetz.dev/articles/benchmark-microsoft-sdl-vs-interlace" rel="noopener noreferrer"&gt;Microsoft SDL vs Interlace: Enterprise Security Benchmark&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  eslint-plugin-no-secrets — Narrow But Precise
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;F1 Score: 9.5%&lt;/strong&gt; | 2 detected, 0 false positives&lt;/p&gt;

&lt;p&gt;Only 2 rules, but they do their job — detecting hardcoded secrets with zero false positives. Good as a supplement, but not a security strategy.&lt;/p&gt;

&lt;h3&gt;
  
  
  eslint-plugin-no-unsanitized (Mozilla) — DOM XSS Only
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;F1 Score: 9.3%&lt;/strong&gt; | 2 detected, 1 false positive&lt;/p&gt;

&lt;p&gt;Detects &lt;code&gt;innerHTML&lt;/code&gt; and &lt;code&gt;insertAdjacentHTML&lt;/code&gt; DOM sinks. Cannot recognize DOMPurify sanitization (1 FP). Useful for browser projects but covers only 2 of 14 categories.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Non-Security Plugins: Confirmed Gaps
&lt;/h2&gt;

&lt;p&gt;These widely-installed plugins are &lt;strong&gt;not security tools&lt;/strong&gt;, confirmed by zero detections:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;Downloads&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Security Detections&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-react&lt;/td&gt;
&lt;td&gt;17M+&lt;/td&gt;
&lt;td&gt;React patterns&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-import&lt;/td&gt;
&lt;td&gt;40M+&lt;/td&gt;
&lt;td&gt;Module resolution&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-promise&lt;/td&gt;
&lt;td&gt;10M+&lt;/td&gt;
&lt;td&gt;Promise patterns&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-jest&lt;/td&gt;
&lt;td&gt;14M+&lt;/td&gt;
&lt;td&gt;Jest testing&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-vue&lt;/td&gt;
&lt;td&gt;7M+&lt;/td&gt;
&lt;td&gt;Vue.js&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@angular-eslint&lt;/td&gt;
&lt;td&gt;2.25M+&lt;/td&gt;
&lt;td&gt;Angular&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-jsx-a11y&lt;/td&gt;
&lt;td&gt;14M+&lt;/td&gt;
&lt;td&gt;Accessibility&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are excellent tools for their intended purpose. But if your security posture relies on them, you have &lt;strong&gt;zero coverage&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Category-by-Category Breakdown
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Interlace&lt;/th&gt;
&lt;th&gt;SonarJS&lt;/th&gt;
&lt;th&gt;MS SDL&lt;/th&gt;
&lt;th&gt;Security&lt;/th&gt;
&lt;th&gt;no-unsanitized&lt;/th&gt;
&lt;th&gt;no-secrets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQL Injection (4)&lt;/td&gt;
&lt;td&gt;✅ 4/4&lt;/td&gt;
&lt;td&gt;⚠️ 2/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Command Injection (4)&lt;/td&gt;
&lt;td&gt;✅ 4/4&lt;/td&gt;
&lt;td&gt;⚠️ 2/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path Traversal (4)&lt;/td&gt;
&lt;td&gt;✅ 4/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardcoded Credentials (4)&lt;/td&gt;
&lt;td&gt;✅ 4/4&lt;/td&gt;
&lt;td&gt;⚠️ 2/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;⚠️ 2/4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JWT (3)&lt;/td&gt;
&lt;td&gt;✅ 3/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XSS / eval (4)&lt;/td&gt;
&lt;td&gt;✅ 4/4&lt;/td&gt;
&lt;td&gt;⚠️ 2/4&lt;/td&gt;
&lt;td&gt;⚠️ 2/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;td&gt;⚠️ 2/4&lt;/td&gt;
&lt;td&gt;❌ 0/4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prototype Pollution (3)&lt;/td&gt;
&lt;td&gt;✅ 3/3&lt;/td&gt;
&lt;td&gt;⚠️ 2/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insecure Random (2)&lt;/td&gt;
&lt;td&gt;✅ 2/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weak Crypto (3)&lt;/td&gt;
&lt;td&gt;✅ 3/3&lt;/td&gt;
&lt;td&gt;⚠️ 2/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;td&gt;❌ 0/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timing Attacks (2)&lt;/td&gt;
&lt;td&gt;✅ 2/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NoSQL Injection (2)&lt;/td&gt;
&lt;td&gt;✅ 2/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSRF (2)&lt;/td&gt;
&lt;td&gt;✅ 2/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open Redirect (1)&lt;/td&gt;
&lt;td&gt;✅ 1/1&lt;/td&gt;
&lt;td&gt;❌ 0/1&lt;/td&gt;
&lt;td&gt;❌ 0/1&lt;/td&gt;
&lt;td&gt;❌ 0/1&lt;/td&gt;
&lt;td&gt;❌ 0/1&lt;/td&gt;
&lt;td&gt;❌ 0/1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReDoS (2)&lt;/td&gt;
&lt;td&gt;✅ 2/2&lt;/td&gt;
&lt;td&gt;⚠️ 1/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;td&gt;❌ 0/2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TOTAL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40/40&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13/40&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2/40&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/40&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2/40&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2/40&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Specialization vs. one-size-fits-all
&lt;/h3&gt;

&lt;p&gt;The reason Interlace achieves 100% coverage is &lt;strong&gt;specialization&lt;/strong&gt;. Instead of one monolithic plugin trying to cover everything, the ecosystem uses 10 purpose-built plugins:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;Focus&lt;/th&gt;
&lt;th&gt;Rules&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eslint-plugin-secure-coding&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Core OWASP patterns&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eslint-plugin-node-security&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fs, child_process, vm, weak crypto, randomness&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eslint-plugin-browser-security&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;XSS, CORS, CSP&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eslint-plugin-pg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SQL injection, connection safety&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eslint-plugin-jwt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Algorithm confusion, token safety&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eslint-plugin-mongodb-security&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NoSQL injection, operator injection&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eslint-plugin-vercel-ai-security&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prompt injection, output validation&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eslint-plugin-lambda-security&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;IAM, cold starts, secrets&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eslint-plugin-express-security&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Helmet, CORS, sessions&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eslint-plugin-nestjs-security&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Guards, pipes, decorators&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;Crypto rules (weak algorithms, insecure randomness) were consolidated into &lt;code&gt;eslint-plugin-node-security&lt;/code&gt; on 2026-05-10. The previously separate &lt;code&gt;eslint-plugin-crypto&lt;/code&gt; package is deprecated and should not be installed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Each plugin is maintained by domain experts and updated independently. A JWT vulnerability doesn't require updating the SQL injection rules.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Means For Your Team
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Math
&lt;/h3&gt;

&lt;p&gt;If your codebase has 100 potentially vulnerable patterns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your Current Stack&lt;/th&gt;
&lt;th&gt;Detected&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Shipped to Production&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-security&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100 vulnerabilities&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-sonarjs&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;65 vulnerabilities&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@microsoft/eslint-plugin-sdl&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90 vulnerabilities&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Interlace Ecosystem&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 vulnerabilities&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The False Positive Tax
&lt;/h3&gt;

&lt;p&gt;False positives create &lt;strong&gt;alert fatigue&lt;/strong&gt; — developers learn to ignore security warnings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;FP Rate&lt;/th&gt;
&lt;th&gt;Developer Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-unicorn&lt;/td&gt;
&lt;td&gt;51.1%&lt;/td&gt;
&lt;td&gt;Every other warning is noise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eslint-plugin-sonarjs&lt;/td&gt;
&lt;td&gt;26.3%&lt;/td&gt;
&lt;td&gt;1 in 4 is noise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Interlace&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Every warning is actionable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The detection gap is about to get much worse
&lt;/h2&gt;

&lt;p&gt;Two years ago, the 40 patterns in this suite entered codebases at human typing speed — one developer, one risky line, occasionally. That constraint is gone. Your team now generates code with an LLM, and the model reproduces these exact patterns at machine speed, with the confidence of well-formatted, type-correct output.&lt;/p&gt;

&lt;p&gt;This isn't speculation; I measured it. In a separate experiment I asked Claude (Haiku through Opus) to write 80 common Node.js functions with no security context — &lt;strong&gt;65–75% shipped with a vulnerability&lt;/strong&gt;, and the rate was statistically consistent across every model size. The categories were the same OWASP families this benchmark scores: string-concatenated SQL, &lt;code&gt;child_process&lt;/code&gt; with shell, unbounded regex, weak crypto. (&lt;a href="https://ofriperetz.dev/articles/i-let-claude-write-60-functions-65-75-had-security-vulnerabilities" rel="noopener noreferrer"&gt;I Let Claude Write 80 Functions&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;The model output is the new attack surface, and it walks straight past the human review that used to be the last line of defense — because it &lt;em&gt;looks&lt;/em&gt; senior. I gave Claude one prompt for a NestJS users service and got 200 lines that TypeScript compiled clean; a specialized linter found &lt;strong&gt;6 security holes in 3 seconds&lt;/strong&gt; (&lt;a href="https://ofriperetz.dev/articles/claude-wrote-nestjs-service-eslint-found-6-security-holes" rel="noopener noreferrer"&gt;the full breakdown&lt;/a&gt;). And asking the model to &lt;em&gt;fix&lt;/em&gt; its own findings without deterministic feedback made it worse: it introduced brand-new vulnerability categories at &lt;strong&gt;4× the rate&lt;/strong&gt; — what I call &lt;a href="https://ofriperetz.dev/articles/the-ai-hydra-problem-fix-one-ai-bug-get-two-more" rel="noopener noreferrer"&gt;the AI Hydra Problem&lt;/a&gt;: cut one head, two grow back.&lt;/p&gt;

&lt;p&gt;The takeaway for this benchmark: a plugin that detects 0% or 35% of these patterns was already a liability. Pointed at AI-generated code that reintroduces the same patterns by the hundred, it's a rubber stamp on a vulnerability factory. The deterministic 100%-recall, 0%-FP layer is what gives the model an objective signal to converge against — and it's the same &lt;code&gt;npm run benchmark:fn-fp&lt;/code&gt; command below, which you can rerun against your own AI's output, not just mine.&lt;/p&gt;

&lt;p&gt;If you've read this far, close the gap in your own repo before you forget — two commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; eslint-plugin-secure-coding eslint-plugin-node-security eslint-plugin-browser-security
npx eslint &lt;span class="nb"&gt;.&lt;/span&gt;   &lt;span class="c"&gt;# against your last AI-generated PR, ideally&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Full flat-config block and the per-domain plugins are in Migrate in 60 Seconds below.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fixture Design
&lt;/h3&gt;

&lt;p&gt;All fixtures are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Realistic:&lt;/strong&gt; Patterns from actual production codebases, not contrived examples&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Annotated:&lt;/strong&gt; Each pattern includes its CWE, expected severity, and detection requirement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproducible:&lt;/strong&gt; Published in the open-source benchmark suite&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Metrics
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Formula&lt;/th&gt;
&lt;th&gt;What It Measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;True Positive (TP)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vulnerability detected&lt;/td&gt;
&lt;td&gt;Correct detection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;False Positive (FP)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Safe code flagged&lt;/td&gt;
&lt;td&gt;Noise / alert fatigue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;False Negative (FN)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vulnerability missed&lt;/td&gt;
&lt;td&gt;Security gap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Precision&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TP / (TP + FP)&lt;/td&gt;
&lt;td&gt;Signal-to-noise ratio&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Recall&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;TP / (TP + FN)&lt;/td&gt;
&lt;td&gt;Coverage completeness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;F1 Score&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2 × (P × R) / (P + R)&lt;/td&gt;
&lt;td&gt;Overall accuracy balance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Reproducibility
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ofri-peretz/eslint-benchmark-suite
&lt;span class="nb"&gt;cd &lt;/span&gt;eslint-benchmark-suite
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run benchmark:fn-fp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every claim in this article can be independently verified.&lt;/p&gt;




&lt;h2&gt;
  
  
  Migrate in 60 Seconds
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; eslint-plugin-secure-coding eslint-plugin-node-security &lt;span class="se"&gt;\&lt;/span&gt;
  eslint-plugin-browser-security &lt;span class="se"&gt;\&lt;/span&gt;
  eslint-plugin-pg eslint-plugin-jwt eslint-plugin-mongodb-security
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// eslint.config.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;secureCoding&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint-plugin-secure-coding&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;nodeSecurity&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint-plugin-node-security&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;browserSecurity&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eslint-plugin-browser-security&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nx"&gt;secureCoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;nodeSecurity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;browserSecurity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run ESLint. See what you've been missing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related deep dives in this series
&lt;/h2&gt;

&lt;p&gt;This article is the ecosystem overview. For the head-to-head per-plugin comparisons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/articles/benchmark-sonarjs-vs-interlace" rel="noopener noreferrer"&gt;SonarJS vs Interlace: 269 Rules Still Miss 65% of Vulnerabilities&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/articles/benchmark-microsoft-sdl-vs-interlace" rel="noopener noreferrer"&gt;Microsoft SDL vs Interlace: Enterprise Security Benchmark&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/articles/eslint-plugin-security-is-unmaintained-heres-what-nobody-tells-you-96h" rel="noopener noreferrer"&gt;eslint-plugin-security Is Unmaintained — Here's What Nobody Tells You&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/articles/eslint-security-fn-fp-benchmark" rel="noopener noreferrer"&gt;The Methodology: How the FN/FP Benchmark Is Built&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And for why this benchmark matters more every quarter — the AI angle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/articles/i-let-claude-write-60-functions-65-75-had-security-vulnerabilities" rel="noopener noreferrer"&gt;I Let Claude Write 80 Functions. 65–75% Had Security Vulnerabilities.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/articles/claude-wrote-nestjs-service-eslint-found-6-security-holes" rel="noopener noreferrer"&gt;Claude Wrote a NestJS Service. ESLint Found 6 Security Holes.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ofriperetz.dev/articles/the-ai-hydra-problem-fix-one-ai-bug-get-two-more" rel="noopener noreferrer"&gt;The AI Hydra Problem: Fix One AI Bug, Get Two More&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Explore the Full Ecosystem
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;201 security rules. 10 specialized plugins. 100% OWASP Top 10 coverage.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Interlace ESLint Ecosystem provides comprehensive security static analysis for modern Node.js applications.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://eslint.interlace.tools" rel="noopener noreferrer"&gt;📖 Documentation&lt;/a&gt; | &lt;a href="https://github.com/ofri-peretz/eslint" rel="noopener noreferrer"&gt;⭐ GitHub&lt;/a&gt; | &lt;a href="https://npmjs.com/~ofriperetz" rel="noopener noreferrer"&gt;📦 NPM&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;Go check one thing right now: what ESLint version is your &lt;code&gt;eslint-plugin-security&lt;/code&gt; running against, and when did you last confirm it actually fires? On ESLint 9 with flat config, there's a real chance the answer is "it's been a green checkmark over nothing for months."&lt;/p&gt;

&lt;p&gt;Then run the benchmark against your own stack — or against your AI assistant's last 40 functions — and tell me in the comments: &lt;strong&gt;what was the gap between what your linter reported and what was actually in the code?&lt;/strong&gt; I want the worst one. The "we had a security linter the whole time" stories are the ones the rest of us learn from.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Build Securely.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm Ofri Peretz, a Security Engineering Leader and the architect of the Interlace Ecosystem. I build static analysis standards that automate security and performance for Node.js fleets at scale.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ofriperetz.dev?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=benchmark-17-plugins" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt; | &lt;a href="https://linkedin.com/in/ofri-peretz" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://github.com/ofri-peretz" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>eslint</category>
      <category>node</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
