<?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%2F6b34d598-2f6e-4b24-89ac-4edb086ffb9b.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>I Linted My Own Design System. All 8 Breaks Were Pasted In.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Thu, 20 Aug 2026 09:23:07 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/i-linted-my-own-design-system-all-8-breaks-were-pasted-in-3hm4</link>
      <guid>https://dev.to/ofri-peretz/i-linted-my-own-design-system-all-8-breaks-were-pasted-in-3hm4</guid>
      <description>&lt;p&gt;Your design system has tokens. Your linter does not know they exist.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;eslint-plugin-react&lt;/code&gt; has 104 rules and not one of them can tell you that &lt;code&gt;#ffffff1f&lt;/code&gt; should have been &lt;code&gt;var(--surface-highlight)&lt;/code&gt;. That gap is invisible in review, because a hex value in a &lt;code&gt;className&lt;/code&gt; looks exactly like every other string.&lt;/p&gt;

&lt;p&gt;So I ran four rules over my own code: 401 &lt;code&gt;.tsx&lt;/code&gt; files across a design system, a docs site, a blog, and a control-room app. &lt;strong&gt;144 violations in 50 files.&lt;/strong&gt; The design system defines &lt;strong&gt;192 tokens&lt;/strong&gt;. It also breaks them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Raw color literals: 30 findings, and they cluster
&lt;/h2&gt;

&lt;p&gt;The rule is &lt;a href="https://ofriperetz.dev/go/r/1hmw4sbvl2w?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;&lt;code&gt;no-raw-color-literal&lt;/code&gt;&lt;/a&gt; — any hex, &lt;code&gt;rgb()&lt;/code&gt;, or &lt;code&gt;rgba()&lt;/code&gt; in JSX source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/ui/src/effects/shimmer-button.tsx:102&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]&lt;/span&gt;&lt;span class="dl"&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 plaintext"&gt;&lt;code&gt;⚠️ Raw color literal in source — use a design token (R19) | MEDIUM
 Fix: Replace with a CSS custom property (var(--your-token)) or a Tailwind
      theme class wired to it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this survives review:&lt;/strong&gt; the hex is inside an arbitrary Tailwind value inside a class string. A reviewer scanning for color changes greps for &lt;code&gt;color&lt;/code&gt; or &lt;code&gt;bg-&lt;/code&gt;. Nobody greps for &lt;code&gt;#&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the part I did not expect. The design system package had &lt;strong&gt;8&lt;/strong&gt; of these, and all 8 were in &lt;code&gt;ambient/&lt;/code&gt;, &lt;code&gt;effects/&lt;/code&gt;, and &lt;code&gt;patterns/&lt;/code&gt; — components I pasted in from component galleries. The core primitives had &lt;strong&gt;zero&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The system didn't drift. It leaked at the seam where other people's code came in.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;data-slot&lt;/code&gt;: 67 findings, the biggest by far
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;⚠️ JSX element has data-testid but no data-slot (R6) | MEDIUM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/r/1qfwkf9odgs?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;&lt;code&gt;require-data-slot&lt;/code&gt;&lt;/a&gt; fires when an element carries a &lt;code&gt;data-testid&lt;/code&gt; but no &lt;code&gt;data-slot&lt;/code&gt;. Both are hooks; only one is a contract. A &lt;code&gt;data-testid&lt;/code&gt; says "a test grabs this." A &lt;code&gt;data-slot&lt;/code&gt; says "consumers may style this." Ship the first without the second and every consumer targets your test IDs, and your next refactor breaks their CSS.&lt;/p&gt;

&lt;p&gt;67 of my 144 findings were this one rule. It is the least dramatic and the most expensive — a &lt;code&gt;data-testid&lt;/code&gt; standing in for a styling contract is a &lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;proxy metric&lt;/a&gt; with a refactor bill attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;A design system fails at its boundaries, not its center. The primitives I wrote while thinking about tokens obey them. The components I imported while thinking about &lt;em&gt;shipping&lt;/em&gt; do not — and they arrive pre-broken, which means no diff ever shows the moment the violation entered.&lt;/p&gt;

&lt;p&gt;That is why this has to be a lint rule and not a review convention. Review catches what a diff shows. Pasted code shows up as one clean green addition.&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;reactFeatures&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-react-features&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="s2"&gt;**/*.tsx&lt;/span&gt;&lt;span class="dl"&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;react-features&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reactFeatures&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;react-features/no-raw-color-literal&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-features/no-arbitrary-token-class&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-features/require-data-slot&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;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="s2"&gt;react-features/no-inline-style&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;warn&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 i &lt;span class="nt"&gt;-D&lt;/span&gt; eslint-plugin-react-features
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/npm/eslint-plugin-react-features?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;&lt;code&gt;eslint-plugin-react-features&lt;/code&gt; on npm&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/1h2bvr8vcub?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;all four rule docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Start the last two at &lt;code&gt;warn&lt;/code&gt;. On a codebase that has never had these rules, &lt;code&gt;require-data-slot&lt;/code&gt; alone will be your largest number, and turning it red on day one just teaches people to disable it.&lt;/p&gt;

&lt;p&gt;An honest caveat: 47 of this plugin's 61 rules overlap &lt;code&gt;eslint-plugin-react&lt;/code&gt; by name. These four do not — nothing in the incumbent enforces a token system, because the incumbent cannot know what your tokens are.&lt;/p&gt;




&lt;p&gt;Source at &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;github.com/ofri-peretz/eslint&lt;/a&gt;. Every number here came from a run on 2026-08-11 against the four repos named above — four repos I own, which is a sample of one opinion about components, not &lt;a href="https://ofriperetz.dev/go/how-to-design-a-ground-truth-corpus?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;a ground-truth corpus&lt;/a&gt;. I ran my own rule over my own code and reported what it found, which is &lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=design-system-token-drift-eslint" rel="noopener noreferrer"&gt;the only version of benchmarking I trust myself to do&lt;/a&gt;. Re-run it on yours and you will get different numbers.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Run one of these on your own components — what's your &lt;code&gt;data-slot&lt;/code&gt; number? Mine was 67, and I wrote the rule.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>eslint</category>
      <category>css</category>
    </item>
    <item>
      <title>Your Token Is Not Safer in a Cookie. It Is Safer From JavaScript.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Fri, 14 Aug 2026 09:43:04 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/your-token-is-not-safer-in-a-cookie-it-is-safer-from-javascript-5e45</link>
      <guid>https://dev.to/ofri-peretz/your-token-is-not-safer-in-a-cookie-it-is-safer-from-javascript-5e45</guid>
      <description>&lt;p&gt;Every team I have worked on has had the localStorage argument. Someone says tokens belong&lt;br&gt;
in cookies. Someone else says cookies mean CSRF. Both cite a blog post. The token stays&lt;br&gt;
where it was.&lt;/p&gt;

&lt;p&gt;The argument is unwinnable as stated, because both sides are describing the container —&lt;br&gt;
and the container is not what decides the outcome.&lt;/p&gt;




&lt;h2&gt;
  
  
  The axis everyone argues about
&lt;/h2&gt;

&lt;p&gt;Ask why one mechanism is safer and you get answers about persistence, capacity, expiry,&lt;br&gt;
whether it survives a tab close. Real differences. None of them has anything to do with an&lt;br&gt;
attacker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The axis that decides it
&lt;/h2&gt;

&lt;p&gt;One question: &lt;strong&gt;can JavaScript running on your origin read it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If yes, anything achieving script execution on your page reads it too. Not encryption —&lt;br&gt;
you have nowhere to put the key. Not obfuscation — the attacker has your bundle. Not "we&lt;br&gt;
only write it after login" — the attacker runs after login too.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;Readable by page JS&lt;/th&gt;
&lt;th&gt;Under XSS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;localStorage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sessionStorage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IndexedDB&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache API&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookie without &lt;code&gt;HttpOnly&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookie with &lt;code&gt;HttpOnly&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;survives&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five of those six rows are the same row. The debate spends its energy on a distinction&lt;br&gt;
that exists only in the last line — and it is not the cookie that creates it, it is the&lt;br&gt;
flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;HttpOnly&lt;/code&gt; is the boundary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;HttpOnly&lt;/code&gt; is unusual: a capability the browser withholds from your own code. You cannot&lt;br&gt;
opt back in at runtime. No API reads it, no devtools trick your bundle can perform. The&lt;br&gt;
value goes out on requests to its domain and is otherwise unreachable.&lt;/p&gt;

&lt;p&gt;Almost nothing else in the browser works this way. Everything in the first five rows is a&lt;br&gt;
filing cabinet in a public lobby — useful, convenient, not a vault, and never described as&lt;br&gt;
one by anyone who built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The corollary people skip
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;HttpOnly&lt;/code&gt; protects the token from being &lt;em&gt;read&lt;/em&gt;. It does not stop it being &lt;em&gt;used&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Script on your origin can still &lt;code&gt;fetch&lt;/code&gt; with &lt;code&gt;credentials: "include"&lt;/code&gt;, and the browser&lt;br&gt;
attaches the cookie. The attacker does not hold your session; they operate it, from your&lt;br&gt;
page, while it is open. Smaller blast radius — no exfiltration, no replay next week — but&lt;br&gt;
not nothing.&lt;/p&gt;

&lt;p&gt;The honest summary: &lt;code&gt;HttpOnly&lt;/code&gt; converts &lt;em&gt;permanent theft&lt;/em&gt; into &lt;em&gt;temporary misuse&lt;/em&gt;. Real&lt;br&gt;
and worthwhile. Not immunity.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what counts as sensitive
&lt;/h2&gt;

&lt;p&gt;Stop asking "is this sensitive" — a word that invites negotiation. Ask: &lt;strong&gt;if an attacker&lt;br&gt;
had this value, what could they do, and for how long?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session token&lt;/strong&gt; — act as the user until expiry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh token&lt;/strong&gt; — mint new sessions, far longer. Higher risk, and the one most often
parked in &lt;code&gt;localStorage&lt;/code&gt; &lt;em&gt;because&lt;/em&gt; it needs to persist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cached API response with personal data&lt;/strong&gt; — disclosure, not takeover. The Cache API is
the mechanism people forget is storage at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature flag, theme, draft&lt;/strong&gt; — nothing. Put it anywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  And the CSRF half?
&lt;/h2&gt;

&lt;p&gt;Never on the same axis either. CSRF is the browser &lt;em&gt;attaching&lt;/em&gt; a cookie to a request the&lt;br&gt;
user did not intend — a property of ambient authority, not storage — and &lt;code&gt;SameSite&lt;/code&gt;&lt;br&gt;
addresses it directly.&lt;/p&gt;

&lt;p&gt;That is why the debate feels unresolvable: one camp describes a read primitive, the other&lt;br&gt;
a send primitive. &lt;code&gt;HttpOnly&lt;/code&gt; answers the first, &lt;code&gt;SameSite&lt;/code&gt; the second — orthogonal flags on&lt;br&gt;
the same cookie.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lands in the standards
&lt;/h2&gt;

&lt;p&gt;Reading storage you should not have access to is&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/ga4vp29bce?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;CWE-522&lt;/a&gt; — insufficiently protected&lt;br&gt;
credentials — under&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/1w8dz7dsugr?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;A07:2021&lt;/a&gt;.&lt;br&gt;
&lt;code&gt;HttpOnly&lt;/code&gt; and &lt;code&gt;Secure&lt;/code&gt; are specified in&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/1ddgz712p2y?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;RFC 6265 §4.1.2.5–6&lt;/a&gt;;&lt;br&gt;
&lt;code&gt;SameSite&lt;/code&gt; arrived later, in&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/12q8n02v2xm?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;RFC 6265bis&lt;/a&gt;, still&lt;br&gt;
a draft despite universal implementation. Both are on&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/12gpdyvdiyp?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;MDN's Set-Cookie page&lt;/a&gt;.&lt;br&gt;
For severity language, use&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/cvss-scores-explained?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;CVSS&lt;/a&gt; and&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/owasp-top-10-explained?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;the OWASP categories&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the argument keeps happening
&lt;/h2&gt;

&lt;p&gt;All five equivalent rows are reached the same way: script execution on your origin. So&lt;br&gt;
storage is &lt;em&gt;downstream&lt;/em&gt; of the XSS question, and a team that has not settled how untrusted&lt;br&gt;
data reaches a DOM sink is arguing about where to put the token while leaving the door&lt;br&gt;
open. &lt;a href="https://ofriperetz.dev/go/dom-sink-taxonomy?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;DOM sinks and sources&lt;/a&gt;, the&lt;br&gt;
companion piece in this series, works through why those sources outnumber the sinks.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What is in your &lt;code&gt;localStorage&lt;/code&gt; right now? Not what you think — open devtools and look. I&lt;br&gt;
have found a refresh token, a full user object with an email, and a cached permissions&lt;br&gt;
array in codebases I would have defended.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I write about measurement and static analysis at&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=client-storage-trust-boundary" rel="noopener noreferrer"&gt;dev.to/ofri-peretz&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>javascript</category>
      <category>node</category>
    </item>
    <item>
      <title>innerHTML Has Five Doors. Most Reviews Only Watch One.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sat, 08 Aug 2026 08:19:02 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/innerhtml-has-five-doors-most-reviews-only-watch-one-587c</link>
      <guid>https://dev.to/ofri-peretz/innerhtml-has-five-doors-most-reviews-only-watch-one-587c</guid>
      <description>&lt;p&gt;I have reviewed the same line of code, written five different ways, and caught it once.&lt;/p&gt;

&lt;p&gt;The line is &lt;code&gt;element.innerHTML = something&lt;/code&gt;. I catch it when &lt;code&gt;something&lt;/code&gt; is obviously a&lt;br&gt;
URL parameter. I miss it when &lt;code&gt;something&lt;/code&gt; arrived four frames ago through a &lt;code&gt;message&lt;/code&gt;&lt;br&gt;
event, got stored, and is written to the DOM by a function in a different file.&lt;/p&gt;

&lt;p&gt;The sink was identical every time. My attention was not.&lt;/p&gt;


&lt;h2&gt;
  
  
  Sinks are few. Sources are many.
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;sink&lt;/strong&gt; is where data becomes execution: &lt;code&gt;innerHTML&lt;/code&gt;, &lt;code&gt;outerHTML&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;insertAdjacentHTML&lt;/code&gt;, &lt;code&gt;document.write&lt;/code&gt;, &lt;code&gt;eval&lt;/code&gt;. You can list them on one hand, which is&lt;br&gt;
why they feel solved. Grep for &lt;code&gt;innerHTML&lt;/code&gt;. Ban it in review. Done.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;source&lt;/strong&gt; is where untrusted data enters. Sources are not listable — they are a&lt;br&gt;
property of your architecture, and they multiply with every feature.&lt;/p&gt;

&lt;p&gt;That asymmetry is the whole problem. The sink is a syntactic fact. The source is a&lt;br&gt;
&lt;em&gt;trust&lt;/em&gt; fact, and trust facts do not survive being passed as a function argument.&lt;/p&gt;
&lt;h2&gt;
  
  
  The five doors
&lt;/h2&gt;

&lt;p&gt;The same sink, reached five ways. Every one is code I would call normal.&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;// 1. Direct — the one everybody catches&lt;/span&gt;
&lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. postMessage — another frame speaks, event.data is a string like any other&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 3. FileReader — the user picked the file, which feels like consent&lt;/span&gt;
&lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;load&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reader&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="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 4. WebSocket — the connection is authenticated, so the payload feels authenticated&lt;/span&gt;
&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertAdjacentHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;beforeend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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. Worker — it is our own worker, running our own code&lt;/span&gt;
&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&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;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&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;Five doors, one room. On the first, a reviewer says "that's user input." On the other&lt;br&gt;
four, they say some version of &lt;em&gt;"but that's ours."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The word "ours" is doing all the damage
&lt;/h2&gt;

&lt;p&gt;Each of those four defences is a claim about &lt;strong&gt;provenance&lt;/strong&gt;, and each is wrong the same way.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;message&lt;/code&gt; event fires for any frame with a handle on your window unless you check&lt;br&gt;
&lt;code&gt;event.origin&lt;/code&gt; — the listener does not filter by default. An authenticated WebSocket says&lt;br&gt;
the &lt;em&gt;connection&lt;/em&gt; is trusted, not what the other end sent, which may have been stored by a&lt;br&gt;
different user hours earlier. A worker is your code, but the data it posts back is&lt;br&gt;
whatever it was given. And a file the user chose is untrusted precisely &lt;em&gt;because&lt;/em&gt; they&lt;br&gt;
chose it.&lt;/p&gt;

&lt;p&gt;The failure is not that developers think &lt;code&gt;innerHTML&lt;/code&gt; is safe. Everyone knows. The failure&lt;br&gt;
is that danger gets assessed at the &lt;em&gt;sink&lt;/em&gt;, where the only visible fact is a string — and&lt;br&gt;
the string looks the same whoever wrote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why generated code lands here
&lt;/h2&gt;

&lt;p&gt;Ask a model to render a preview and it reaches for &lt;code&gt;innerHTML&lt;/code&gt;, because that is the&lt;br&gt;
shortest correct-looking answer to "put this markup on the page" and the training corpus&lt;br&gt;
is fifteen years of tutorials using it.&lt;/p&gt;

&lt;p&gt;More to the point: a model completing a &lt;code&gt;message&lt;/code&gt; handler has the reviewer's problem,&lt;br&gt;
worse. It sees a local scope. It cannot know whether that listener lives in an app that&lt;br&gt;
checks &lt;code&gt;event.origin&lt;/code&gt; elsewhere. The sink is local; the trust decision is not.&lt;/p&gt;

&lt;p&gt;Which is the interesting part — the information needed to get it right is &lt;em&gt;not present at&lt;br&gt;
the point of writing&lt;/em&gt;. A property of the problem, not of the author.&lt;/p&gt;

&lt;h2&gt;
  
  
  The taxonomy is the useful artifact
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Trust claim that fails&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;URL / query params&lt;/td&gt;
&lt;td&gt;none — everyone catches this&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postMessage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"another frame of ours"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket message&lt;/td&gt;
&lt;td&gt;"the connection is authenticated"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;FileReader&lt;/code&gt; result&lt;/td&gt;
&lt;td&gt;"the user chose the file"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker message&lt;/td&gt;
&lt;td&gt;"it is our own worker"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the right column. That is not five vulnerabilities. It is one — &lt;em&gt;provenance assumed&lt;br&gt;
from proximity&lt;/em&gt; — wearing five costumes. Sinks tell you where to look; pairs tell you what&lt;br&gt;
to &lt;strong&gt;ask&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it means for detection
&lt;/h2&gt;

&lt;p&gt;Grepping a sink is a heuristic. Following a source to a sink is taint tracking —&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/taint-vs-heuristic-detection?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;the thing that decides your false-positive rate&lt;/a&gt;.&lt;br&gt;
Full taint tracking across frames and sockets is not something a linter does; those&lt;br&gt;
boundaries are&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/static-analysis-vs-sast-vs-linting?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;the line between linting and SAST&lt;/a&gt;.&lt;br&gt;
What a linter &lt;em&gt;can&lt;/em&gt; do is match source and sink inside one handler — which covers all four&lt;br&gt;
cases, because the risky pattern is nearly always written in a single function.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two branches, not one chain
&lt;/h3&gt;

&lt;p&gt;The XSS &lt;em&gt;outcome&lt;/em&gt; is &lt;a href="https://ofriperetz.dev/go/r/1g3gv4lpdto?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;CWE-79&lt;/a&gt;, under&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/1qpejqok4p?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;CWE-74&lt;/a&gt; Injection. The &lt;em&gt;trust failure&lt;/em&gt; is&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/l9p4nl18o8?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;CWE-345&lt;/a&gt; under&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/o7crbwo4ui?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;CWE-693&lt;/a&gt; — with its child&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/1uu8tu6zd69?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;CWE-346&lt;/a&gt; applying to &lt;code&gt;postMessage&lt;/code&gt;&lt;br&gt;
alone, the only case with a page-side origin check. A WebSocket's &lt;code&gt;Origin&lt;/code&gt; is validated&lt;br&gt;
server-side at the handshake; FileReader and workers have no origin at all.&lt;/p&gt;

&lt;p&gt;Different branches, not one chain. Filing all of it as "XSS" is how the actual defect goes&lt;br&gt;
unrecorded. &lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;The taxonomy piece&lt;/a&gt;&lt;br&gt;
has the map.&lt;/p&gt;

&lt;p&gt;Worth reading in full:&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/16norlpyu9f?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;OWASP's DOM-based XSS Prevention Cheat Sheet&lt;/a&gt;&lt;br&gt;
and &lt;a href="https://ofriperetz.dev/go/r/61fr9k50xh?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;PortSwigger on sources and sinks&lt;/a&gt;.&lt;br&gt;
To stop it being a review question at all,&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/17ux0fezqqz?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;Trusted Types&lt;/a&gt; moves&lt;br&gt;
it to the browser — the only place that sees every assignment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is boring, which is the point
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&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;event&lt;/span&gt;&lt;span class="p"&gt;)&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://trusted.example&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;Check the origin. Use &lt;code&gt;textContent&lt;/code&gt; for text. Sanitize when you genuinely need markup.&lt;br&gt;
None of that is the hard part.&lt;/p&gt;

&lt;p&gt;The hard part is noticing you are in one of the five doorways — because four of them do&lt;br&gt;
not look like doorways. They look like your own code talking to itself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Which door caught you? I am most interested in &lt;code&gt;postMessage&lt;/code&gt; — the origin check is one&lt;br&gt;
line, documented everywhere, and I still find handlers without it in code I wrote myself.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I write about measurement and static analysis at&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=dom-sink-taxonomy" rel="noopener noreferrer"&gt;dev.to/ofri-peretz&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>security</category>
      <category>eslint</category>
    </item>
    <item>
      <title>Nobody Writes Bad Crypto. They Write Correct Crypto at Four Layers.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sat, 01 Aug 2026 09:00:32 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/nobody-writes-bad-crypto-they-write-correct-crypto-at-four-layers-2noa</link>
      <guid>https://dev.to/ofri-peretz/nobody-writes-bad-crypto-they-write-correct-crypto-at-four-layers-2noa</guid>
      <description>&lt;p&gt;The code review comment I have written most often, and regretted most often, is&lt;br&gt;
"use a stronger algorithm."&lt;/p&gt;

&lt;p&gt;It is regrettable because it is almost never the actual defect. It is the defect I can see&lt;br&gt;
from where I am standing. The team swaps SHA-1 for SHA-256, everyone agrees the review was&lt;br&gt;
useful, and the thing that would have mattered stays where it was.&lt;/p&gt;




&lt;h2&gt;
  
  
  Four layers, each hiding the next
&lt;/h2&gt;

&lt;p&gt;A crypto call has to be right four separate times, and being correct at one layer makes&lt;br&gt;
the next one invisible. AES looks like a decision you already made. It is four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 — the primitive.&lt;/strong&gt; Right kind of thing? A hash where you needed a&lt;br&gt;
KDF. A cipher where you needed a MAC. &lt;code&gt;Math.random()&lt;/code&gt; where you needed a CSPRNG. Everyone&lt;br&gt;
reviews this layer, because it is visible in the function name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2 — the mode.&lt;/strong&gt; Primitive right, mode wrong. AES in ECB encrypts&lt;br&gt;
identical blocks to identical ciphertext — which is why the famous penguin is still&lt;br&gt;
legible after "encryption." Nothing about &lt;code&gt;aes-256-ecb&lt;/code&gt; reads as wrong at a glance; it&lt;br&gt;
contains &lt;code&gt;aes&lt;/code&gt; and &lt;code&gt;256&lt;/code&gt;, and both are reassuring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3 — the parameters.&lt;/strong&gt; Right primitive, right mode, wrong inputs. A&lt;br&gt;
static IV. An iteration count set in 2015 and never revisited. RSA where someone passed&lt;br&gt;
&lt;code&gt;RSA_PKCS1_PADDING&lt;/code&gt; explicitly — Node defaults to OAEP, so this one takes a deliberate&lt;br&gt;
argument to get wrong, which is exactly why it survives review. Each is a single value,&lt;br&gt;
usually a constant, and constants do not attract attention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 4 — the usage.&lt;/strong&gt; Everything above correct, and the surrounding code&lt;br&gt;
gives it away. Comparing an HMAC with &lt;code&gt;===&lt;/code&gt; and leaking the answer through timing.&lt;br&gt;
Deriving a key correctly, then logging it. This layer is not crypto code at all, which is&lt;br&gt;
why crypto review misses it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why layers beat a rule list
&lt;/h2&gt;

&lt;p&gt;A rule list tells you what to grep for. The layer tells you where your team is, and&lt;br&gt;
therefore what you are &lt;em&gt;not&lt;/em&gt; going to catch.&lt;/p&gt;

&lt;p&gt;My working hypothesis — and it is a hypothesis, not something I have measured — is that&lt;br&gt;
failures cluster by how much crypto attention a team has already had. Never thought about&lt;br&gt;
it: layer 1. Just audited: layer 2, because the audit said "upgrade the primitives" and it&lt;br&gt;
did exactly that. Careful for years: layers 3 and 4.&lt;/p&gt;

&lt;p&gt;If that holds, the uncomfortable corollary is that layer 3 and 4 defects live longest&lt;br&gt;
&lt;em&gt;because&lt;/em&gt; the code passes every review asking "are we using strong algorithms." The&lt;br&gt;
maturity that fixes the first two layers is the same maturity that stops looking further&lt;br&gt;
down. That is the measurement I most want from this taxonomy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detection: the easy layers are the trap
&lt;/h2&gt;

&lt;p&gt;Layers 1 and 2 live in a single call expression. &lt;code&gt;createHash("sha1")&lt;/code&gt; and &lt;code&gt;aes-256-ecb&lt;/code&gt;&lt;br&gt;
are string literals; matching them is syntactic and any linter does it well.&lt;/p&gt;

&lt;p&gt;Layers 3 and 4 are not. Whether an IV is static depends on where it came from — a&lt;br&gt;
data-flow question, the difference between&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/taint-vs-heuristic-detection?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;matching a pattern and following a value&lt;/a&gt;.&lt;br&gt;
Whether a comparison is timing-unsafe depends on whether the value is a secret, which no&lt;br&gt;
analyzer knows from the expression alone.&lt;/p&gt;

&lt;p&gt;So tool coverage is inversely correlated with how long a defect survives. The layers a&lt;br&gt;
tool covers cleanly are the ones your team already fixes. Read any claim that a tool&lt;br&gt;
"covers crypto" as "covers layers 1 and 2" until shown otherwise — and checking that needs&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/ground-truth-in-security-testing?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;a corpus with known answers&lt;/a&gt;,&lt;br&gt;
not a rule count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lands in the standards
&lt;/h2&gt;

&lt;p&gt;The layers map to distinct CWEs, which is the useful part —&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;the taxonomy&lt;/a&gt; already encodes the&lt;br&gt;
distinction most reviews collapse:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&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;1 — primitive&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://ofriperetz.dev/go/r/1y07lslclcs?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-327&lt;/a&gt; risky algorithm; &lt;a href="https://ofriperetz.dev/go/r/p2oxz36hto?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-338&lt;/a&gt; weak PRNG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 — mode&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://ofriperetz.dev/go/r/1y07lslclcs?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-327&lt;/a&gt; — ECB has no dedicated CWE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 — parameters&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://ofriperetz.dev/go/r/j214mfy26i?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-1204&lt;/a&gt; weak IV &lt;em&gt;generation&lt;/em&gt;; &lt;a href="https://ofriperetz.dev/go/r/13stfzus4ba?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-323&lt;/a&gt; &lt;em&gt;reusing&lt;/em&gt; a nonce or IV; &lt;a href="https://ofriperetz.dev/go/r/2g579rv1srr?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-916&lt;/a&gt; insufficient computational effort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 — usage&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://ofriperetz.dev/go/r/1ppwov1rupo?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;CWE-208&lt;/a&gt; observable timing discrepancy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Filing all four as "weak crypto" is how the layer 3 and 4 instances disappear from your own&lt;br&gt;
bug data, and then from your priorities.&lt;/p&gt;

&lt;p&gt;Prior art worth reading: Egele et al., &lt;em&gt;An Empirical Study of Cryptographic Misuse in&lt;br&gt;
Android Applications&lt;/em&gt; (CCS 2013), which found the overwhelming majority of apps using&lt;br&gt;
crypto APIs got at least one layer wrong. A decade old, different platform, and the&lt;br&gt;
layering has not aged at all.&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/239k7h7sy66?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;NIST SP 800-175B&lt;/a&gt; is the reference for&lt;br&gt;
what each primitive is for, and Bernstein's cache-timing work on AES is the clearest&lt;br&gt;
statement of why layer 4 is not paranoia.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why generated code lands on layers 2 and 3
&lt;/h2&gt;

&lt;p&gt;A model asked for "AES encryption in Node" produces something that runs. Running is the&lt;br&gt;
constraint it optimizes, and every one of these layers is invisible to that constraint.&lt;br&gt;
ECB runs. A static IV runs. An iteration count of 1000 runs. The code is correct at the&lt;br&gt;
layer a reader checks, because it was trained on a corpus where that is what correct&lt;br&gt;
looked like.&lt;/p&gt;

&lt;p&gt;Same reason the human review misses it. The model read the same tutorials you did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Which layer does your codebase fail at? The answer tends to say more about when the code&lt;br&gt;
was written than who wrote it — layer 2 usually means "audited once, years ago."&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I write about measurement and static analysis at&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=crypto-misuse-taxonomy" rel="noopener noreferrer"&gt;dev.to/ofri-peretz&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>node</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I Audited 203 of Our Own ESLint Security Rules. 16% Mislabel Their Own CVSS Score.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Tue, 28 Jul 2026 04:59:46 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score-3hgo</link>
      <guid>https://dev.to/ofri-peretz/i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score-3hgo</guid>
      <description>&lt;p&gt;Run &lt;code&gt;eslint-plugin-node-security&lt;/code&gt; against a Server-Side Request Forgery&lt;br&gt;
pattern and the terminal prints this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/fetch-avatar.ts
  4:7  warning  🔒 CWE-918 CVSS:9.1 | HTTP call whose URL argument name
              suggests user input. | LOW
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CVSS 9.1. Read the number on the calculator and it's nine tenths of a point&lt;br&gt;
from the maximum possible score a vulnerability can get. Read the word next&lt;br&gt;
to it and it says &lt;strong&gt;LOW&lt;/strong&gt;. Those two facts are printed four characters apart&lt;br&gt;
on the same line, by the same rule, and they disagree with each other.&lt;/p&gt;

&lt;p&gt;I write the CWE/CVSS/OWASP metadata that ships in every one of our lint&lt;br&gt;
messages — it's meant to be the thing that tells you, at a glance, how&lt;br&gt;
worried to be. So I went and checked whether it actually does that. I wrote&lt;br&gt;
a 25-line script that reads every rule in the Interlace ESLint ecosystem,&lt;br&gt;
pulls its CVSS score and its shipped severity label, and checks whether the&lt;br&gt;
label matches the official band the score falls in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;33 of 203 rules — 16% — don't.&lt;/strong&gt; Some of those are defensible once you&lt;br&gt;
know why. Some aren't. All of them are a good excuse to explain what CWE,&lt;br&gt;
CVSS, and OWASP actually measure, because the gap between them is exactly&lt;br&gt;
where lint output stops being trustworthy and starts being decoration.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the three labels actually measure
&lt;/h2&gt;

&lt;p&gt;A single line of our lint output packs in three unrelated standards. Quick&lt;br&gt;
definitions — there's a full reference for each if you need it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;CWE&lt;/a&gt;&lt;/strong&gt; names &lt;em&gt;what kind&lt;/em&gt; of bug it is.
&lt;code&gt;CWE-918&lt;/code&gt; is SSRF; &lt;code&gt;CWE-287&lt;/code&gt; is Improper Authentication. A category, not a
verdict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://ofriperetz.dev/go/cvss-scores-explained?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;CVSS&lt;/a&gt;&lt;/strong&gt; is a
&lt;em&gt;computed&lt;/em&gt; 0.0–10.0 score. Its four bands are the ruler this audit uses:
0.1–3.9 Low, 4.0–6.9 Medium, 7.0–8.9 High, 9.0–10.0 Critical. The number is
the primary source; the band name is derived from it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://ofriperetz.dev/go/owasp-top-10-explained?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;OWASP Top 10&lt;/a&gt;&lt;/strong&gt; is a &lt;em&gt;category&lt;/em&gt; bucket
(A01–A10) — an address, not a severity measurement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only CVSS is actually a severity measurement. So when a lint rule prints a&lt;br&gt;
severity word, that word has exactly one legitimate source: the CVSS score&lt;br&gt;
next to it. If they disagree, the word is wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  Case 1: a 9.1 that prints LOW — and the honest reason it might be fine
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;no-ssrf&lt;/code&gt; flags HTTP calls where the URL argument's &lt;em&gt;name&lt;/em&gt; looks&lt;br&gt;
user-supplied (&lt;code&gt;userUrl&lt;/code&gt;, &lt;code&gt;req.query.endpoint&lt;/code&gt;, &lt;code&gt;targetUri&lt;/code&gt;) — a classic&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/r/69k202sr3e?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;Server-Side Request Forgery&lt;/a&gt;&lt;br&gt;
setup, the bug class behind more than one well-known cloud metadata-endpoint&lt;br&gt;
breach:&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 — the *name* suggests user input, not a data-flow trace&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchAvatar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userUrl&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="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userUrl&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 rule's own metadata: &lt;code&gt;cwe: 'CWE-918'&lt;/code&gt;, &lt;code&gt;cvss: 9.1&lt;/code&gt; — squarely in the&lt;br&gt;
Critical band. The message it prints: &lt;code&gt;severity: 'LOW'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the part that's genuinely defensible: the rule's own doc comment&lt;br&gt;
calls itself out as &lt;strong&gt;"a naming heuristic, not data-flow analysis."&lt;/strong&gt; It&lt;br&gt;
matches on identifier names, not on whether the value actually reaches an&lt;br&gt;
attacker-influenced source. That's a real limitation — plenty of&lt;br&gt;
&lt;code&gt;userUrl&lt;/code&gt;-named parameters are perfectly safe, admin-configured constants.&lt;br&gt;
A low-confidence match that could be a&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/confusion-matrix-tp-fp-fn-tn?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;false positive&lt;/a&gt;&lt;br&gt;
&lt;em&gt;does&lt;/em&gt; deserve a lower-urgency label than a rule that traced the actual&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/taint-vs-heuristic-detection?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;taint&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The part that isn't defensible: &lt;strong&gt;confidence and impact are two different&lt;br&gt;
axes, and &lt;code&gt;severity&lt;/code&gt; is only supposed to encode one of them.&lt;/strong&gt; "How sure are&lt;br&gt;
we this is real" and "how bad is it if it's real" are independent&lt;br&gt;
questions — a low-confidence match on a 9.1-impact bug is still worth&lt;br&gt;
&lt;em&gt;more&lt;/em&gt; attention than a high-confidence match on a 3.0. Collapsing both into&lt;br&gt;
one &lt;code&gt;LOW&lt;/code&gt;/&lt;code&gt;MEDIUM&lt;/code&gt;/&lt;code&gt;HIGH&lt;/code&gt;/&lt;code&gt;CRITICAL&lt;/code&gt; string means the reader can't tell&lt;br&gt;
which axis they're looking at, and a &lt;code&gt;grep&lt;/code&gt;-based triage pass (or an&lt;br&gt;
LLM agent doing the same) will deprioritize a Critical-impact finding on the&lt;br&gt;
strength of a label that was only ever describing detection confidence.&lt;/p&gt;
&lt;h2&gt;
  
  
  Case 2: a 9.8 with no heuristic excuse at all
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;require-audience-validation&lt;/code&gt; and &lt;code&gt;require-issuer-validation&lt;/code&gt; aren't naming&lt;br&gt;
heuristics — they check one deterministic fact: does a &lt;code&gt;jwt.verify()&lt;/code&gt; call&lt;br&gt;
pass an &lt;code&gt;audience&lt;/code&gt; (or &lt;code&gt;issuer&lt;/code&gt;) option? Yes or no, no ambiguity:&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 — no audience claim checked&lt;/span&gt;
&lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// not flagged&lt;/span&gt;
&lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com&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;Skip audience validation and a JWT minted for &lt;em&gt;any&lt;/em&gt; service that trusts the&lt;br&gt;
same signing key gets accepted by &lt;em&gt;this&lt;/em&gt; service too — a textbook confused&lt;br&gt;
deputy, &lt;a href="https://ofriperetz.dev/go/r/j2k6t47hqz?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;CWE-287&lt;/a&gt;,&lt;br&gt;
&lt;code&gt;cvss: 9.8&lt;/code&gt;. Same story on the issuer side. Both rules print &lt;code&gt;severity:&lt;br&gt;
'MEDIUM'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's no false-positive story to hide behind here — the check is exact.&lt;br&gt;
9.8 is a hair under the maximum a CVSS score can reach. &lt;code&gt;MEDIUM&lt;/code&gt; sits two&lt;br&gt;
whole bands below where that number lives. If a reader — or a CI gate that&lt;br&gt;
filters on severity string instead of CVSS number — treats &lt;code&gt;MEDIUM&lt;/code&gt; findings&lt;br&gt;
as "fix this sprint," a near-maximum-severity auth bypass just got queued&lt;br&gt;
behind a color contrast warning.&lt;/p&gt;
&lt;h2&gt;
  
  
  Case 3: the one that runs the other way
&lt;/h2&gt;

&lt;p&gt;Not every mismatch under-states. &lt;a href="https://ofriperetz.dev/go/searchpath-hijacking-postgresql-attack?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;&lt;code&gt;no-unsafe-search-path&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
— the PostgreSQL &lt;code&gt;search_path&lt;/code&gt; hijacking rule I wrote up in full elsewhere —&lt;br&gt;
ships &lt;code&gt;cvss: 7.5&lt;/code&gt; (High band) but prints &lt;code&gt;severity: 'CRITICAL'&lt;/code&gt;, one band&lt;br&gt;
&lt;em&gt;above&lt;/em&gt; its own score. I won't re-run the attack walkthrough here — the&lt;br&gt;
short version, if a label disagreeing with a number bothers you as much as&lt;br&gt;
it bothers me: read that piece for the exploit, come back here for why the&lt;br&gt;
label drifted in the first place.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this happens: &lt;code&gt;severity&lt;/code&gt; is the one field nobody enriches
&lt;/h2&gt;

&lt;p&gt;Every rule funnels its message through one formatter,&lt;br&gt;
&lt;code&gt;formatLLMMessage()&lt;/code&gt;, and every formatter call goes through the same&lt;br&gt;
enrichment step first:&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;// packages/eslint-devkit/src/messaging/formatters.ts&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;enrichFromCWE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cwe&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;options&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;cweData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CWE_MAPPING&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cwe&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;cweData&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;options&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;owasp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owasp&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;cweData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owasp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cvss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cvss&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;cweData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cvss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;compliance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;compliance&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;CWE_COMPLIANCE_MAPPING&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cwe&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;There's a canonical lookup table, &lt;code&gt;CWE_MAPPING&lt;/code&gt;, and it's &lt;em&gt;correct&lt;/em&gt; — its&lt;br&gt;
entry for &lt;code&gt;CWE-918&lt;/code&gt; is &lt;code&gt;{ cvss: 9.1, severity: 'CRITICAL' }&lt;/code&gt;, internally&lt;br&gt;
consistent, right band. &lt;code&gt;CWE-287&lt;/code&gt; maps to &lt;code&gt;{ cvss: 9.8, severity:&lt;br&gt;
'CRITICAL' }&lt;/code&gt;. If every rule simply deferred to this table, none of the 33&lt;br&gt;
mismatches would exist.&lt;/p&gt;

&lt;p&gt;But look at what &lt;code&gt;enrichFromCWE&lt;/code&gt; actually forwards: &lt;code&gt;owasp&lt;/code&gt;, &lt;code&gt;cvss&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;compliance&lt;/code&gt; — and that's the whole list. &lt;strong&gt;&lt;code&gt;severity&lt;/code&gt; isn't in it.&lt;/strong&gt; Every&lt;br&gt;
&lt;code&gt;cvss&lt;/code&gt; number in our output either comes from this vetted table or from an&lt;br&gt;
explicit override that a human cross-checked against it. Every &lt;code&gt;severity&lt;/code&gt;&lt;br&gt;
string is whatever word the rule's author typed into that one call site,&lt;br&gt;
with nothing checking it against the table, the CVSS number, or any other&lt;br&gt;
rule's choice for the same CWE. Two rules can share a CWE, share a CVSS&lt;br&gt;
score, and print two different severity words, and nothing in the pipeline&lt;br&gt;
would ever notice.&lt;/p&gt;

&lt;p&gt;That's the actual bug: not "someone mislabeled 33 rules," but "the schema&lt;br&gt;
has a field with no source of truth." Free-text fields drift; that's what&lt;br&gt;
they do when nothing enriches them. The fix is the same one-line pattern&lt;br&gt;
already applied to &lt;code&gt;owasp&lt;/code&gt; and &lt;code&gt;cvss&lt;/code&gt; — default &lt;code&gt;severity&lt;/code&gt; from&lt;br&gt;
&lt;code&gt;CWE_MAPPING[cwe].severity&lt;/code&gt; unless a rule explicitly overrides it, the same&lt;br&gt;
&lt;code&gt;options.severity ?? cweData.severity&lt;/code&gt; fallback the other three fields&lt;br&gt;
already get. I've filed it; it hasn't shipped as of this writing, and I'd&lt;br&gt;
rather say that than quietly imply it's fixed.&lt;/p&gt;
&lt;h2&gt;
  
  
  The audit, if you want to run it yourself
&lt;/h2&gt;

&lt;p&gt;Point this at any rule set that ships CVSS numbers and severity strings —&lt;br&gt;
ours included — and it'll tell you exactly where they disagree:&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;// audit-severity-drift.ts — flags rules where severity doesn't match its own CVSS band&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;readFileSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;globSync&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="s2"&gt;node:fs&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;BANDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;9.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;10.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CRITICAL&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="mf"&gt;7.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;9.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HIGH&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="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;7.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MEDIUM&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="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LOW&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="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&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;bandFor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cvss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;BANDS&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;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cvss&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cvss&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;hi&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="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="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="nf"&gt;globSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;packages/eslint-plugin-*/src/rules/*/index.ts&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf-8&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;cvss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/cvss:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;([\d&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&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;severity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/severity:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*'&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;A-Z&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;'/&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;cvss&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bandFor&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;cvss&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;expected&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: CVSS &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cvss&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="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, shipped &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;severity&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;203 rule files scanned, 33 flagged — an 84% clean rate I'd rather publish&lt;br&gt;
honestly than round up. And that rate is itself a snapshot: the rule sources&lt;br&gt;
as they stood when this published (2026-07-06), spread across the ecosystem's&lt;br&gt;
twenty-odd independently-versioned plugins, not one pinned release. The&lt;br&gt;
article's own thesis applies to its own numbers — a printed audit is a cache&lt;br&gt;
too. Re-run the script against today's checkout and trust that count over&lt;br&gt;
mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a severity label like a security engineer, not a triage bot
&lt;/h2&gt;

&lt;p&gt;None of this is specific to our plugins — it generalizes to any tool that&lt;br&gt;
prints a severity word next to a CVSS number, including the ones you didn't&lt;br&gt;
write:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The number is the primary source. The word is a cache of it, and
caches go stale.&lt;/strong&gt; If a &lt;code&gt;CRITICAL&lt;/code&gt;/&lt;code&gt;HIGH&lt;/code&gt;/&lt;code&gt;MEDIUM&lt;/code&gt;/&lt;code&gt;LOW&lt;/code&gt; string and a
CVSS score disagree, trust the score — it's the one with a formula
behind it, not a human's word choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CWE tells you the shape of the bug, not how loudly to worry.&lt;/strong&gt; "This is
a &lt;code&gt;CWE-918&lt;/code&gt; (SSRF)" is a fact about the code. It says nothing about
&lt;em&gt;this instance's&lt;/em&gt; severity until a CVSS vector is computed for it
specifically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A low-confidence detector and a low-impact bug produce the same label
and mean opposite things.&lt;/strong&gt; If a tool's docs mention "heuristic" anywhere
near a &lt;code&gt;LOW&lt;/code&gt;, ask whether &lt;code&gt;LOW&lt;/code&gt; is describing the bug or the detector's
certainty — they're rarely the same axis, and only one of them tells you
whether to actually go read the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't build a CI gate on the adjective.&lt;/strong&gt; &lt;code&gt;severity !== 'CRITICAL'&lt;/code&gt;
as a merge-blocking filter inherits every mislabel in the tool it's
reading from. Gate on the CVSS number, or on the CWE list you've decided
matters, not on a string a human typed by hand.&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 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 eslint-plugin-jwt
&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="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;jwt&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-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;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;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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both plugins print the CWE/CVSS/OWASP line on every finding — that's what&lt;br&gt;
made this audit possible to run against our own output in the first place.&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/getting-started-eslint-plugin-node-security?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;Getting started with eslint-plugin-node-security&lt;/a&gt;&lt;br&gt;
and &lt;a href="https://ofriperetz.dev/go/getting-started-eslint-plugin-jwt?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;getting started with eslint-plugin-jwt&lt;/a&gt;&lt;br&gt;
cover the rest of what each ships.&lt;/p&gt;




&lt;p&gt;I'm not writing this to talk our own numbers down — 84% of 203 rules&lt;br&gt;
printing a severity word that matches their own CVSS band is a real bar,&lt;br&gt;
and I'd take it over a tool that doesn't print a CVSS number at all and so&lt;br&gt;
never gets caught disagreeing with itself. I'm writing it because "check&lt;br&gt;
the number, not the adjective" is a rule I only started following rigorously&lt;br&gt;
&lt;em&gt;after&lt;/em&gt; writing the script that caught my own tool getting it wrong three&lt;br&gt;
different ways in three different rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your turn:&lt;/strong&gt; open whatever security linter you already run, pick one&lt;br&gt;
finding, and look up its CVSS vector by hand. If the vector notation is new,&lt;br&gt;
&lt;a href="https://ofriperetz.dev/go/cvss-scores-explained?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;what a CVSS score actually measures&lt;/a&gt;&lt;br&gt;
walks the AV/AC/PR/UI fields — and why severity, exploit probability, and&lt;br&gt;
confirmed exploitation are three separate measurements with three separate&lt;br&gt;
owners. Does the severity word next to it still hold up once you've done the&lt;br&gt;
math yourself?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;⭐ Star on GitHub if you've ever trusted a severity label more than the score behind it.&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;I'm &lt;strong&gt;Ofri Peretz&lt;/strong&gt;, a security engineering leader and the author of the&lt;br&gt;
Interlace ESLint ecosystem — domain-specific static analysis for security,&lt;br&gt;
reliability, and performance on the Node.js stack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/1dhos3yly5w?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/lhohj885s7?utm_source=devto&amp;amp;from=i-audited-203-of-our-own-eslint-security-rules-16-mislabel-their-own-cvss-score" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>devsecops</category>
      <category>eslint</category>
    </item>
    <item>
      <title>142,076 Weekly Downloads. Zero Releases Since 2021. Is the Niche Defended?</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:45:01 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/142076-weekly-downloads-zero-releases-since-2021-is-the-niche-defended-4hko</link>
      <guid>https://dev.to/ofri-peretz/142076-weekly-downloads-zero-releases-since-2021-is-the-niche-defended-4hko</guid>
      <description>&lt;p&gt;Two reads from the npm registry, same package, same morning (2026-07-19):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;downloads, last week:  142,076
last publish:          2021-05-21  (v3.2.0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;serverless-iam-roles-per-function&lt;/code&gt; is one of the Serverless Framework ecosystem's most-installed plugins, and it hasn't shipped a release since May 2021. Eleven weeks earlier: 118,761 weekly downloads (measured 2026-05-04). Zero releases in between; 23,315 weekly installs gained.&lt;/p&gt;

&lt;p&gt;The package isn't surviving abandonment — its installed base is growing straight through it.&lt;/p&gt;

&lt;p&gt;My first read was the common one: "142k a week, the niche is taken, I'm five years late." That's the misconception this article exists to correct. &lt;strong&gt;High downloads don't mean a niche is taken. Downloads measure the installed base; maintenance decay measures whether the niche is defended.&lt;/strong&gt; The gap between those signals is where I decide what to build next, using what I call the &lt;strong&gt;incumbent map&lt;/strong&gt;. Fair warning: I ran the arithmetic in May, shipped against its #1 row on 2026-05-07, and only ran the filter that kills that row while fact-checking this article in July. The method works; I ran half of it. Both halves are below, in the order I should have run them.&lt;/p&gt;




&lt;h2&gt;
  
  
  How do you build an incumbent map?
&lt;/h2&gt;

&lt;p&gt;Three signals per package, all public, all re-runnable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Weekly downloads — demand.&lt;/strong&gt; The installed base a maintained replacement would serve. Downloads are a &lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;proxy metric&lt;/a&gt; — npm's own account of download counts includes build servers, mirrors, and robots, so the number tracks lockfile momentum, not humans choosing a package. As a &lt;em&gt;relative&lt;/em&gt; ranking of where demand pools inside one ecosystem, though, it's the best public signal there is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Years since last publish — supply-side decay.&lt;/strong&gt; Read the registry at &lt;code&gt;time[&amp;lt;latest-version&amp;gt;]&lt;/code&gt;. Not &lt;code&gt;time.modified&lt;/code&gt; — that field moves when npm touches registry metadata: &lt;code&gt;serverless-iam-roles-per-function&lt;/code&gt; shows &lt;code&gt;modified: 2022-05-17&lt;/code&gt;, a full year after its last release. Trust the version's own timestamp, nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open-issue decay — unmet demand.&lt;/strong&gt; No clean number here, so make the procedure explicit: sort open issues by reactions, list the recurring asks, date the last maintainer reply. For the plugin above: managed-policy attachment and custom role naming — asked for years across multiple issues, no maintainer commitment (the 2018 managed-policy ask drew "open for PRs"; nothing shipped since). And read the replies, not just titles; mine matter later.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first two signals, as commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.npmjs.org/downloads/point/last-week/&amp;lt;pkg&amp;gt;               &lt;span class="c"&gt;# demand&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://registry.npmjs.org/&amp;lt;pkg&amp;gt; | jq &lt;span class="s1"&gt;'.time[."dist-tags".latest]'&lt;/span&gt;  &lt;span class="c"&gt;# decay&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiply, roughly: demand × decay × unanswered asks, ranked descending. Deliberately not a precise formula — a shortlist generator whose output you still have to judge.&lt;/p&gt;

&lt;p&gt;The closest prior art is &lt;a href="https://ofriperetz.dev/go/r/13873q2ynqh?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;OpenSSF Scorecard&lt;/a&gt;: its &lt;code&gt;Maintained&lt;/code&gt; check formalizes the same "is anyone home?" signal for the dependencies you &lt;em&gt;consume&lt;/em&gt; — a risk lens. The incumbent map points it the other way: not "should I trust this package?" but "should I compete with it?" I haven't seen that build-side version written down anywhere — a strong claim, and an open invitation to correct me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the serverless incumbent map actually show?
&lt;/h2&gt;

&lt;p&gt;The worked example that produced my roadmap. Two zones, all numbers measured 2026-07-19:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defended — active maintainers, do not compete on maintenance:&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;Package&lt;/th&gt;
&lt;th&gt;Downloads/wk&lt;/th&gt;
&lt;th&gt;Last publish&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-offline&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;627,578&lt;/td&gt;
&lt;td&gt;2026-06-23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-esbuild&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;388,619&lt;/td&gt;
&lt;td&gt;2026-05-21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-domain-manager&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;304,882&lt;/td&gt;
&lt;td&gt;2026-07-17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-step-functions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;208,865&lt;/td&gt;
&lt;td&gt;2026-07-13&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Abandoned — demand present, nobody home:&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;Package&lt;/th&gt;
&lt;th&gt;Downloads/wk&lt;/th&gt;
&lt;th&gt;Last publish&lt;/th&gt;
&lt;th&gt;Silence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-iam-roles-per-function&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;142,076&lt;/td&gt;
&lt;td&gt;2021-05-21&lt;/td&gt;
&lt;td&gt;5.2 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-plugin-include-dependencies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;52,500&lt;/td&gt;
&lt;td&gt;2024-07-22&lt;/td&gt;
&lt;td&gt;2.0 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-plugin-common-excludes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;51,415&lt;/td&gt;
&lt;td&gt;2021-07-06&lt;/td&gt;
&lt;td&gt;5.0 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-api-gateway-throttling&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;36,528&lt;/td&gt;
&lt;td&gt;2023-03-19&lt;/td&gt;
&lt;td&gt;3.3 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-plugin-tracing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;35,709&lt;/td&gt;
&lt;td&gt;2017-10-13&lt;/td&gt;
&lt;td&gt;8.8 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-plugin-canary-deployments&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;31,562&lt;/td&gt;
&lt;td&gt;2022-04-11&lt;/td&gt;
&lt;td&gt;4.3 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;serverless-associate-waf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;28,787&lt;/td&gt;
&lt;td&gt;2020-04-17&lt;/td&gt;
&lt;td&gt;6.3 yrs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The top zone shipped within two months. The bottom zone's median silence: five years — on packages still installed tens of thousands of times a week.&lt;/p&gt;

&lt;p&gt;The map moves, in both directions. &lt;code&gt;serverless-associate-waf&lt;/code&gt; shows demand finally decaying — down roughly a quarter from my May reading — so abandonment does reach the download number, on a lag of years, not months. And re-pulling before publishing deleted a row: &lt;code&gt;serverless-openapi-documenter&lt;/code&gt;, filed in my May notes as a stale documentation-plugin niche, shipped a release on 2026-06-24. Someone re-defended it while I drafted. Row gone. An incumbent map is a snapshot, not a subscription — re-run it the week you commit, not the quarter you planned.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is a quiet package finished instead of dead?
&lt;/h2&gt;

&lt;p&gt;The honest counter-case, because the map's arithmetic will lie to you twice.&lt;/p&gt;

&lt;p&gt;First: &lt;strong&gt;stable is not dead.&lt;/strong&gt; &lt;code&gt;serverless-prune-plugin&lt;/code&gt; sits at 414,154 downloads a week (measured 2026-07-19), last publish 2024-10-16. Twenty-one quiet months — and it may simply be done: its job is narrow, its surface barely moves. The separating question: &lt;strong&gt;does the surface underneath the package move?&lt;/strong&gt; IAM — the ground &lt;code&gt;serverless-iam-roles-per-function&lt;/code&gt; stands on — moves constantly: new AWS services, new actions, new policy patterns. Five silent years on a moving surface is decay; on a frozen surface, it can be completion. Same silence, opposite meanings — a classic &lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;measurement-bias&lt;/a&gt; trap.&lt;/p&gt;

&lt;p&gt;Second: &lt;strong&gt;platform absorption.&lt;/strong&gt; &lt;code&gt;serverless-plugin-typescript&lt;/code&gt;: 151,586 downloads a week (measured 2026-07-19), dormant since 2023-06-05 — by raw arithmetic the juiciest target on the board. It's not on my list: Serverless Framework v4 builds TypeScript natively and won't run build plugins like it unless you explicitly opt out of the native build. That niche isn't undefended; it's dissolving. A replacement would be competing with the platform itself.&lt;/p&gt;

&lt;p&gt;An installed base looks like a moat. The publish log says whether anyone still defends it; the platform roadmap, whether the castle is even staying put.&lt;/p&gt;

&lt;h2&gt;
  
  
  How were the replacement targets actually picked?
&lt;/h2&gt;

&lt;p&gt;The full pipeline, as actually run for the serverless line:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rank&lt;/strong&gt; every community plugin by demand × decay (the tables above are the output).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter: is the niche still real?&lt;/strong&gt; — &lt;code&gt;serverless-plugin-typescript&lt;/code&gt; fell here first. Then the same filter, run against the current v4 docs instead of my stale notes, reached the top row of my map: v4 ships per-function IAM roles natively — provider-statement inheritance on by default, log/VPC/event-source permissions auto-added, the plugin's own config fields accepted for migration. The &lt;a href="https://ofriperetz.dev/go/r/123glrpu8vs?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;official IAM guide&lt;/a&gt; credits the plugin for pioneering the feature and says you can now safely remove it. My #1 target wasn't an undefended niche; it was one the platform had absorbed — with honors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter: is the incumbent truly undefended?&lt;/strong&gt; — re-pull the registry the week you commit; that check removed &lt;code&gt;serverless-openapi-documenter&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter: is the unmet demand documented in public — and still unmet?&lt;/strong&gt; The newest open issue asks "Serverless Framework V4 Support?" — and the thread answers itself: a collaborator's "it works," then a comment pointing at the v4 docs above. The two features its tracker spent years asking for — managed policies, custom role names — shipped in v4's native implementation, not the plugin's. The counter-evidence to my top pick sat in the tracker I was reading for demand. Read the replies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The worked example deleted two of its own rows — one while I drafted, one while I fact-checked. Here is what the second deletion cost, stated plainly: my May map scored &lt;code&gt;serverless-iam-roles-per-function&lt;/code&gt; 23/25 — "the biggest single-plugin opportunity in the portfolio" — and I shipped &lt;code&gt;@interlace/serverless-iam-roles-per-function&lt;/code&gt; 1.0.0 off that row on 2026-05-07 (the npm publish date; check it). The filter you just read ran two months later. The package stays published and does what it says; what it is not, anymore, is the line's growth bet — v4 owns that niche now. The ranked ROI analysis lives in the repo, unchanged, dates included — deliberately, as the before-artifact: you can see exactly the map I built from, and exactly what running filter 2 late overturned. Arithmetic generates the shortlist; filters, run against primary sources — &lt;em&gt;before you build&lt;/em&gt; — make the decision.&lt;/p&gt;

&lt;p&gt;Notice the conflict of interest: the person selling replacements is grading the incumbents — the same conflict as &lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;building what I benchmark&lt;/a&gt;, with the same mitigation: every number here is a public registry read, stamped with its measurement date, re-runnable in one &lt;code&gt;curl&lt;/code&gt; without me in the loop.&lt;/p&gt;

&lt;p&gt;The map is step one of entering an ecosystem. Step two keeps the building honest — write down claims and evidence &lt;em&gt;before&lt;/em&gt; the marketing exists: &lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;the claims registry&lt;/a&gt;. Different &lt;a href="https://ofriperetz.dev/go/different-metrics-for-different-package-types?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;package types need different metrics&lt;/a&gt; once you measure what you built — but the map is how you decide to build at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's the most-downloaded abandoned package in your ecosystem — React components? Terraform providers? Gradle plugins? Two registry reads will tell you. I'm collecting incumbent maps, and I'd genuinely like to see yours.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;What it measures&lt;/th&gt;
&lt;th&gt;Where to read it&lt;/th&gt;
&lt;th&gt;Failure mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weekly downloads&lt;/td&gt;
&lt;td&gt;Installed base (demand) — not adoption, not health&lt;/td&gt;
&lt;td&gt;&lt;code&gt;api.npmjs.org/downloads/point/last-week/&amp;lt;pkg&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mistaking base for defense; CI/mirror inflation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Years since last publish&lt;/td&gt;
&lt;td&gt;Supply-side decay&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;registry.npmjs.org/&amp;lt;pkg&amp;gt;&lt;/code&gt; → &lt;code&gt;time[&amp;lt;latest-version&amp;gt;]&lt;/code&gt; — &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;time.modified&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Finished ≠ dead on a frozen surface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open-issue decay&lt;/td&gt;
&lt;td&gt;Unmet demand, stated in public&lt;/td&gt;
&lt;td&gt;Issue tracker: sort by reactions, date the last maintainer reply&lt;/td&gt;
&lt;td&gt;Reading titles, not replies — the answer may be "the platform shipped it"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deprecation/archive status + platform roadmap&lt;/td&gt;
&lt;td&gt;Whether the niche still exists&lt;/td&gt;
&lt;td&gt;Registry &lt;code&gt;deprecated&lt;/code&gt; field, repo header, framework changelog + docs&lt;/td&gt;
&lt;td&gt;High downloads on a dissolving niche&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decision rule&lt;/td&gt;
&lt;td&gt;demand × decay × unanswered asks → shortlist → 3 filters&lt;/td&gt;
&lt;td&gt;This article&lt;/td&gt;
&lt;td&gt;It's a snapshot — re-run the week you commit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/8e2ficgx9r?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;"numeric precision matters: how npm download counts work"&lt;/a&gt; (npm blog, 2014) — npm's own account of what a download includes (build servers, mirrors, robots); the primary source for why downloads can only ever be a demand proxy.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/2c14snsbept?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;npm registry download-counts documentation&lt;/a&gt; — the API the map reads: the endpoints and the data pipeline behind the counts.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/123glrpu8vs?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;Serverless Framework IAM guide&lt;/a&gt; — the platform-absorption primary source: v4's native per-function roles, statement inheritance, auto-added permissions, and the credit to the community plugin it replaces.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/13873q2ynqh?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;OpenSSF Scorecard&lt;/a&gt; — the closest prior art: its &lt;code&gt;Maintained&lt;/code&gt; check operationalizes "is anyone home?" for consumption-side dependency risk; the incumbent map reuses that signal for build-side decisions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related deep dives
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;Proxy Metrics: When the Number Isn't the Thing&lt;/a&gt; — why downloads, stars, and every other convenient number measure something adjacent to what you care about&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;Bias in Measurement&lt;/a&gt; — how a real number still produces a wrong inference, including the too-clean-zero forensic&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;The Claims Registry&lt;/a&gt; — step two of the playbook: the evidence discipline that comes before any claim&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/different-metrics-for-different-package-types?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;Different Metrics for Different Package Types&lt;/a&gt; — once you build, how the measuring has to change per package type&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;I Built What I Benchmark. Here's How I Try Not to Cheat.&lt;/a&gt; — the conflict-of-interest process this article's mitigation borrows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/serverless" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Star the serverless repo — the map above is its roadmap&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;Interlace ESLint ecosystem&lt;/a&gt;. Source on &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=abandoned-incumbent-map" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>serverless</category>
      <category>aws</category>
      <category>opensource</category>
      <category>node</category>
    </item>
    <item>
      <title>We Were Wrong About sls remove</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:44:23 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/we-were-wrong-about-sls-remove-keo</link>
      <guid>https://dev.to/ofri-peretz/we-were-wrong-about-sls-remove-keo</guid>
      <description>&lt;p&gt;The loudest claim in my serverless line was wrong. Early copy for &lt;code&gt;@interlace/serverless-api-gateway-caching&lt;/code&gt; implied that the community caching plugin leaves an API Gateway cache cluster running — and billing — after &lt;code&gt;sls remove&lt;/code&gt;, and that our cleanup hook fixes it. On 2026-05-04, our own E2E harness ran the community plugin through &lt;code&gt;sls remove&lt;/code&gt; on live AWS: 31 seconds, exit 0, no orphans. CloudFormation deleted the stage and took the cache cluster with it. There is no &lt;code&gt;sls remove&lt;/code&gt; ghost-billing bug.&lt;/p&gt;

&lt;p&gt;The trap itself is real — I had reproduced it on live AWS the day before, 2026-05-03, in about ten minutes for roughly $0.005 of cache-cluster time. It just lives on a different path than the one we were loudest about. This article is the full telling of that re-scoping: the original claim, the two runs that killed and rebuilt it, and why the narrower claim that replaced it does more work than the broad one ever did.&lt;/p&gt;




&lt;h2&gt;
  
  
  What was the original claim?
&lt;/h2&gt;

&lt;p&gt;The community &lt;code&gt;serverless-api-gateway-caching&lt;/code&gt; (v1.11.0) manages the API Gateway cache cluster imperatively — it calls the AWS &lt;code&gt;UpdateStage&lt;/code&gt; API during deploy instead of declaring the cluster in the CloudFormation template. CloudFormation never learns the cluster exists. From there, the inference writes itself: if CloudFormation doesn't know about the cluster, teardown won't remove it, so &lt;code&gt;sls remove&lt;/code&gt; must leave it behind, billing forever. Our plugin ships a &lt;code&gt;before:remove:remove&lt;/code&gt; hook that disables the cluster before teardown — so early copy implied, in effect: &lt;em&gt;the community plugin has a ghost-billing bug on &lt;code&gt;sls remove&lt;/code&gt;, and we fix it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Notice what that claim actually was: an inference from architecture, shipped as if it were a measurement. And it was consistent — repeated the same way across the README, the docs, the comparison table. Consistency is reliability; whether the claim describes the failure that actually happens is &lt;a href="https://ofriperetz.dev/go/valid-vs-reliable-metrics?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;validity&lt;/a&gt; — and nobody had checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did the measurement actually show?
&lt;/h2&gt;

&lt;p&gt;Two runs on live AWS, one day apart, both against the pinned community release &lt;code&gt;1.11.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2026-05-03 — the trap, reproduced.&lt;/strong&gt; Deploy with the community plugin (a live stack in 49s), wait for the cache cluster to reach &lt;code&gt;AVAILABLE&lt;/code&gt; (4–7 minutes), then do what a real team does when it stops wanting a plugin: delete it from the &lt;code&gt;plugins&lt;/code&gt; array and redeploy the still-running service. The redeploy succeeds in ~28 seconds and &lt;strong&gt;no plugin code runs&lt;/strong&gt; — the plugin is gone from the config, so it can't. The cluster afterward: &lt;code&gt;enabled: true, status: AVAILABLE, size: 0.5&lt;/code&gt;. Still running, still billing. No CloudFormation event, no log line, no warning. And no exit through the plugin either: &lt;code&gt;sls caching disable&lt;/code&gt; and &lt;code&gt;sls caching status&lt;/code&gt; both return "command not found" — the community plugin registers no custom commands, so the only escape is dropping to the AWS CLI and patching &lt;code&gt;cacheClusterEnabled&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt; by hand. Total cost of watching all of this happen: ~10 minutes, ~$0.005 — the recipe is public in &lt;a href="https://ofriperetz.dev/go/r/11jasy64k1j?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;&lt;code&gt;docs/ghost-billing-reproduction.md&lt;/code&gt;&lt;/a&gt;, cheap enough that &lt;a href="https://ofriperetz.dev/go/reproducibility-vs-replicability?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;"it reproduced on my machine"&lt;/a&gt; can be your finding rather than my assurance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2026-05-04 — the loud path, refuted.&lt;/strong&gt; The community E2E then ran the exact path our copy warned about: &lt;code&gt;sls remove&lt;/code&gt; with the plugin still installed. Clean. 31 seconds, exit 0, no orphans — CloudFormation deleted the stage, and the cluster went with it.&lt;/p&gt;

&lt;p&gt;The mechanism fits in one sentence: the cache cluster rides on the stage. Delete the stage — which is what &lt;code&gt;sls remove&lt;/code&gt; does — and the cluster dies with the stack, no matter who created it. Keep the service deployed and remove only the plugin, and the stage stays up while the one tool that remembered the cluster leaves your toolchain.&lt;/p&gt;

&lt;p&gt;I ran that second E2E to collect the evidence &lt;em&gt;for&lt;/em&gt; the claim. It came back with the evidence against it — exit 0, no orphans, and a rewrite of my best line of copy. The same discipline that makes you distrust a &lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;result that flatters you&lt;/a&gt; is the one that makes you run the test that can defeat you, before someone else does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the re-scoped claim?
&lt;/h2&gt;

&lt;p&gt;The old copy didn't get quietly patched; it got re-scoped, on the record. Our &lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;claims registry&lt;/a&gt; keeps a dedicated table for exactly this case — original copy, what we measured, what's true — and the row for this incident reads, condensed: earlier copy implying "we fix &lt;code&gt;sls remove&lt;/code&gt;'s ghost-billing bug" was wrong, because there is no &lt;code&gt;sls remove&lt;/code&gt; ghost-billing bug. The orphaned-cluster trap lives on the &lt;strong&gt;uninstall-while-keeping-service&lt;/strong&gt; path, where &lt;code&gt;sls caching disable&lt;/code&gt; — an explicit offboarding command the community plugin doesn't have — is the actual fix. The &lt;code&gt;before:remove:remove&lt;/code&gt; hook stays, verified live (stack deleted in 28s, zero residuals, measured 2026-05-03), but it's defense-in-depth now, not the headline.&lt;/p&gt;

&lt;p&gt;That's the whole lifecycle in one incident: claim → reproduction attempt → refutation → re-scope → versioned evidence file. The refuting run is committed as a dated JSON next to the claim row; the trap ships as a recipe anyone can replay.&lt;/p&gt;

&lt;p&gt;The stakes are why the claim deserved this much care. The cluster we reproduced was the smallest tier — 0.5 GB at $0.020/hour, $175.20/year. AWS's published cache pricing runs up to 237 GB at $33,288/year, and the charge is per stage, per region, with no flag on orphaned clusters. A number that size deserves a claim pointed at the path where the money actually leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is a narrower claim stronger?
&lt;/h2&gt;

&lt;p&gt;Value investors call it margin of safety: you don't pay the price your own estimate says is fair — you pay less, so that being somewhat wrong doesn't ruin you. A claim scoped to exactly what the run files show carries the same margin. The broad claim had none: it rode on an untested inference, and a single ten-minute E2E from anyone could falsify it. Ours did, mercifully before anyone else's.&lt;/p&gt;

&lt;p&gt;The narrow claim is also more useful to you. "Run &lt;code&gt;sls caching disable&lt;/code&gt; before you remove the plugin from config" is an action. "Beware &lt;code&gt;sls remove&lt;/code&gt;" would have taught you to distrust the one command that actually cleans up after itself — misallocating your caution away from the path that costs money and onto the path that doesn't.&lt;/p&gt;

&lt;p&gt;Which is the misconception worth naming: &lt;strong&gt;"walking back a claim costs credibility."&lt;/strong&gt; It's backwards. Unmeasured claims cost credibility — on a schedule you don't control. A published re-scope is the cheapest credibility purchase available: it costs one uncomfortable table row and buys back the right to be believed about everything the run files do support. The genre has old prior art — Buffett's shareholder letters have aired his own mistakes for decades, and it's the same model behind &lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;how I try not to cheat in my own benchmarks&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I'll admit the first instinct, reading the clean run, was the other genre: quietly delete the sentence and move on. That instinct — fix silently, stay loud — is exactly what a claims registry exists to make impossible, and the reason this article exists at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer (measured 2026-05-03/04, community plugin v1.11.0)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Does &lt;code&gt;sls remove&lt;/code&gt; orphan the community plugin's cache cluster?&lt;/td&gt;
&lt;td&gt;No — CloudFormation deletes the stage and the cluster with it (31s, exit 0, no orphans)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where is the actual trap?&lt;/td&gt;
&lt;td&gt;Remove the plugin from &lt;code&gt;plugins&lt;/code&gt; and redeploy the still-running service — no plugin code runs; cluster persists &lt;code&gt;enabled: true, AVAILABLE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is there a plugin-mediated escape (community v1.11.0)?&lt;/td&gt;
&lt;td&gt;No — &lt;code&gt;sls caching disable&lt;/code&gt; / &lt;code&gt;sls caching status&lt;/code&gt; return "command not found"; manual &lt;code&gt;aws apigateway update-stage&lt;/code&gt; required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What does an orphaned cluster cost?&lt;/td&gt;
&lt;td&gt;$175.20/yr at 0.5 GB up to $33,288/yr at 237 GB — per stage, per region, unflagged by AWS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The safe offboarding path (&lt;code&gt;@interlace/serverless-api-gateway-caching&lt;/code&gt;)?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sls caching disable&lt;/code&gt; before uninstalling; &lt;code&gt;before:remove:remove&lt;/code&gt; covers &lt;code&gt;sls remove&lt;/code&gt; (verified live: stack deleted in 28s, zero residuals)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reproduce it yourself&lt;/td&gt;
&lt;td&gt;~10 minutes, ~$0.005 — &lt;a href="https://ofriperetz.dev/go/r/11jasy64k1j?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;ghost-billing-reproduction.md&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;The claim that survived is smaller than the one we started with, and it's the only one of the two I'd defend in public — because defending it requires no rhetoric, just the run files. If a claim in our docs matters to you, check whether it has a row in &lt;a href="https://ofriperetz.dev/go/r/zwh1e23qps?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;CLAIMS.md&lt;/a&gt;; if it doesn't, it isn't allowed to ship. That policy — and the re-scope you just read, kept on the record instead of buried — is the honest answer to "why should I trust a plugin author's comparison table."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/serverless" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star the serverless repo — where claims ship with run files, or don't ship&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;Next in this arc: one step earlier in the story. Before you can re-scope a claim about an incumbent plugin, you have to decide which incumbent is worth challenging at all — &lt;a href="https://ofriperetz.dev/go/abandoned-incumbent-map?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;the abandoned-incumbent map&lt;/a&gt; is how the serverless line made that call.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/11ld6bhww47?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Amazon API Gateway pricing — cache pricing&lt;/a&gt; — the per-size price table behind every $/hour and $/year figure in this article.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/9yycsabb48?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Cache settings for REST APIs in API Gateway — Amazon API Gateway Developer Guide&lt;/a&gt; — the stage-level cache settings (&lt;code&gt;cacheClusterEnabled&lt;/code&gt;, cluster size) that make the cluster live and die with its stage.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Bias in Measurement&lt;/a&gt; — why a result that flatters you is the one to re-run&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/reproducibility-vs-replicability?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Reproducibility vs Replicability&lt;/a&gt; — the bar the $0.005 recipe is built to clear&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/valid-vs-reliable-metrics?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Valid vs Reliable Metrics&lt;/a&gt; — how a claim can be perfectly consistent and still measure nothing&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;The Claims Registry&lt;/a&gt; — the table this article's re-scoped row lives in&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;I Built What I Benchmark. Here's How I Try Not to Cheat.&lt;/a&gt; — the same discipline applied to the ESLint benchmarks&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Interlace ESLint ecosystem&lt;/a&gt;. Source on &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=we-were-wrong-about-sls-remove" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>serverless</category>
      <category>aws</category>
      <category>devops</category>
      <category>node</category>
    </item>
    <item>
      <title>25% of My Benchmark Verdict Is an Opinion. Here's the Anatomy.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:43:35 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/25-of-my-benchmark-verdict-is-an-opinion-heres-the-anatomy-294d</link>
      <guid>https://dev.to/ofri-peretz/25-of-my-benchmark-verdict-is-an-opinion-heres-the-anatomy-294d</guid>
      <description>&lt;p&gt;On 2026-05-04, my benchmark harness printed its verdict on two Serverless Framework caching plugins: mine scored &lt;strong&gt;0.88&lt;/strong&gt;; the community incumbent, &lt;strong&gt;0.3025&lt;/strong&gt;. Every digit of that gap is arithmetic — seven dimensions, each one measured, normalized, weighted, summed. The raw JSON actually stores my side as &lt;code&gt;0.8800000000000001&lt;/code&gt;, kept as-is: IEEE 754's reminder that a number can be very precise about being an opinion. Because the most consequential input in that file was never measured by any instrument: &lt;strong&gt;Lifecycle Correctness is worth 25% of the score because I said so.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A composite score is opinions wearing a number costume — the general argument, and the honesty requirements any composite owes its readers, live in &lt;a href="https://ofriperetz.dev/go/composite-scores-and-weighting?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Composite Scores and Weighting&lt;/a&gt;, and I won't re-derive them here. What I owe you instead is the costume's pattern, dimension by dimension — including whether the verdict survives when you attack the weights.&lt;/p&gt;




&lt;h2&gt;
  
  
  What are the seven dimensions — and where does each number come from?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Weight&lt;/th&gt;
&lt;th&gt;Interlace&lt;/th&gt;
&lt;th&gt;Community&lt;/th&gt;
&lt;th&gt;Measured from&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle Correctness&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;local source + live AWS deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLI Surface&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;local source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript Coverage&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;npm registry*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance Signal&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;npm registry*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle Weight&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;npm registry*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hook Coverage&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;0.375&lt;/td&gt;
&lt;td&gt;local source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documentation Quality&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;td&gt;0.4&lt;/td&gt;
&lt;td&gt;README scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Composite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Σ=1.0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.88&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.3025&lt;/strong&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;&lt;em&gt;* Community side only. The Interlace values in these rows came from local source (&lt;code&gt;"source": "local"&lt;/code&gt; in the result file) — version 0.0.0 has never been published.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Run stamped 2026-05-04, Node v24.12.0 on darwin/arm64. Two disclosures before anything else. First: my &lt;code&gt;@interlace/serverless-api-gateway-caching&lt;/code&gt; was measured from local source at version 0.0.0, while the community's &lt;code&gt;serverless-api-gateway-caching&lt;/code&gt; is the published 1.11.0 from the registry. That asymmetry makes one score in the table an artifact, not a measurement: my Maintenance Signal 1.0 rests on &lt;code&gt;daysSincePublish: 0, totalVersions: null, weeklyDownloads: 0&lt;/code&gt; — an unpublished plugin scores perfect maintenance while the community's real 379-day publish gap scores 0. Read that row as &lt;em&gt;unmeasured&lt;/em&gt;, not &lt;em&gt;excellent&lt;/em&gt;. Second: the lifecycle numbers are not simulations. Both plugins went through a live-AWS end-to-end lifecycle run, and the two E2E result files sit on disk stamped 43 minutes apart from the same night.&lt;/p&gt;

&lt;p&gt;The "measured from" column matters as much as the scores. Registry-sourced dimensions are &lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;proxy metrics&lt;/a&gt;: publish cadence stands in for maintenance, type definitions stand in for TypeScript support. And a README scan is &lt;a href="https://ofriperetz.dev/go/valid-vs-reliable-metrics?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;reliable without being automatically valid&lt;/a&gt; — it returns the same 0.8 every run; whether 0.8 captures what "good documentation" means to you, the harness cannot answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do kilobytes, hook counts, and pass rates become one scale?
&lt;/h2&gt;

&lt;p&gt;The seven dimensions arrive in incompatible units: bundle size in kilobytes, hook coverage as a count of lifecycle hooks handled, lifecycle correctness as pass/fail steps against a live deploy, documentation as a scan score. A weighted sum over raw units lets the biggest-range unit silently win — the classic &lt;a href="https://ofriperetz.dev/go/composite-scores-and-weighting?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;mixing-scales trap&lt;/a&gt;. So every dimension is normalized to 0–1 before any weight touches it.&lt;/p&gt;

&lt;p&gt;The community plugin's Hook Coverage of 0.375 is not a grade someone typed in — it's 3 hooks counted against a ceiling of 8, and that ceiling is my own plugin's full hook-and-command surface, which guarantees my side a 1.0. That denominator is a normalization policy that happens to flatter its author, and it deserves the same suspicion as the bundle curve coming next. On Bundle Weight, my 78 KB unpacked against the community's 47 KB came out 0 against 1 — which tells you the normalization curve is itself a policy decision. A gentler curve would have given my 78 KB partial credit; this one didn't, and I left it that way. Normalization choices change scores without changing a single measurement, so they belong in the published methodology, not in a footnote you have to diff the source to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when a dimension can't be measured?
&lt;/h2&gt;

&lt;p&gt;The harness has a standing rule for dimensions it cannot measure — historically the AWS-dependent ones, when there's no live deploy to run against: score them &lt;strong&gt;null&lt;/strong&gt; and never impute a value. An imputed dimension is an invented measurement wearing the same costume as the real ones. But the null path deserves the least flattering disclosure in this article: when nulls are present, &lt;code&gt;compositeScore&lt;/code&gt; renormalizes over the measured subset — null dimensions drop out of both numerator and denominator — so a partial run still prints one clean-looking composite. Renormalizing isn't imputation, but it quietly moves weight onto whatever happened to be measurable. The day before this run did exactly that: three of seven dimensions measured, and the harness stored &lt;code&gt;0.7499999999999999&lt;/code&gt; vs 0.25 — IEEE 754 again, equally precise about a composite built from 40% of the total weight, with nothing in the numbers admitting four dimensions were missing. The &lt;em&gt;n&lt;/em&gt;-of-7 label that makes a partial composite honest currently lives one layer up, in &lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;the claims registry&lt;/a&gt; rather than the harness output — a real gap: "3-of-7, here's what's missing" should be printed by the code, not patched on at the claims layer. In this run the null path went unused — &lt;code&gt;dimensionScores&lt;/code&gt; holds numeric values for all seven keys, on both plugins (don't cite the &lt;code&gt;skipped&lt;/code&gt; array for this; it was empty in the partial run too) — and I'm telling you anyway, because a policy you only hear about when it's exercised is a policy you can't audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is Lifecycle Correctness worth 25% — and who decided?
&lt;/h2&gt;

&lt;p&gt;I decided. Here's the reasoning, in tiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;25% — the damage tier.&lt;/strong&gt; Lifecycle Correctness is the only dimension where failure means the plugin actively harms your deployment instead of merely annoying you. A caching plugin that mishandles the deploy lifecycle doesn't degrade politely — it leaves your API Gateway stage in a state you didn't ask for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;15% × 3 — the daily-use tier.&lt;/strong&gt; CLI Surface, TypeScript Coverage, Maintenance Signal: the things you touch or depend on every working day. Individually smaller than correctness, collectively 45% of the score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10% × 3 — the comfort tier.&lt;/strong&gt; Bundle Weight, Hook Coverage, Documentation Quality: real costs you can see coming and route around.&lt;/p&gt;

&lt;p&gt;Reasonable people could defend a different split — which is the point. The common misconception is that &lt;strong&gt;a benchmark score computed from real measurements is objective.&lt;/strong&gt; The measurements are objective; the weights they run through are editorial, and computed is not neutral. Nor is admitting that unusual: Lighthouse versions its scoring weights and OpenSSF Scorecard publishes its full methodology — the composite-scores canonical walks both. Same page, new Lighthouse version, different score — which means every performance score you've ever pasted into a report was a committee vote you didn't attend. Every index has a committee. The only real question is whether the committee publishes its votes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the winner survive a ±50% attack on the weights?
&lt;/h2&gt;

&lt;p&gt;If a ranking flips when the weights shift, the composite is telling you about the committee, not the plugins. So attack mine as hard as ±50% allows. Halve every weight where my plugin leads: Lifecycle to 12.5%, CLI, TypeScript, and Maintenance to 7.5% each, Hooks and Docs to 5% each. Raise the one dimension where my plugin &lt;em&gt;loses&lt;/em&gt; — Bundle Weight — by half, to 15%. Renormalize so the weights sum to 1.0 again.&lt;/p&gt;

&lt;p&gt;The composite becomes roughly &lt;strong&gt;0.73 vs 0.42&lt;/strong&gt;. The order holds, and not by luck: the community plugin leads exactly one dimension out of seven, so no ±50% reweighting exists in which one 10%-class dimension outvotes six others. Push past ±50% and, holding the other six weights in proportion, Bundle Weight needs roughly &lt;strong&gt;43% of the whole composite&lt;/strong&gt; — against its actual 10% — to flip the winner. The gap is score-broad, not weight-fragile.&lt;/p&gt;

&lt;p&gt;That surviving loss deserves its own sentence. My plugin scores &lt;strong&gt;0&lt;/strong&gt; on Bundle Weight — 78 KB unpacked against the community's 47 KB — and it's printed in the table above at full weight, in a benchmark I wrote, scoring a plugin I built, under weights I chose. That conflict of interest is real, and the two controls you can audit against it are the published loss and the sensitivity math you can redo on a napkin — the full self-benchmark process is in &lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;I Built What I Benchmark&lt;/a&gt;. A composite that survives its own worst dimension in public is worth more than one that never shows a loss.&lt;/p&gt;

&lt;p&gt;If you'd have weighted it differently, don't argue in the abstract — the whole run is one command: &lt;code&gt;npm run bench:caching&lt;/code&gt; from &lt;code&gt;serverless/benchmarks&lt;/code&gt;, and this run's output sits at &lt;code&gt;benchmarks/benchmark-results/api-gateway-caching/2026-05-04_v1.0/result.json&lt;/code&gt; in the repo. Change the weights, re-run it; if your reweighting flips the order, that's a finding and I want it in an issue. The strongest evidence that this harness follows the data even when it disagrees with me is the next article in this series: the time our loudest serverless claim died under its own E2E run — &lt;a href="https://ofriperetz.dev/go/we-were-wrong-about-sls-remove?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;We Were Wrong About sls remove&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What is the composite?&lt;/td&gt;
&lt;td&gt;Weighted sum over 7 dimensions; weights sum to 1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Result (2026-05-04 run)&lt;/td&gt;
&lt;td&gt;Interlace &lt;strong&gt;0.88&lt;/strong&gt; vs community &lt;strong&gt;0.3025&lt;/strong&gt;; all 7 dimensions measured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who sets the weights?&lt;/td&gt;
&lt;td&gt;The author — editorial, published, versioned with the results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weight tiers&lt;/td&gt;
&lt;td&gt;Damage 25% · daily-use 15%×3 · comfort 10%×3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed units?&lt;/td&gt;
&lt;td&gt;Every dimension normalized to 0–1 before weighting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unmeasurable dimension?&lt;/td&gt;
&lt;td&gt;Null, never imputed; harness renormalizes over measured dims — &lt;em&gt;n&lt;/em&gt;-of-7 label lives at the claims layer (this run: 7-of-7)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;±50% adversarial reweighting&lt;/td&gt;
&lt;td&gt;~0.73 vs ~0.42 — the order holds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The honest loss&lt;/td&gt;
&lt;td&gt;Bundle Weight: 78 KB vs 47 KB → scored 0 vs 1, published at full weight&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Versions measured&lt;/td&gt;
&lt;td&gt;Interlace 0.0.0 (local source, unpublished) vs community 1.11.0 (npm)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reproduce&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm run bench:caching&lt;/code&gt; in &lt;code&gt;serverless/benchmarks&lt;/code&gt; → &lt;code&gt;benchmark-results/api-gateway-caching/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  External references
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/1jh3gr5k5v4?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;OECD/JRC — Handbook on Constructing Composite Indicators: Methodology and User Guide (2008)&lt;/a&gt; — the authoritative methodology reference for composite indicators; the normalization and sensitivity sections above are the single-benchmark version of its checklist.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/2gj60g4jn6m?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Lighthouse performance scoring documentation&lt;/a&gt; — Google's published, versioned scoring weights: the working precedent for treating a weight change as a verdict change.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;What's the largest weight you'd assign differently in that table — and which dimension would you demote to pay for it?&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Related deep dives
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/composite-scores-and-weighting?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Composite Scores and Weighting&lt;/a&gt; — the tier-0 canonical this benchmark instantiates: why composites exist, the honesty requirements, and the Lighthouse / OpenSSF Scorecard case studies&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;I Built What I Benchmark. Here's How I Try Not to Cheat.&lt;/a&gt; — the conflict-of-interest process behind every benchmark on this site&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/claims-registry-evidence-framework?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;The Claims Registry&lt;/a&gt; — the claim→evidence-file contract that this run's result file feeds&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Proxy Metrics&lt;/a&gt; — why registry-sourced dimensions measure stand-ins, not the thing itself&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/valid-vs-reliable-metrics?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Valid vs Reliable Metrics&lt;/a&gt; — the difference a README scan can't close on its own&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/serverless" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;Star the serverless repo — you've just read exactly how its scores are built&lt;/a&gt;
&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Interlace ESLint ecosystem&lt;/a&gt;. Source on &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=composite-benchmark-anatomy" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>serverless</category>
      <category>devops</category>
      <category>node</category>
      <category>aws</category>
    </item>
    <item>
      <title>Marketing Pages Rot Silently. Mine Print an Expiry Date on Every Claim.</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:43:26 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/marketing-pages-rot-silently-mine-print-an-expiry-date-on-every-claim-1km4</link>
      <guid>https://dev.to/ofri-peretz/marketing-pages-rot-silently-mine-print-an-expiry-date-on-every-claim-1km4</guid>
      <description>&lt;p&gt;Around 2026-08-01, a "verification pending" banner goes up on eleven claims in my serverless docs. Nothing will have broken. No competitor will have shipped. The banner goes up because every verified row in that repo's &lt;a href="https://ofriperetz.dev/go/r/zwh1e23qps?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;&lt;code&gt;CLAIMS.md&lt;/code&gt;&lt;/a&gt; is stamped 2026-05-03 or 2026-05-04, the file gives evidence a 90-day warranty, and date arithmetic doesn't care that I wrote the rule.&lt;/p&gt;

&lt;p&gt;Line 5 of that file:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a claim doesn't have a row, it can't ship in the docs. If "Last verified" is older than 90 days, the claim is &lt;strong&gt;stale&lt;/strong&gt; and gets a "verification pending" banner in docs until refreshed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That line exists because marketing pages rot silently: a claim gets written on the day it's true, nobody dates it, and the sentence sits there aging in public. The fix is one file per product line — &lt;strong&gt;the claims registry&lt;/strong&gt;: every public claim holds a row, every row points at a versioned result file, and every date is an expiry date.&lt;/p&gt;

&lt;p&gt;This is the methodology piece — the contract, the lifecycle, the staleness rule — and none of it is JavaScript-specific. If your product page makes claims, it transfers in an afternoon.&lt;/p&gt;




&lt;h2&gt;
  
  
  What does a claim owe you before it ships?
&lt;/h2&gt;

&lt;p&gt;The contract is deliberately boring. A claim backed by static evidence — a benchmark you can run on a laptop — carries four fields: the claim text &lt;strong&gt;exactly as it appears in docs or marketing&lt;/strong&gt;, the suite that measures it, a link to the latest result file, and the date it was last verified. Verbatim matters: paraphrase is where scope creep hides — if the row is politer than the landing page, the landing page is the claim nobody verified.&lt;/p&gt;

&lt;p&gt;One worked row from each product line:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serverless.&lt;/strong&gt; The claim "Composite score 88% vs community 30% (7 of 7 dimensions)" points at the &lt;code&gt;api-gateway-caching&lt;/code&gt; suite and its result file &lt;a href="https://ofriperetz.dev/go/r/1cywkt4os1j?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;&lt;code&gt;latest.json&lt;/code&gt;&lt;/a&gt;, last verified 2026-05-04. Open the file: composite 0.88 for my plugin (measured from local source at v0.0.0, not a published release) against 0.3025 for the community &lt;code&gt;serverless-api-gateway-caching@1.11.0&lt;/code&gt;, all seven weighted dimensions scored, including the one I lose outright: bundle weight, 78 KB unpacked against their 47 KB. The row carries the loss because the file does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ESLint.&lt;/strong&gt; The &lt;a href="https://ofriperetz.dev/go/r/1ea7m4rs921?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;sister registry's&lt;/a&gt; row reads "Top of leaderboard on the ILB-Arena 40-vuln / 38-safe corpus (1st of 18 plugins tested, 17 security-relevant)" and points at the ilb-arena suite's dated result file, where &lt;code&gt;summary.leaderboard[0].rank == 1&lt;/code&gt;. That row survived an audit its neighbor didn't: the registry had been asserting "97.6% precision, 100% recall, 98.8% F1" — figures that appear nowhere in the cited JSON, which reports 100% across the board. The 2026-05-13 audit withdrew the claim rather than substitute the perfect score — 100% on a 40-fixture self-authored corpus is the textbook "regression test, not benchmark" failure mode. The ordinal claim kept its row; the flattering one kept only its receipt, in a "Withdrawn claims" section. The suite column enforces &lt;a href="https://ofriperetz.dev/go/valid-vs-reliable-metrics?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;validity&lt;/a&gt; — measure what the copy asserts; the result file makes the claim &lt;a href="https://ofriperetz.dev/go/reproducibility-vs-replicability?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;reproducible&lt;/a&gt; — regenerate the number without trusting me.&lt;/p&gt;

&lt;p&gt;Claims only live infrastructure can verify get a second row flavor: claim text, the E2E step that asserts it, the last release verified. "Cache MISS on first request, HIT within TTL" maps to steps 4–5 of a deploy-and-assert run against real AWS, last verified 2026-05-03.&lt;/p&gt;

&lt;p&gt;The contract also kills a third category — the adjective. "Battle-tested," "blazing fast," "production-grade": &lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;proxies&lt;/a&gt; for measurements nobody made. No suite outputs "battle-tested," so no row, so no ship. The registry doesn't argue with weak claims; it has nowhere to put them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does a claim live and die?
&lt;/h2&gt;

&lt;p&gt;Draft → evidenced → stale → re-verified or retired — every transition a visible edit to one file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Draft.&lt;/strong&gt; The add-a-claim procedure opens with its most important sentence: &lt;em&gt;don't write the marketing copy first.&lt;/em&gt; Build or extend the benchmark until it produces a measurable result for your product and at least one competitor, add the row with today's date, and only then write the copy. The rule is that blunt because my instinct runs the other way — when I draft a README, the adjective arrives before the evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pending.&lt;/strong&gt; Unbacked copy isn't deleted — it's routed into a "Pending claims" section next to the suite that would back it. As of today my serverless registry holds 11 verified rows (4 static-evidence, 7 live-evidence) and 2 pending, both "Not started." Pending is the honest parking lot: marketing wishes become a benchmark backlog, priced in engineering hours instead of adjectives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Re-scoped.&lt;/strong&gt; The state nobody plans for: the measurement comes back and narrows the claim. Early copy in my serverless line implied we fix a ghost-billing bug in &lt;code&gt;sls remove&lt;/code&gt;. Then the community-plugin E2E ran on 2026-05-04: &lt;code&gt;sls remove&lt;/code&gt; came back clean — 31 seconds, exit 0, no orphans. The real trap is uninstalling the plugin while keeping the service. The original copy went into a re-scoped row — original text, what we measured, what's true — instead of being quietly rewritten (&lt;a href="https://ofriperetz.dev/go/we-were-wrong-about-sls-remove?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;told in full here&lt;/a&gt;). The most useful rows in my registry are the ones that say I was wrong, in writing, next to the run file that proved it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retired&lt;/strong&gt; is a re-scope with nothing left: no narrower version survives the measurement. The row stays; the docs copy goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does a claim go stale — and who decides?
&lt;/h2&gt;

&lt;p&gt;The misconception to kill: &lt;strong&gt;"claims go stale when the product changes."&lt;/strong&gt; They don't. A claim goes stale when its &lt;em&gt;evidence&lt;/em&gt; ages past its warranty — the product changing is just one way that happens. Your code can be byte-for-byte identical while the competitor you compared against ships three releases and the runtime you benchmarked goes end-of-life. The sentence didn't move; everything it pointed at did.&lt;/p&gt;

&lt;p&gt;That's why staleness is &lt;em&gt;declared, not discovered&lt;/em&gt;. Discovered staleness means a reader catches the drift, and the correction arrives after the damage. Declared staleness is date arithmetic: "Last verified" older than 90 days, banner up, mechanically, before anyone is misled. Ninety isn't sacred; it's a warranty one maintainer can honor — pick yours and enforce it the same way.&lt;/p&gt;

&lt;p&gt;What keeps this from becoming a refresh treadmill is the version stamp. "eslint-plugin-security has 27.5% recall" rots — a standing claim about the present. "eslint-plugin-security v2.1.1 scored 27.5% recall on our 40-fixture corpus, on ESLint 8.57.0" is a dated measurement no future release can falsify — true forever. Financial statements solved this long ago: a balance sheet is always "as of" a date. Stamped measurements carry zero refresh obligation; only &lt;em&gt;standing&lt;/em&gt; claims — "fastest," "most complete," anything in the present tense — sit under the 90-day clock.&lt;/p&gt;

&lt;p&gt;The opening countdown is this rule running on its author: the banner lands around 2026-08-01 whether or not I get the re-runs done first. The ESLint registry is already there — one row, a synthetic-corpus speedup last verified 2026-01-02, carries the "re-verify recommended" flag today. That's the system working on me, twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you adopt this in an afternoon?
&lt;/h2&gt;

&lt;p&gt;Nothing above depends on ESLint, serverless, or even software — the suite can be a load test, an accessibility audit, or a lab assay. The afternoon version:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create &lt;code&gt;CLAIMS.md&lt;/code&gt; at the repo root. The whole starting file:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;   # Claims Registry
&lt;span class="gt"&gt;
   &amp;gt; No row, no ship. "Last verified" older than 90 days →&lt;/span&gt;
&lt;span class="gt"&gt;   &amp;gt; "verification pending" banner in docs until refreshed.&lt;/span&gt;

   | Claim (verbatim from docs/marketing)   | Suite               | Latest result              | Last verified |
   | -------------------------------------- | ------------------- | -------------------------- | ------------- |
   | "Composite score 88% vs community 30%" | api-gateway-caching | benchmarks/.../latest.json | 2026-05-04    |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Inventory every public claim — README, landing page, package description — verbatim, no paraphrase.&lt;/li&gt;
&lt;li&gt;For each, name the evidence (suite + latest result + date) or move it to Pending.&lt;/li&gt;
&lt;li&gt;For everything new: evidence first, row second, copy last.&lt;/li&gt;
&lt;li&gt;On refresh, commit the new result as a dated file (&lt;code&gt;&amp;lt;suite&amp;gt;/&amp;lt;YYYY-MM-DD&amp;gt;_v&amp;lt;version&amp;gt;/&lt;/code&gt;) and bump the date — what you claimed, and when it was last true, becomes repo history.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This isn't an island. The closest neighbor is the &lt;a href="https://ofriperetz.dev/go/r/1qqfaz74bul?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;OpenSSF Scorecard&lt;/a&gt;, which treats a repo's security posture as automated, publicly specified checks pointed at repositories from the outside. The claims registry is the manual, product-side sibling, pointed inward at your own marketing copy (&lt;a href="https://ofriperetz.dev/go/how-to-design-a-ground-truth-corpus?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;corpus design&lt;/a&gt; covers the datasheets-for-datasets lineage). And it pairs with the conflict-of-interest discipline in &lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;I Built What I Benchmark&lt;/a&gt;: I built the products &lt;em&gt;and&lt;/em&gt; the registry that polices them — a suspicious arrangement, which is why every row names the command that would catch me.&lt;/p&gt;

&lt;p&gt;Next in this arc: &lt;a href="https://ofriperetz.dev/go/we-were-wrong-about-sls-remove?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;We Were Wrong About sls remove&lt;/a&gt; — the re-scope told in full, because the lifecycle is easiest to believe when you watch it delete a claim I liked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What gets a row&lt;/td&gt;
&lt;td&gt;Every claim in docs/marketing, verbatim — no paraphrase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static-evidence row&lt;/td&gt;
&lt;td&gt;Claim text · suite · latest result file · last-verified date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live-evidence row&lt;/td&gt;
&lt;td&gt;Claim text · E2E step · last release verified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ship rule&lt;/td&gt;
&lt;td&gt;No row → the claim cannot ship in docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staleness rule&lt;/td&gt;
&lt;td&gt;"Last verified" &amp;gt; 90 days → "verification pending" banner until refreshed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;Draft → evidenced → stale → re-verified or retired; re-scoped when measurement narrows it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New-claim order&lt;/td&gt;
&lt;td&gt;Evidence first, row second, marketing copy last&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unbacked copy&lt;/td&gt;
&lt;td&gt;Routed to Pending with the suite that would back it — never into docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh mechanics&lt;/td&gt;
&lt;td&gt;Re-run suite → commit dated result file (&lt;code&gt;&amp;lt;suite&amp;gt;/&amp;lt;YYYY-MM-DD&amp;gt;_v&amp;lt;version&amp;gt;/&lt;/code&gt;) → bump date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The immortal form&lt;/td&gt;
&lt;td&gt;Version-stamped: "vX scored Y (measured DATE)" — a dated measurement, true forever&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The suite that generates the ESLint result files is public — go check:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/eslint-benchmark-suite" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star the benchmark suite — the result files this registry points at&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;Before the clock catches yours: what's the oldest undated claim on your product page right now — and would it survive a re-run today?&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/1qqfaz74bul?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;OpenSSF Scorecard&lt;/a&gt; — the Open Source Security Foundation's automated checks on open-source repos; cited as the closest prior art for treating claims as named, publicly specified checks rather than prose.&lt;/li&gt;
&lt;li&gt;Gebru et al., &lt;a href="https://ofriperetz.dev/go/r/2epvqpcbvmd?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;Datasheets for Datasets&lt;/a&gt; (arXiv:1803.09010, later published in CACM) — cited for the documentation-contract idea the registry applies to marketing claims instead of datasets: standardized fields, stated provenance, explicit scope.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;Interlace ESLint ecosystem&lt;/a&gt;. Source on &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=claims-registry-evidence-framework" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>node</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Fixtures First, Rules Second: How to Design a Ground-Truth Corpus</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:43:02 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/fixtures-first-rules-second-how-to-design-a-ground-truth-corpus-1g05</link>
      <guid>https://dev.to/ofri-peretz/fixtures-first-rules-second-how-to-design-a-ground-truth-corpus-1g05</guid>
      <description>&lt;p&gt;&lt;code&gt;'SELECT * FROM users WHERE id = ' + userId&lt;/code&gt; is a vulnerable fixture — no debate. Wrap the input in &lt;code&gt;parseInt(userId)&lt;/code&gt; — vulnerable or safe? The label depends entirely on your threat model, and whichever way the author calls it, &lt;a href="https://ofriperetz.dev/go/ground-truth-in-security-testing?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus#hard-decisions" rel="noopener noreferrer"&gt;that call &lt;em&gt;is&lt;/em&gt; the ground truth&lt;/a&gt;. The corpus behind the Interlace security benchmarks took 40 calls like that one; &lt;code&gt;parseInt&lt;/code&gt; is the call I've gone back and forth on longest — it's still not in the corpus, which is itself a judgment call. And the cost of getting the discipline wrong isn't hypothetical: this corpus scores its own author's tool at a perfect 100% — a number that reads as evidence only because the fixtures demonstrably came first, and as a mirror otherwise. That discipline — categories, borderline labels, and the sequencing rule — is the rest of this article.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why do fixtures have to come before rules?
&lt;/h2&gt;

&lt;p&gt;Write the corpus &lt;em&gt;after&lt;/em&gt; the tool, with one eye on what it already detects, and the fixtures inherit its blind spots — the benchmark stops measuring and starts reflecting. That's &lt;a href="https://ofriperetz.dev/go/goodharts-law-explained?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;Goodhart's Law&lt;/a&gt; operating at design time.&lt;/p&gt;

&lt;p&gt;The defense is sequencing: this corpus was designed against published OWASP categories and CWE mappings before any Interlace rule existed to cover it, and its 38 safe patterns represent realistic validated code, not Interlace's allow-listing logic. Sequencing bounds the &lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;bias&lt;/a&gt;, not removes it — the same person still wrote both sides of the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you choose categories — and how many of each?
&lt;/h2&gt;

&lt;p&gt;Anchor categories to a public taxonomy, not instinct. The vulnerable corpus is 40 exported fixture functions across 14 categories, each mapped to a &lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;CWE ID&lt;/a&gt; — from four SQL-injection fixtures (CWE-89) down to a single open redirect (CWE-601), 17 distinct CWE IDs in total. Paired with those are 38 safe fixtures — 78 in all ("the 40-fixture corpus" names the vulnerable half; &lt;a href="https://ofriperetz.dev/go/precision-recall-f1-for-static-analysis?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;precision&lt;/a&gt; is computed against the safe half).&lt;/p&gt;

&lt;p&gt;Prior art: NIST's SARD is the canonical labeled vulnerable-code corpus family — the Juliet suites cover C/C++ and Java, with PHP and C# suites alongside; OWASP's Benchmark Project is the closest scored SAST analog; and SecBench.js (ICSE 2023) labels ~600 real-world server-side JavaScript vulnerabilities with executable exploits. What none of them offered Node.js-native is an OWASP-Benchmark-style scored SAST leaderboard — this corpus is that analog, smaller.&lt;/p&gt;

&lt;p&gt;One distortion to own out loud: the corpus is roughly balanced across categories and production code is not — real vulnerabilities are rare, so a balanced corpus says nothing about the false-alarm rate at &lt;a href="https://ofriperetz.dev/go/base-rate-problem-explained?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;production base rates&lt;/a&gt;. It measures detection per pattern; prevalence needs different evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you label the edge cases?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;parseInt&lt;/code&gt; label is an author judgment inside the CWE-89 definition, and it earns an annotation carrying its reasoning: &lt;em&gt;borderline; would be vulnerable if the query used string interpolation instead of the typed value&lt;/em&gt;. The generalizable rule: record the reasoning, not just the verdict. Six months later, a label without reasoning is an opinion with tenure.&lt;/p&gt;

&lt;p&gt;This corpus has one rater, and &lt;a href="https://ofriperetz.dev/go/inter-rater-agreement-cohens-kappa?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;inter-rater agreement&lt;/a&gt; needs at least two — there is no kappa to report, only me agreeing with myself. That's the weakest joint in the design, and why the reasoning must be public: so a second rater can show up later and disagree precisely.&lt;/p&gt;

&lt;p&gt;Labels live &lt;em&gt;with&lt;/em&gt; the code: a manifest at the fixture file's bottom (&lt;code&gt;EXPECTED_DETECTIONS&lt;/code&gt;: function → &lt;code&gt;{cwe, severity}&lt;/code&gt;; &lt;code&gt;EXPECTED_NO_DETECTIONS&lt;/code&gt;: the safe list) is what the runner imports to score &lt;a href="https://ofriperetz.dev/go/confusion-matrix-tp-fp-fn-tn?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;TP/FP/FN/TN&lt;/a&gt; — no spreadsheet drifting out of sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make a corpus result reproducible?
&lt;/h2&gt;

&lt;p&gt;A corpus result is a claim about specific tool versions — the &lt;a href="https://ofriperetz.dev/go/bias-in-measurement?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus#the-unicorn-incident" rel="noopener noreferrer"&gt;unicorn false-zero&lt;/a&gt; in my own benchmark came from a stale Node version in the shell, not from the plugin. &lt;a href="https://ofriperetz.dev/go/reproducibility-vs-replicability?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;Reproducibility&lt;/a&gt; rests on three mechanisms, none of them "trust the manifest": a lockfile pins the install; the runner resolves each plugin's actually-installed version at run time and writes it into the results JSON; and the environment ships with the numbers — per run, inside each results file, because runs happen on different days on different Nodes. The Interlace 3.0.2 run behind the 40 TP / 0 FP / 0 FN line records Node v24.12.0, ESLint 9.39.2, measured 2026-05-30; the sonarjs 3.0.6 run (14/40) records the same environment a day earlier, 2026-05-29. One plugin (&lt;code&gt;eslint-plugin-security&lt;/code&gt; 2.1.1) crashes on ESLint 9 and runs in a separate compat package — it declares &lt;code&gt;eslint ^8.57.0&lt;/code&gt; and lets its lockfile hold the exact version — recorded, not smoothed over.&lt;/p&gt;

&lt;p&gt;The other half is publishing: corpus public, divergence channel public, and reproduction that needs zero help from me:&lt;br&gt;
&lt;/p&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.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:fn-fp   &lt;span class="c"&gt;# → node benchmarks/fn-fp-comparison/run.js&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it, get a different number, open an issue — your results JSON carries its own environment block, so the disagreement starts from evidence, not recollection. A corpus nobody can re-run is a claim, not a benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can 40 fixtures actually prove?
&lt;/h2&gt;

&lt;p&gt;The 78 fixtures are a constructed set, not a random draw, so results are descriptions, not inferences — no p-value is meaningful at any corpus size, because &lt;a href="https://ofriperetz.dev/go/sample-size-and-statistical-power?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;statistical power&lt;/a&gt; assumes a sampling process this design doesn't have. What n=40 is good at is category-level findings: a plugin catching 14 of 40 while missing whole categories is a real, checkable statement about that plugin at that version. Don't decorate results with statistics the design can't carry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the corpus lifecycle?
&lt;/h2&gt;

&lt;p&gt;The lifecycle: design → publish → saturate → refresh.&lt;/p&gt;

&lt;p&gt;Saturation is where mine is now: Interlace v3.0.2 scores 40 TP, 0 FP, 0 FN — 100/100/100 precision/recall/F1. The tempting read is "the tool is finished." The correct read: the &lt;em&gt;corpus&lt;/em&gt; is finished, for that tool — a saturated corpus can't measure the improvement of the tool that saturates it. Publishing that line felt less like winning and more like watching the instrument go quiet. The corpus still measures competitors (next best: 14 of 40, eslint-plugin-sonarjs 3.0.6), but for Interlace it's now a regression test, not a benchmark.&lt;/p&gt;

&lt;p&gt;Then contamination: public fixtures can be tuned against — by any tool, mine included. Public corpora decay the way public trading signals do: once everyone sees the signal, trading on it erases the information it carried. The answer is versioning, not secrecy (secrecy kills reproducibility): treat the published corpus as v1 and plan a harder v2 held to the same fixtures-before-rules discipline — anchored to the taxonomy, not to any tool's known behavior, Interlace's misses included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which findings survive a robustness check?
&lt;/h2&gt;

&lt;p&gt;Before publishing, run leave-one-category-out: re-score the leaderboard 14 times, once per removed CWE category, and check whether the ordering survives every drop. A 40/40-versus-14/40 gap survives any single removal by construction; two mid-table plugins a fixture apart may swap the moment a category disappears — that's the ranking to report as fragile, or not as a ranking at all. Cheap, mechanical, and almost nobody does it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are Fixture Cards?
&lt;/h2&gt;

&lt;p&gt;The last discipline is documentation, and it deserves a name: &lt;strong&gt;Fixture Cards&lt;/strong&gt; — per-fixture documentation modeled on Datasheets for Datasets (Gebru et al.) and Model Cards (Mitchell et al.), scaled down to the single labeled example. Six fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;The &lt;code&gt;parseInt&lt;/code&gt; fixture's card would read&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CWE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CWE-89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Label&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;threat-model-conditional: vulnerable under "any non-parameterized query is unsafe"; safe under "direct injection vector only"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Reasoning&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;borderline; would be vulnerable if the query used string interpolation instead of the typed value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Author&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ofri Peretz&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;the day the call is made, pinned to the corpus version it enters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Disputed?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;open — threat-model-dependent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four of the six fields already exist in the suite: CWE and label live in the manifest the runner imports, author and date in the fixture file's git history. The Card names that standard and adds the two fields no runner ever checks — reasoning and dispute status. Packaging all six as a JSON schema is the remaining step, and it doubles as the contribution unit: to submit a fixture is to submit its Card. Labeling debt stops accumulating silently, and the single-rater problem gets its structural fix — every &lt;code&gt;disputed?&lt;/code&gt; field is an open seat for a second rater.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;parseInt&lt;/code&gt; Card above is the first open seat, and it's yours if you want it: comment with your label — vulnerable or safe — and the threat model that produces it. That's the second rating this corpus doesn't yet have.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Design decision&lt;/th&gt;
&lt;th&gt;The rule&lt;/th&gt;
&lt;th&gt;If you skip it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sequencing&lt;/td&gt;
&lt;td&gt;Fixtures before rules&lt;/td&gt;
&lt;td&gt;The benchmark becomes a mirror&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Category selection&lt;/td&gt;
&lt;td&gt;Anchor to CWE/OWASP (here: 14 categories, 17 CWE IDs)&lt;/td&gt;
&lt;td&gt;Blind spots inherited from instinct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Balance&lt;/td&gt;
&lt;td&gt;Balanced corpus — and say what it can't estimate&lt;/td&gt;
&lt;td&gt;Base-rate overclaims about production FP rates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge-case labels&lt;/td&gt;
&lt;td&gt;Record reasoning with the label; mark borderline calls&lt;/td&gt;
&lt;td&gt;Labels become opinions with tenure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Machine-readable labels&lt;/td&gt;
&lt;td&gt;Manifest lives in the fixture file; runner imports it&lt;/td&gt;
&lt;td&gt;Spreadsheet drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Versions&lt;/td&gt;
&lt;td&gt;Lockfile + per-run resolved versions in the results&lt;/td&gt;
&lt;td&gt;Irreproducible numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publishing&lt;/td&gt;
&lt;td&gt;Public corpus + a divergence channel&lt;/td&gt;
&lt;td&gt;A claim, not a benchmark&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sample claims&lt;/td&gt;
&lt;td&gt;n=40 describes; it does not infer&lt;/td&gt;
&lt;td&gt;Fake statistical rigor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;Version the corpus; plan v2 at saturation&lt;/td&gt;
&lt;td&gt;100% scores misread as "done"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Robustness&lt;/td&gt;
&lt;td&gt;Leave-one-category-out before publishing&lt;/td&gt;
&lt;td&gt;Fragile rankings published as real&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixture Cards&lt;/td&gt;
&lt;td&gt;CWE · label · reasoning · author · date · disputed?&lt;/td&gt;
&lt;td&gt;Labeling debt accumulates silently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;Corpus design is the unglamorous half of benchmarking — nobody stars a repo for a well-reasoned borderline label. But every downstream precision and recall number is only as honest as these decisions. Next in this arc the corpus does its job: the &lt;a href="https://ofriperetz.dev/go/eslint-security-fn-fp-benchmark?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;false-positive/false-negative benchmark&lt;/a&gt; scores six plugins against these 78 fixtures, wins and losses included.&lt;/p&gt;

&lt;p&gt;If this is the discipline you'd want behind a benchmark you trust, the corpus — fixtures, manifests, runner, results — is public:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ofri-peretz/eslint-benchmark-suite" class="crayons-btn crayons-btn--primary" rel="noopener noreferrer"&gt;⭐ Star the benchmark suite — every fixture and label in this article is in it&lt;/a&gt;
&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/amji6fw5zd?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;NIST SARD — Software Assurance Reference Dataset&lt;/a&gt; (incl. the Juliet Test Suite) — the canonical labeled vulnerable-code corpora for C/C++/Java; this corpus is the Node.js-native analog, at a fraction of the scale.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/r/22pc6fhgqgm?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;OWASP Benchmark Project&lt;/a&gt; — the closest prior art overall: a public, scored SAST benchmark on the same TP/FP mechanics.&lt;/li&gt;
&lt;li&gt;Gebru et al., &lt;a href="https://ofriperetz.dev/go/r/2epvqpcbvmd?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;"Datasheets for Datasets"&lt;/a&gt; (CACM 2021) — the dataset-documentation standard Fixture Cards are modeled on.&lt;/li&gt;
&lt;li&gt;Mitchell et al., &lt;a href="https://ofriperetz.dev/go/r/1gqzr4mayj3?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;"Model Cards for Model Reporting"&lt;/a&gt; (FAT* 2019) — the same documentation move at model level; Fixture Cards scale it down to the single labeled example.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related deep dives
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/ground-truth-in-security-testing?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;Ground Truth in Security Testing: Who Decides What's Vulnerable?&lt;/a&gt; — the T1 vocabulary this article builds on, including the full &lt;code&gt;parseInt&lt;/code&gt; labeling story&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/i-built-what-i-benchmark-heres-how-i-try-not-to-cheat?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;I Built What I Benchmark. Here's How I Try Not to Cheat.&lt;/a&gt; — the conflict-of-interest process this corpus discipline lives inside&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/eslint-security-fn-fp-benchmark?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;1.5M Weekly Downloads, 1 False Alarm per Real Bug&lt;/a&gt; — the evidence article produced by this corpus&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;Interlace ESLint ecosystem&lt;/a&gt;. Source on &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=how-to-design-a-ground-truth-corpus" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>eslint</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Valid vs. Reliable Metrics: Consistent Numbers Can Still Be Wrong</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 19 Jul 2026 23:47:32 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/valid-vs-reliable-metrics-consistent-numbers-can-still-be-wrong-1a2m</link>
      <guid>https://dev.to/ofri-peretz/valid-vs-reliable-metrics-consistent-numbers-can-still-be-wrong-1a2m</guid>
      <description>&lt;p&gt;A clock that runs ten minutes fast is the friendly kind of broken: it shows the same answer every time you look, so it feels dependable — right up until it makes you miss the train. The clock was never inconsistent. It was consistently wrong, which is the harder problem, because consistency is the part that looks like quality. That gap between &lt;em&gt;steady&lt;/em&gt; and &lt;em&gt;correct&lt;/em&gt; is the oldest distinction in measurement theory, and it's the one most dashboards quietly skip: a number can be perfectly &lt;strong&gt;reliable&lt;/strong&gt; — the same value on every read — and still be &lt;strong&gt;invalid&lt;/strong&gt; — pointed at the wrong quantity. Precision of the wrong thing is still the wrong thing.&lt;/p&gt;

&lt;p&gt;Reliability versus validity applies identically whether you weigh yourself, price a stock, score a diagnostic test, grade a diver, or benchmark software. Reliability is a property of the instrument; validity is a property of the claim you attach to its number. They are independent — you can have either without the other — and telling them apart is the whole job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Makes a Metric Reliable?
&lt;/h2&gt;

&lt;p&gt;A metric is &lt;strong&gt;reliable&lt;/strong&gt; when repeated measurement under the same conditions returns the same number. A scale that reads 82.0 kg five times in a row is reliable. A lab assay that returns 5.4 mmol/L on the same blood sample, run three times, is reliable — same sample, same number. Reliability never asks whether 82.0 or 5.4 is &lt;em&gt;correct&lt;/em&gt;; only whether the instrument will say it again.&lt;/p&gt;

&lt;p&gt;Reliability is a property of the &lt;em&gt;instrument&lt;/em&gt;, not of the truth. That makes it the cheap property to check: you don't need to know anything about the world — take the measurement twice and compare. It's also the property people naturally police, because unreliability is loud. A blood-pressure cuff that gives a different reading every squeeze, a poll that swings ten points between identical samples, a stopwatch that disagrees with itself — a wobbling number announces itself, gets questioned, gets fixed.&lt;/p&gt;

&lt;p&gt;That loudness is exactly what makes reliability feel like quality. It isn't. It's half of quality, and it's the easy half.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Makes a Metric Valid?
&lt;/h2&gt;

&lt;p&gt;A metric is &lt;strong&gt;valid&lt;/strong&gt; when it measures the concept you claim it measures. Psychometricians call this &lt;em&gt;construct validity&lt;/em&gt;: the "construct" is the real thing you care about — health, aptitude, risk, security — and validity is how faithfully your number tracks it.&lt;/p&gt;

&lt;p&gt;Three general examples, in increasing order of how much they cost when they're wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A scale that is 2 kg off&lt;/strong&gt; reads 82.0 kg every time while you actually weigh 80. Perfectly reliable, invalid for "my actual weight." A calibration error doesn't reduce consistency at all — which is precisely why it survives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standardized tests&lt;/strong&gt; produce famously reliable scores: retake next week, get nearly the same number. The century-old argument is entirely about validity — whether the score measures aptitude or measures practice at taking that kind of test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A call center reports 95% of calls answered within 20 seconds&lt;/strong&gt; — cleanly measured, stable month to month. But the customers who gave up before connecting were never in the denominator. The metric is valid for "answered calls, handled fast" and invalid for "customers who could reach us," and the gap between those two is where the complaints live.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This framing isn't mine. Messick's 1989 treatment made construct validity the organizing idea of measurement in the social sciences, and Jacobs &amp;amp; Wallach ported the framework to computational systems in 2021. The practitioner translation: every number you publish is implicitly a validity claim, whether you meant to make one or not.&lt;/p&gt;

&lt;p&gt;The hard part is that you cannot check validity by re-running. Reliability is verified from inside the measurement; validity needs an independent route to the construct — a second instrument you trust, a real-world outcome, a ground truth established some other way. You can be certain a number is stable and have no idea whether it is true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliable, Valid, Both, or Neither?
&lt;/h2&gt;

&lt;p&gt;The standard picture is a dartboard. Where the darts land is your metric; the bullseye is the construct.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tight cluster on the bullseye&lt;/strong&gt; — reliable and valid. The goal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tight cluster in the wrong corner&lt;/strong&gt; — reliable, invalid. &lt;strong&gt;The dangerous quadrant&lt;/strong&gt;, because consistency masquerades as correctness. Nobody audits a number that never wobbles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scattered around the bullseye&lt;/strong&gt; — valid on average, unreliable. Annoying, but self-announcing: the wobble is visible, so it gets fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scattered and off-target&lt;/strong&gt; — neither. Usually caught fast, because nothing about it looks trustworthy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the misconception this article exists to delete: &lt;strong&gt;"a metric that gives consistent numbers is a good metric."&lt;/strong&gt; Consistency is reliability; whether the number measures the right thing is validity — and they are independent. You can have either without the other, and quadrant 2 is where the expensive failures concentrate.&lt;/p&gt;

&lt;p&gt;My clearest picture of quadrant 2 is a silent zero. A fraud filter flags 0 of roughly 50,000 transactions every night for a week — the same 0 on every run, perfectly reproducible — because a data feed upstream had gone null and the model was scoring empty rows. The real fraud rate that week was about 0.8%. Nothing wobbled, so nothing got investigated; the stability &lt;em&gt;was&lt;/em&gt; the disguise. A number that never moves is not the same as a number that is right.&lt;/p&gt;

&lt;p&gt;One more force pushes metrics toward quadrant 2: optimization. When a number becomes a target, people and processes make it &lt;em&gt;more&lt;/em&gt; stable while draining its meaning — the number stays crisp as its connection to the construct erodes. That mechanism has &lt;a href="https://ofriperetz.dev/go/goodharts-law-explained?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;its own article on Goodhart's law&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is One Valid Metric Enough? Construct Coverage
&lt;/h2&gt;

&lt;p&gt;Validity is a property of a metric. &lt;strong&gt;Construct coverage&lt;/strong&gt; is the property of your metric &lt;em&gt;system&lt;/em&gt;: it is valid only if every failure mode that matters has at least one metric watching it.&lt;/p&gt;

&lt;p&gt;The expensive failures are usually not a bad metric — they're a missing one. A company can report revenue, gross margin, and burn rate — each audited, each reliable, each valid for its own construct — while no line anywhere tracks customer concentration. Then the single client that was 40% of revenue walks, and every number on the healthy dashboard was true right up to the moment it stopped mattering. A coverage hole is invisible from inside the dashboard, because every number you &lt;em&gt;do&lt;/em&gt; have looks fine. The metrics were excellent at answering questions nobody was about to ask.&lt;/p&gt;

&lt;p&gt;The audit generalizes to anything you ship or promise:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;List the claims.&lt;/strong&gt; Everything a spec sheet, a contract, a label, or a marketing page asserts — "safe under load," "accurate to ±1%," "no side effects," "holds up outside the sample it was built on."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For each claim, name the check that would falsify it.&lt;/strong&gt; Not the check that sits near it — the one that fails if the claim is false. A "survives a crash" promise needs a test that actually triggers the crash; a "works after the upgrade" promise needs a run on the upgraded system, not the one you happened to have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The diff is your exposure.&lt;/strong&gt; Every claim without a falsifying check is a promise you are making on vibes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the constructive move this whole distinction buys you: reliability tells you your instrument is steady; validity tells you it's pointed at something true; coverage tells you nothing important sits unwatched. Tom DeMarco needed a public recantation in 2009 to walk back decades of "you can't control what you can't measure" — measurement humility from one of the people who taught the industry to measure. The practical version of that humility is small: before you trust a stable number, ask what question it actually answers. Then ask which questions have no number at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;th&gt;How to check&lt;/th&gt;
&lt;th&gt;Failure smell&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reliability&lt;/td&gt;
&lt;td&gt;Does re-measuring give the same number?&lt;/td&gt;
&lt;td&gt;Take it twice under identical conditions&lt;/td&gt;
&lt;td&gt;Wobbling numbers — loud, self-announcing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validity&lt;/td&gt;
&lt;td&gt;Does the number measure the concept claimed?&lt;/td&gt;
&lt;td&gt;Independent route to the construct (second instrument, real outcome, ground truth)&lt;/td&gt;
&lt;td&gt;Stable dashboard, surprised stakeholders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reliable + invalid&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;The dangerous quadrant: consistency masquerading as correctness&lt;/td&gt;
&lt;td&gt;A number nobody audits because it never wobbles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Construct coverage&lt;/td&gt;
&lt;td&gt;Does every claim have a metric watching it?&lt;/td&gt;
&lt;td&gt;List claims → name the falsifying check for each → diff&lt;/td&gt;
&lt;td&gt;A stated guarantee with no check pointed at it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/goodharts-law-explained?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Goodhart's Law, Explained&lt;/a&gt; — the mechanism that drives metrics into the reliable-but-invalid quadrant.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Proxy Metrics&lt;/a&gt; — every proxy has a validity gap; that gap is this article's subject, measured.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/composite-scores-and-weighting?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Composite Scores and Weighting&lt;/a&gt; — what happens to validity when you collapse many metrics into one.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ofriperetz.dev/go/different-metrics-for-different-package-types?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Different Metrics for Different Package Types&lt;/a&gt; — the software worked example this idea was drawn from: a real construct-coverage hole, and the metric system built to close it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If this is the kind of reference you'll want at your next metrics review, &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;follow me on Dev.to&lt;/a&gt; and bookmark it — it's built to be cited, not skimmed once.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Messick, S. (1989). "Validity." In R. L. Linn (Ed.), &lt;em&gt;Educational Measurement&lt;/em&gt; (3rd ed., pp. 13–103). American Council on Education / Macmillan. The authoritative construct-validity treatment; the source of the modern view that validity is about the &lt;em&gt;interpretation&lt;/em&gt; of a score, not the score itself.&lt;/li&gt;
&lt;li&gt;Jacobs, A. Z., &amp;amp; Wallach, H. (2021). "Measurement and Fairness." &lt;em&gt;Proceedings of the ACM Conference on Fairness, Accountability, and Transparency (FAccT '21)&lt;/em&gt;. &lt;a href="https://ofriperetz.dev/go/r/ak24ceu5rq?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;arxiv.org/abs/1912.05511&lt;/a&gt;. Measurement theory applied to computational systems — the bridge between psychometrics and the numbers you actually ship.&lt;/li&gt;
&lt;li&gt;Trochim, W. M. K. &lt;em&gt;Research Methods Knowledge Base&lt;/em&gt;. &lt;a href="https://ofriperetz.dev/go/r/1syg5136tmw?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;conjointly.com/kb&lt;/a&gt;. Free online reference; the fastest concept-check for reliability/validity vocabulary, including the dartboard figure.&lt;/li&gt;
&lt;li&gt;DeMarco, T. (2009). "&lt;a href="https://ofriperetz.dev/go/r/rf2cdhh4bl?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Software Engineering: An Idea Whose Time Has Come and Gone?&lt;/a&gt;" &lt;em&gt;IEEE Software&lt;/em&gt;, 26(4). The recantation of "you can't control what you can't measure" — metrics humility from one of the field's original measurement advocates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Foundations series: ← &lt;a href="https://ofriperetz.dev/go/inter-rater-agreement-cohens-kappa?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Inter-Rater Agreement &amp;amp; Cohen's κ&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/16wihjeozse?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;hub&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/proxy-metrics?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Proxy Metrics&lt;/a&gt; →&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Interlace ESLint ecosystem&lt;/a&gt;. Source on &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=valid-vs-reliable-metrics" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>node</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Static Analysis vs. SAST vs. Linting: The Taxonomy That Matters for Security Teams</title>
      <dc:creator>Ofri Peretz</dc:creator>
      <pubDate>Sun, 19 Jul 2026 23:47:10 +0000</pubDate>
      <link>https://dev.to/ofri-peretz/static-analysis-vs-sast-vs-linting-the-taxonomy-that-matters-for-security-teams-g5o</link>
      <guid>https://dev.to/ofri-peretz/static-analysis-vs-sast-vs-linting-the-taxonomy-that-matters-for-security-teams-g5o</guid>
      <description>&lt;p&gt;When a security dashboard files a single-file linter under "SAST tools," it's using "SAST" as a marketing umbrella for anything that inspects source code without running it — technically defensible, practically misleading.&lt;/p&gt;

&lt;p&gt;A lint rule fires in your editor in under a second. CodeQL runs on a pull request in minutes and can trace a tainted value through a dozen function calls across as many files. SonarQube keeps a persistent model of your codebase and tracks how an issue count moves across releases. Different scopes, different failure modes, different jobs in the pipeline — squeezed into one word that tells you none of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The taxonomy
&lt;/h2&gt;

&lt;p&gt;Three levels, broadest to narrowest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static analysis&lt;/strong&gt; is any examination of source code without executing it — abstract interpretation, control-flow analysis, taint analysis, and everything below all qualify. Every tool in this piece is static analysis; the word alone tells you nothing about what a given tool actually catches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SAST&lt;/strong&gt; (Static Application Security Testing) narrows that to security vulnerabilities specifically, and adds three defining traits: it runs in CI/CD rather than at edit time, it traces values across function and file boundaries (interprocedural analysis), and it reports a finding with severity, &lt;a href="https://ofriperetz.dev/go/cwe-taxonomy-explained?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;CWE classification&lt;/a&gt;, and the data-flow path that proves it. CodeQL, Semgrep, and SonarQube Security are the common names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Linting&lt;/strong&gt; narrows further, to syntactic and pattern-level checks meant to run while you're still writing the code. A lint rule works on a single file's &lt;a href="https://ofriperetz.dev/go/r/5valei5tw4?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;AST&lt;/a&gt; — the pluggable, per-file architecture Nicholas C. Zakas built ESLint around in 2013 — with no cross-file data flow, and it has to finish in milliseconds to be worth having in the editor. It reports patterns: style violations, correctness bugs, and the security mistakes that are visible from inside one file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security linting&lt;/strong&gt; — the category a dedicated security linter belongs to — is linting aimed at security patterns specifically: same edit-time speed, same single-file scope, but pattern-matching for constructs like string concatenation at a SQL call site, &lt;code&gt;Math.random()&lt;/code&gt; generating a token, or a missing JWT verify call. Security linting at edit time. Not a SAST replacement.&lt;/p&gt;




&lt;h2&gt;
  
  
  What each level detects
&lt;/h2&gt;

&lt;p&gt;The differences get concrete on one vulnerability, shown two ways.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pattern a security linter catches
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vulnerable.js&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/users/: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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM users WHERE 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;params&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;//                                                 ^^^^^^^^^^^^^^^^^^&lt;/span&gt;
  &lt;span class="c1"&gt;//            ESLint sees: string + user-controlled value at a db.query call&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="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&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 linter sees the concatenation at the call site — source (&lt;code&gt;req.params.id&lt;/code&gt;) and sink (&lt;code&gt;pool.query&lt;/code&gt;) both visible in the same file, adjacent lines. A security rule fires on the string-built SQL and attaches CWE-89. The developer sees the warning before they commit.&lt;/p&gt;

&lt;h3&gt;
  
  
  The multi-hop path that needs taint analysis
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// routes/users.js&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;buildQuery&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;../db/query-builder.js&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/users/: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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;userId&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;params&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;// source: user-controlled&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sanitized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;              &lt;span class="c1"&gt;// looks sanitized — only strips whitespace&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildQuery&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="nx"&gt;sanitized&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// tainted value crosses file boundary&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="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// db/query-builder.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`SELECT * FROM &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; WHERE id = &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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// template injection&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A lint rule scanning &lt;code&gt;routes/users.js&lt;/code&gt; sees &lt;code&gt;userId.trim()&lt;/code&gt; and a function call — the concatenation itself sits one file away, inside &lt;code&gt;buildQuery()&lt;/code&gt;. The call site looks clean; the rule doesn't fire. (And it isn't safe: &lt;code&gt;.trim()&lt;/code&gt; removes whitespace, not SQL — &lt;code&gt;1; DROP TABLE users--&lt;/code&gt; passes through untouched and reaches the template intact. Had the code used &lt;code&gt;parseInt(userId, 10)&lt;/code&gt; the payload really would have collapsed to the integer &lt;code&gt;1&lt;/code&gt;; the trap is the sanitizer that only &lt;em&gt;looks&lt;/em&gt; like one.) CodeQL traces &lt;code&gt;req.params.id&lt;/code&gt; → &lt;code&gt;userId&lt;/code&gt; → &lt;code&gt;sanitized&lt;/code&gt; → &lt;code&gt;query&lt;/code&gt; inside &lt;code&gt;buildQuery&lt;/code&gt; → &lt;code&gt;pool.query&lt;/code&gt;, and files a CWE-89 finding with the full path as evidence.&lt;/p&gt;

&lt;p&gt;Same vulnerability, two different visibility ceilings — not a defect in either tool, just two levels of the taxonomy doing what they're built to do.&lt;/p&gt;




&lt;h2&gt;
  
  
  Time-in-pipeline analysis
&lt;/h2&gt;

&lt;p&gt;Capability isn't the only axis that matters — timing is. Security linting fires at edit time, under a second. A light SAST pass on a PR takes one to five minutes. A deep nightly scan runs thirty minutes to hours. That gap changes what a developer actually does with a finding: a three-minute CI wait means they've already pushed the branch and moved on to reviewing someone else's PR by the time the result lands. A half-second lint warning arrives while their hands are still on the keyboard, in the same context as the line that triggered it.&lt;/p&gt;

&lt;p&gt;Security linting and SAST aren't competing for the same job. They sit at different moments in the workflow, at different depth, for different cost — which is why a mature pipeline runs both instead of picking one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a security linter is not a SAST tool
&lt;/h2&gt;

&lt;p&gt;A security linter carries the constraints of its category on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed and scope.&lt;/strong&gt; Rules run in milliseconds, on one file's AST, because that's what edit-time feedback requires — any rule needing cross-file data flow would break the guarantee. A linter can't trace &lt;code&gt;userInput&lt;/code&gt; through five function calls across three files; it pattern-matches on argument names, import shapes, and option keys instead of building a program-semantics model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Domain depth over breadth.&lt;/strong&gt; Where a security linter earns its keep is depth inside specific library surfaces: JWT verification via &lt;code&gt;jsonwebtoken&lt;/code&gt;, parameterized queries via &lt;code&gt;pg&lt;/code&gt;, SSRF sinks in &lt;code&gt;node-fetch&lt;/code&gt; and &lt;code&gt;axios&lt;/code&gt;, unsafe deserialization in &lt;code&gt;serialize-javascript&lt;/code&gt;. The rule knows the library's dangerous shapes and pattern-matches them within a single call site — narrow, but precise where it looks. That's the honest claim: security linting at edit time, covering what linting can structurally detect — not a replacement for CodeQL, Semgrep, or SonarQube's interprocedural coverage.&lt;/p&gt;

&lt;p&gt;A vendor's "SAST" label isn't false — at maximum breadth "SAST" does mean "any source-code security analysis." I've stopped being annoyed that it's technically defensible: a tide pool and the open ocean are both "water," and that stays true right up until you have to decide where it's safe to swim. What the label discards is the one distinction that tells you which tool to reach for — a pattern match at edit time, or a proven data-flow path at CI time.&lt;/p&gt;

&lt;p&gt;The precise label is &lt;strong&gt;security linter&lt;/strong&gt;: lint speed, lint scope, right for edit-time and CI, not a substitute for taint analysis. Where that structural ceiling comes from, and how precision and recall diverge under it, is the subject of &lt;a href="https://ofriperetz.dev/go/taint-vs-heuristic-detection?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Taint vs. Heuristic Detection&lt;/a&gt; and &lt;a href="https://ofriperetz.dev/go/precision-recall-f1-for-static-analysis?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Precision, Recall, and F1 for Static Analysis&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The recommended stack
&lt;/h2&gt;

&lt;p&gt;A complete Node.js security pipeline uses four positions — not redundant, each catches a different category at a different cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Edit time — security linting (ESLint plus a security plugin).&lt;/strong&gt; Catches obvious single-file patterns at developer speed, zero CI wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Pre-commit / pre-push — CI linting.&lt;/strong&gt; Same rules, enforced as a hard gate — catches what a skipped editor integration let through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. PR check — SAST (CodeQL or Semgrep).&lt;/strong&gt; Taint analysis across the diff and its call graph; catches multi-hop injection that spans files and imports, before merge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Nightly / weekly — deep SAST or DAST&lt;/strong&gt; (SonarQube, Checkmarx, OWASP ZAP on staging). Full-codebase analysis, drift tracking across releases, and — for DAST — the runtime behaviors no source read can reach.&lt;/p&gt;

&lt;p&gt;Teams that skip positions 1–2 pay for 3–4 to catch what the editor could have blocked for free. Teams that skip 3–4 leave every multi-hop taint path undetected. A complete pipeline runs all four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Named misconception:&lt;/strong&gt; "ESLint security plugins are SAST tools." They're security linters — the subset of static analysis that runs at lint speed and lint scope, without interprocedural data flow or taint tracking. The confusion comes from vendors using "SAST" as an umbrella term for any source-code security analysis, which is technically accurate and practically uninformative.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick reference
&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;Example tools&lt;/th&gt;
&lt;th&gt;When runs&lt;/th&gt;
&lt;th&gt;Cross-file&lt;/th&gt;
&lt;th&gt;Taint tracking&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Linting&lt;/td&gt;
&lt;td&gt;ESLint, Pylint, RuboCop&lt;/td&gt;
&lt;td&gt;Edit time&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Under 1 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security linting&lt;/td&gt;
&lt;td&gt;eslint-plugin-security, Bandit&lt;/td&gt;
&lt;td&gt;Edit time&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Under 1 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SAST — light&lt;/td&gt;
&lt;td&gt;Semgrep, CodeQL default&lt;/td&gt;
&lt;td&gt;PR check&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;1–5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SAST — deep&lt;/td&gt;
&lt;td&gt;Checkmarx, Veracode, Snyk Code&lt;/td&gt;
&lt;td&gt;Weekly&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;30 min+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DAST&lt;/td&gt;
&lt;td&gt;OWASP ZAP, Burp Suite&lt;/td&gt;
&lt;td&gt;QA / staging&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Minutes–hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;DAST (Dynamic Application Security Testing) tests a running application, not source — out of scope here, but it catches categories, auth state, session management, server-side rendering injection, that no source-code read can reach.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Chess, B., &amp;amp; West, J. (2007). &lt;em&gt;&lt;a href="https://ofriperetz.dev/go/r/1y0jz49es4u?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Secure Programming with Static Analysis&lt;/a&gt;&lt;/em&gt;. Addison-Wesley. The foundational industrial treatment of static analysis for security — data-flow and taint techniques, rule design, and the false-positive economics that separate a linter from a SAST engine.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;NIST IR 8397: &lt;em&gt;&lt;a href="https://ofriperetz.dev/go/r/1ejx87mlrw5?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Guidelines on Minimum Standards for Developer Verification of Software&lt;/a&gt;&lt;/em&gt;. National Institute of Standards and Technology, 2021. nvlpubs.nist.gov/nistpubs/ir/2021/NIST.IR.8397.pdf. Note that NIST itself uses "static analysis" and "SAST" interchangeably here — even listing ESLint among its example SAST tools — which is itself a data point on how loosely the industry applies the label.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://ofriperetz.dev/go/r/222q1ikavwm?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;OWASP Source Code Analysis Tools&lt;/a&gt;. owasp.org/www-community/Source_Code_Analysis_Tools. OWASP's community-maintained list of source-code (SAST) analysis tools. For the automated-analysis-vs-manual-review distinction specifically, see OWASP's &lt;a href="https://ofriperetz.dev/go/r/1n8jtd57ez1?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Static Code Analysis&lt;/a&gt; control page.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Livshits, B., &amp;amp; Lam, M. S. (2005). &lt;a href="https://ofriperetz.dev/go/r/2gddhspwbrs?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Finding security vulnerabilities in Java applications with static analysis&lt;/a&gt;. &lt;em&gt;USENIX Security Symposium&lt;/em&gt;, 14. The structural basis for what makes taint analysis different from pattern matching.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Zakas, N. C. (2013, July 16). Introducing ESLint. humanwhocodes.com. &lt;a href="https://humanwhocodes.com/blog/2013/07/16/introducing-eslint/" rel="noopener noreferrer"&gt;https://humanwhocodes.com/blog/2013/07/16/introducing-eslint/&lt;/a&gt;. The original announcement of ESLint's pluggable, per-file rule architecture — the design decision this whole taxonomy hangs on.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;This is the reference for the next time a scan result gets called "SAST" and it's actually a lint rule — bookmark it, and &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;follow me on Dev.to&lt;/a&gt; for the rest of the Foundations series.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Foundations series: ← &lt;a href="https://ofriperetz.dev/go/taint-vs-heuristic-detection?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Taint vs. Heuristic Detection&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/16wihjeozse?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;hub&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/confusion-matrix-tp-fp-fn-tn?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;start over: The Confusion Matrix&lt;/a&gt; →&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part of the &lt;a href="https://ofriperetz.dev/go/r/8v7joo8tnv?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Interlace ESLint ecosystem&lt;/a&gt;. Source on &lt;a href="https://ofriperetz.dev/go/gh/ofri-peretz/eslint?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · npm: &lt;a href="https://ofriperetz.dev/go/r/29m8v07vw3v?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;@interlace&lt;/a&gt; · Follow: &lt;a href="https://ofriperetz.dev/go/r/2cvyogppkrw?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;Dev.to/ofri-peretz&lt;/a&gt; · &lt;a href="https://ofriperetz.dev/go/r/jp3wgc57f7?utm_source=devto&amp;amp;from=static-analysis-vs-sast-vs-linting" rel="noopener noreferrer"&gt;ofriperetz.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>node</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
