<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>AppSec | FirewallSync</title><link>https://firewallsync.com/tags/appsec/</link><description>Practical cybersecurity writing for engineering teams that ship fast.</description><language>en-us</language><image><url>https://firewallsync.com/icon-512.png</url><title>FirewallSync</title><link>https://firewallsync.com/</link></image><lastBuildDate>Thu, 05 Nov 2026 04:40:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/appsec/index.xml" rel="self" type="application/rss+xml"/><item><title>CVE packageURL: Join CVE JSON 5.2 Records to SBOM purls</title><link>https://firewallsync.com/posts/cve-packageurl-purl-cve-json-5-2-affected-sbom-matching/</link><guid>https://firewallsync.com/posts/cve-packageurl-purl-cve-json-5-2-affected-sbom-matching/</guid><pubDate>Thu, 05 Nov 2026 04:40:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>CVE Record Format 5.2.0 added an optional packageURL field to affected entries. We counted it across all 400,445 published records: 1.6% carry one, 12 CNAs produce them, and a third of GitHub purls are not in canonical lowercase. Here is what the field means, how to normalize it, and a tested Python join against a CycloneDX SBOM.</description><enclosure url="https://firewallsync.com/posts/cve-packageurl-purl-cve-json-5-2-affected-sbom-matching/cover_hu10950611722794385026.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/cve-packageurl-purl-cve-json-5-2-affected-sbom-matching/cover_hu10950611722794385026.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/cve-packageurl-purl-cve-json-5-2-affected-sbom-matching/cover_hu10950611722794385026.jpg" alt="Black-and-white close-up of a bank of grey metal mailboxes, each door marked with a box number and a four-digit unit number" width="1200" height="630"></p><p><strong>Scope.</strong> This is a specification explainer with measurements from public data. We read the CVE Record Format schema on the CVEProject/cve-schema main branch (default <code>dataVersion</code> 5.2.0), RFD 0002 in the same repository, and the Package URL specification in the package-url/purl-spec repository. We counted fields across every record in the cvelistV5 release <code>cve_2026-11-05_0400Z</code> (the &ldquo;all CVEs at midnight&rdquo; bundle for 5 November 2026) with Python 3.13, and ran the join script below against that data. The script is illustrative.</p>
<h2 id="the-short-answer">The short answer</h2>
<p>Since CVE Record Format 5.2.0, each entry in a record&rsquo;s <code>affected</code> array can carry a <code>packageURL</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;vendor&#34;</span>: <span style="color:#e6db74">&#34;fast-uri&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;product&#34;</span>: <span style="color:#e6db74">&#34;fast-uri&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;packageURL&#34;</span>: <span style="color:#e6db74">&#34;pkg:npm/fast-uri&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;defaultStatus&#34;</span>: <span style="color:#e6db74">&#34;unaffected&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;versions&#34;</span>: [
</span></span><span style="display:flex;"><span>    {<span style="color:#f92672">&#34;versionType&#34;</span>: <span style="color:#e6db74">&#34;semver&#34;</span>, <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;affected&#34;</span>, <span style="color:#f92672">&#34;version&#34;</span>: <span style="color:#e6db74">&#34;2.4.2&#34;</span>, <span style="color:#f92672">&#34;lessThan&#34;</span>: <span style="color:#e6db74">&#34;2.4.8&#34;</span>}
</span></span><span style="display:flex;"><span>  ]
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That fragment is from CVE-2026-110180 (trimmed to its first version range). Three rules decide how you use it:</p>
<ol>
<li><strong>No version in the purl.</strong> The schema says the Package URL &ldquo;MUST NOT include a version&rdquo;. Strip the version from your SBOM purl before you look it up.</li>
<li><strong>The versions array still decides.</strong> A purl match finds the records about your package. Whether your version is affected comes from <code>versions</code> and <code>defaultStatus</code>, read with the record&rsquo;s <code>versionType</code>.</li>
<li><strong>It is optional and still rare.</strong> 1.6% of published records carry it today, from 12 CNAs. Treat it as a fast, precise path where it exists, and keep your CPE and name-based matching for the rest.</li>
</ol>
<h2 id="what-the-schema-says">What the schema says</h2>
<p>The field definition in the 5.2.0 schema:</p>
<blockquote>
<p>&ldquo;A Package URL, a unified URL specification for identifying packages hosted by known package hosts. The Package URL MUST NOT include a version.&rdquo;</p>
</blockquote>
<p>The schema types it as a URI and lists examples such as <code>pkg:deb/debian/curl?arch=i386&amp;distro=jessie</code>, <code>pkg:npm/%40angular/animation</code> and <code>pkg:golang/google.golang.org/genproto#googleapis/api/annotations</code>. So qualifiers and subpaths are allowed; only the version is out.</p>
<p><code>packageURL</code> does not satisfy the identifier requirement on its own. The <code>product</code> definition still requires either <code>vendor</code> and <code>product</code> or <code>collectionURL</code> and <code>packageName</code>, plus either <code>versions</code> or <code>defaultStatus</code>. In our scan, every one of the 7,324 entries with a purl also had one of those identifier pairs.</p>
<h3 id="why-it-was-added">Why it was added</h3>
<p>RFD 0002, &ldquo;Supporting Package URLs in CVE&rdquo;, is the design record. It argues that the existing identifier fields &ldquo;are of limited value for automated applicability matching&rdquo; and that Package URLs &ldquo;are better suited than CPEs for capturing distinctions between different distributions of particular open source software&rdquo;.</p>
<p>Two design choices in the RFD matter to consumers:</p>
<ul>
<li><strong>Validation is mostly outside the schema.</strong> The JSON schema only checks that the value is a URI; the RFD says it &ldquo;would be further constrained by CVE Services&rdquo; to ensure the values are valid purls, because encoding purl rules in a regular expression &ldquo;would be excessive and difficult to maintain&rdquo;. Do not assume the schema has validated purl syntax for you.</li>
<li><strong>Versions live in one place.</strong> &ldquo;All version information should only be included within the existing <code>versions</code> field.&rdquo; That keeps one version-matching code path for consumers.</li>
</ul>
<p>The cve-schema repository tags <code>v5.2.0</code>; the tagged commit is dated 3 September 2025. Records that declare <code>dataVersion</code> 5.1 cannot use the field. In our snapshot, 144,619 published records (36.1%) declared 5.2 and 255,826 declared 5.1.</p>
<h2 id="what-the-data-shows">What the data shows</h2>
<p>We read every record in the 5 November 2026 bundle: 400,445 published and 18,890 rejected. Only CNA containers carried the field; no ADP container had a <code>packageURL</code>.</p>
<table>
  <thead>
      <tr>
          <th>Measure</th>
          <th>Count</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Published records with a CNA <code>packageURL</code></td>
          <td>6,284 (1.57%)</td>
      </tr>
      <tr>
          <td><code>affected</code> entries with a <code>packageURL</code></td>
          <td>7,324</td>
      </tr>
      <tr>
          <td>CNAs that use it</td>
          <td>12</td>
      </tr>
      <tr>
          <td>Purls that embed a version</td>
          <td>0</td>
      </tr>
      <tr>
          <td>Purls with qualifiers</td>
          <td>76</td>
      </tr>
      <tr>
          <td>Entries with both <code>packageURL</code> and <code>cpes</code></td>
          <td>863</td>
      </tr>
  </tbody>
</table>
<h3 id="who-writes-it">Who writes it</h3>
<table>
  <thead>
      <tr>
          <th>CNA (short name)</th>
          <th>Records</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>VulnCheck</td>
          <td>4,949</td>
      </tr>
      <tr>
          <td>apache</td>
          <td>455</td>
      </tr>
      <tr>
          <td>EEF</td>
          <td>387</td>
      </tr>
      <tr>
          <td>CPANSec</td>
          <td>199</td>
      </tr>
      <tr>
          <td>openjs</td>
          <td>147</td>
      </tr>
      <tr>
          <td>mitre</td>
          <td>61</td>
      </tr>
      <tr>
          <td>bcorg</td>
          <td>51</td>
      </tr>
      <tr>
          <td>RTI</td>
          <td>31</td>
      </tr>
      <tr>
          <td>Four others</td>
          <td>1 each</td>
      </tr>
  </tbody>
</table>
<p>VulnCheck accounts for 78.8% of records with a purl. If you build matching around this field, most of your hits will come from one CNA&rsquo;s conventions, so test against its records.</p>
<h3 id="which-purl-types">Which purl types</h3>
<p>The most common types across the 7,324 entries: <code>github</code> 2,371, <code>npm</code> 1,729, <code>pypi</code> 805, <code>maven</code> 533, <code>golang</code> 498, <code>composer</code> 314, <code>hex</code> 309, <code>cargo</code> 242, <code>cpan</code> 210, <code>otp</code> 82, <code>software-id</code> 72, <code>nuget</code> 40, <code>gem</code> 31, <code>generic</code> 31.</p>
<p>Two of those need care:</p>
<ul>
<li><strong><code>pkg:github</code></strong> identifies a source repository, not a registry package. VulnCheck wrote 1,917 of the GitHub entries and EEF 413. Your SBOM will usually describe the same software as <code>pkg:npm/...</code> or <code>pkg:pypi/...</code>, so a <code>pkg:github</code> record will not match it directly. You need a mapping from repository to registry package, or a fallback on <code>collectionURL</code> and <code>packageName</code>.</li>
<li><strong><code>pkg:software-id</code></strong> appears 72 times (for example <code>pkg:software-id/erlang.org/otp</code>), but the purl-spec decision record that registers it, dated 21 October 2026, still has the status &ldquo;proposed&rdquo;. Expect parsers to reject it until the type is registered.</li>
</ul>
<h3 id="how-fast-it-is-growing">How fast it is growing</h3>
<p>By the month each record was first published (as the records stand today, so later updates are included):</p>
<table>
  <thead>
      <tr>
          <th>Month</th>
          <th>Records with a purl</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>March 2026</td>
          <td>256</td>
      </tr>
      <tr>
          <td>June 2026</td>
          <td>447</td>
      </tr>
      <tr>
          <td>August 2026</td>
          <td>976</td>
      </tr>
      <tr>
          <td>September 2026</td>
          <td>1,155</td>
      </tr>
      <tr>
          <td>October 2026</td>
          <td>2,000</td>
      </tr>
  </tbody>
</table>
<p>October&rsquo;s 2,000 are 11.7% of the 17,058 records first published that month.</p>
<h2 id="normalize-before-you-join">Normalize before you join</h2>
<p>The purl specification makes the <code>type</code> case-insensitive with lowercase as the canonical form, and some types go further. The type definitions say a GitHub namespace and name are &ldquo;not case sensitive and shall be lowercased&rdquo;, and a PyPI name &ldquo;shall be lowercased and underscore _ replaced with a dash -&rdquo;. npm names are case-sensitive.</p>
<p>CVE records do not always follow those rules:</p>
<ul>
<li>756 of the 2,371 <code>pkg:github</code> entries (31.9%) contain uppercase letters, for example <code>pkg:github/Finsys/dockhand</code>.</li>
<li>9 <code>pkg:pypi</code> entries have uppercase letters and 24 contain an underscore.</li>
<li>GitPython is a concrete case. In the 34 records we found for it, 28 use <code>pkg:pypi/gitpython</code> and 6 use <code>pkg:pypi/GitPython</code>. An exact string join misses one set or the other, depending on how your SBOM tool spells it.</li>
</ul>
<p>So normalize both sides the same way before comparing: lowercase the type; lowercase <code>github</code> and <code>pypi</code> paths; replace <code>_</code> with <code>-</code> for <code>pypi</code>; drop the version and qualifiers from the SBOM purl. Leave npm, Maven and other case-sensitive types alone.</p>
<h2 id="a-tested-join">A tested join</h2>
<p>Two scripts. The first builds an index from the cvelistV5 bundle; the second joins a CycloneDX SBOM to it and applies the <code>versions</code> ranges.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e">#!/usr/bin/env python3</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;build_index.py: index CNA affected[] entries that carry packageURL,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">keyed by a normalized purl (no version, no qualifiers).&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json<span style="color:#f92672">,</span> sys<span style="color:#f92672">,</span> zipfile
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">norm</span>(purl):
</span></span><span style="display:flex;"><span>    core <span style="color:#f92672">=</span> purl<span style="color:#f92672">.</span>split(<span style="color:#e6db74">&#34;#&#34;</span>)[<span style="color:#ae81ff">0</span>]<span style="color:#f92672">.</span>split(<span style="color:#e6db74">&#34;?&#34;</span>)[<span style="color:#ae81ff">0</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;@&#34;</span> <span style="color:#f92672">in</span> core<span style="color:#f92672">.</span>rsplit(<span style="color:#e6db74">&#34;/&#34;</span>, <span style="color:#ae81ff">1</span>)[<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>]:                 <span style="color:#75715e"># drop a version (SBOM side)</span>
</span></span><span style="display:flex;"><span>        core <span style="color:#f92672">=</span> core<span style="color:#f92672">.</span>rsplit(<span style="color:#e6db74">&#34;@&#34;</span>, <span style="color:#ae81ff">1</span>)[<span style="color:#ae81ff">0</span>]
</span></span><span style="display:flex;"><span>    scheme, rest <span style="color:#f92672">=</span> core<span style="color:#f92672">.</span>split(<span style="color:#e6db74">&#34;:&#34;</span>, <span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    ptype, _, path <span style="color:#f92672">=</span> rest<span style="color:#f92672">.</span>partition(<span style="color:#e6db74">&#34;/&#34;</span>)
</span></span><span style="display:flex;"><span>    ptype <span style="color:#f92672">=</span> ptype<span style="color:#f92672">.</span>lower()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ptype <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;github&#34;</span>, <span style="color:#e6db74">&#34;pypi&#34;</span>):                     <span style="color:#75715e"># case-insensitive types per purl-spec</span>
</span></span><span style="display:flex;"><span>        path <span style="color:#f92672">=</span> path<span style="color:#f92672">.</span>lower()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ptype <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;pypi&#34;</span>:
</span></span><span style="display:flex;"><span>        path <span style="color:#f92672">=</span> path<span style="color:#f92672">.</span>replace(<span style="color:#e6db74">&#34;_&#34;</span>, <span style="color:#e6db74">&#34;-&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;pkg:</span><span style="color:#e6db74">{</span>ptype<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">{</span>path<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">main</span>(zip_path, out_path):
</span></span><span style="display:flex;"><span>    z <span style="color:#f92672">=</span> zipfile<span style="color:#f92672">.</span>ZipFile(zip_path)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> open(out_path, <span style="color:#e6db74">&#34;w&#34;</span>) <span style="color:#66d9ef">as</span> out:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> z<span style="color:#f92672">.</span>namelist():
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> n<span style="color:#f92672">.</span>endswith(<span style="color:#e6db74">&#34;.json&#34;</span>) <span style="color:#f92672">or</span> <span style="color:#e6db74">&#34;/CVE-&#34;</span> <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> n:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>            rec <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>loads(z<span style="color:#f92672">.</span>read(n))
</span></span><span style="display:flex;"><span>            meta <span style="color:#f92672">=</span> rec[<span style="color:#e6db74">&#34;cveMetadata&#34;</span>]
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> meta<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;state&#34;</span>) <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#34;PUBLISHED&#34;</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">for</span> a <span style="color:#f92672">in</span> rec[<span style="color:#e6db74">&#34;containers&#34;</span>]<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;cna&#34;</span>, {})<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;affected&#34;</span>, []):
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> a<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;packageURL&#34;</span>):
</span></span><span style="display:flex;"><span>                    out<span style="color:#f92672">.</span>write(json<span style="color:#f92672">.</span>dumps({<span style="color:#e6db74">&#34;key&#34;</span>: norm(a[<span style="color:#e6db74">&#34;packageURL&#34;</span>]), <span style="color:#e6db74">&#34;cve&#34;</span>: meta[<span style="color:#e6db74">&#34;cveId&#34;</span>],
</span></span><span style="display:flex;"><span>                        <span style="color:#e6db74">&#34;defaultStatus&#34;</span>: a<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;defaultStatus&#34;</span>), <span style="color:#e6db74">&#34;versions&#34;</span>: a<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;versions&#34;</span>, [])}) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    main(sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">1</span>], sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">2</span>])
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e">#!/usr/bin/env python3</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;join_sbom.py: join CycloneDX component purls to the CVE packageURL index, then apply versions[] ranges.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Illustrative: only semver-like versionType values are compared; everything else is flagged for review.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json<span style="color:#f92672">,</span> sys
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> packaging.version <span style="color:#f92672">import</span> Version, InvalidVersion
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> build_index <span style="color:#f92672">import</span> norm
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">status_for</span>(ver, entry):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>        v <span style="color:#f92672">=</span> Version(ver)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">except</span> InvalidVersion:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#34;review: unparseable version&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> entry[<span style="color:#e6db74">&#34;versions&#34;</span>]:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> r<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;versionType&#34;</span>) <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;semver&#34;</span>, <span style="color:#e6db74">&#34;maven&#34;</span>, <span style="color:#e6db74">&#34;python&#34;</span>, <span style="color:#66d9ef">None</span>):
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;review: versionType=</span><span style="color:#e6db74">{</span>r<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;versionType&#39;</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>            lo <span style="color:#f92672">=</span> Version(r[<span style="color:#e6db74">&#34;version&#34;</span>]) <span style="color:#66d9ef">if</span> r[<span style="color:#e6db74">&#34;version&#34;</span>] <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;0&#34;</span>, <span style="color:#e6db74">&#34;*&#34;</span>) <span style="color:#66d9ef">else</span> Version(<span style="color:#e6db74">&#34;0&#34;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;lessThan&#34;</span> <span style="color:#f92672">in</span> r:
</span></span><span style="display:flex;"><span>                inside <span style="color:#f92672">=</span> lo <span style="color:#f92672">&lt;=</span> v <span style="color:#f92672">&lt;</span> Version(r[<span style="color:#e6db74">&#34;lessThan&#34;</span>])
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">elif</span> <span style="color:#e6db74">&#34;lessThanOrEqual&#34;</span> <span style="color:#f92672">in</span> r:
</span></span><span style="display:flex;"><span>                inside <span style="color:#f92672">=</span> lo <span style="color:#f92672">&lt;=</span> v <span style="color:#f92672">&lt;=</span> Version(r[<span style="color:#e6db74">&#34;lessThanOrEqual&#34;</span>])
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>                inside <span style="color:#f92672">=</span> v <span style="color:#f92672">==</span> lo
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">except</span> InvalidVersion:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#34;review: unparseable range&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> inside:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> r[<span style="color:#e6db74">&#34;status&#34;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> entry<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;defaultStatus&#34;</span>) <span style="color:#f92672">or</span> <span style="color:#e6db74">&#34;unknown&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">main</span>(index_path, sbom_path):
</span></span><span style="display:flex;"><span>    index <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> line <span style="color:#f92672">in</span> open(index_path):
</span></span><span style="display:flex;"><span>        e <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>loads(line)
</span></span><span style="display:flex;"><span>        index<span style="color:#f92672">.</span>setdefault(e[<span style="color:#e6db74">&#34;key&#34;</span>], [])<span style="color:#f92672">.</span>append(e)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> c <span style="color:#f92672">in</span> json<span style="color:#f92672">.</span>load(open(sbom_path))[<span style="color:#e6db74">&#34;components&#34;</span>]:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> e <span style="color:#f92672">in</span> index<span style="color:#f92672">.</span>get(norm(c[<span style="color:#e6db74">&#34;purl&#34;</span>]), []):
</span></span><span style="display:flex;"><span>            print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;</span><span style="color:#e6db74">{</span>c[<span style="color:#e6db74">&#34;purl&#34;</span>]<span style="color:#e6db74">}</span><span style="color:#ae81ff">\t</span><span style="color:#e6db74">{</span>e[<span style="color:#e6db74">&#34;cve&#34;</span>]<span style="color:#e6db74">}</span><span style="color:#ae81ff">\t</span><span style="color:#e6db74">{</span>status_for(c[<span style="color:#e6db74">&#34;version&#34;</span>], e)<span style="color:#e6db74">}</span><span style="color:#e6db74">&#39;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    main(sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">1</span>], sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">2</span>])
</span></span></code></pre></div><h3 id="test-run">Test run</h3>
<p><code>python3 build_index.py cves.zip purl-index.jsonl</code> took about 30 seconds and wrote 7,324 lines, one per entry. We then ran <code>python3 join_sbom.py purl-index.jsonl sbom.cdx.json</code> (packaging 26.3) with a three-component CycloneDX 1.6 SBOM:</p>
<table>
  <thead>
      <tr>
          <th>SBOM purl</th>
          <th>Records matched</th>
          <th>Affected</th>
          <th>Unaffected</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>pkg:npm/fast-uri@2.4.5</code></td>
          <td>17</td>
          <td>10</td>
          <td>7</td>
      </tr>
      <tr>
          <td><code>pkg:pypi/GitPython@3.1.62</code></td>
          <td>34</td>
          <td>6</td>
          <td>28</td>
      </tr>
      <tr>
          <td><code>pkg:maven/org.bouncycastle/bcprov-jdk18on@1.86?type=jar</code></td>
          <td>23</td>
          <td>0</td>
          <td>23</td>
      </tr>
  </tbody>
</table>
<p>GitPython 3.1.62 comes out affected by the six records whose ranges end at <code>lessThan: 3.2.0</code>, and unaffected by the 28 whose fixes landed at or before 3.1.62. Without normalization, the SBOM spelling <code>GitPython</code> would have matched 6 records and missed 28. The <code>?type=jar</code> qualifier on the Maven purl is dropped before lookup.</p>
<p>What this does not handle: <code>versionType</code> values such as <code>custom</code>, <code>git</code> or distribution schemes (it flags them for review), <code>changes</code> arrays inside version ranges, purl types that need a repository-to-package mapping, and the order sensitivity of overlapping ranges. The <a href="/posts/cve-affected-versions-lessthan-defaultstatus-versiontype/">CVE affected versions guide</a> covers those rules in detail.</p>
<h2 id="when-the-cve-has-no-purl">When the CVE has no purl</h2>
<p>For the other 98% of records, you still need the older paths. The <a href="/posts/cve-cpeapplicability-cpe-match-nodes-operators-version-ranges/">cpeApplicability explainer</a> covers CPE-based matching, and <code>collectionURL</code> plus <code>packageName</code> is often enough to build a purl yourself: <code>https://www.npmjs.com/package/x</code> with <code>packageName</code> <code>x</code> is <code>pkg:npm/x</code>. Record which path produced each match, because a purl match and a name match deserve different confidence.</p>
<p>A match is only the start of the patch decision. In our <a href="/posts/vulnerability-prioritization-workflow-cve-record-to-patch-ticket/">vulnerability prioritization workflow</a>, the purl join is the &ldquo;does this apply to us&rdquo; step; KEV, EPSS and SSVC decide how fast. If your supplier also ships VEX, apply it after the join; our <a href="/posts/csaf-vex-product-tree-product-status-purl-sbom-matching/">CSAF VEX guide</a> shows how to resolve its product IDs to purls.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>The counts describe one snapshot (the 5 November 2026 midnight bundle); CNAs update records, so the numbers will move. Month counts use each record&rsquo;s original <code>datePublished</code>, so a purl added later to an old record is counted in the month the record was first published. We checked purl spelling only for the <code>github</code> and <code>pypi</code> rules; other types have their own normalization rules we did not test. We did not check how CVE Services validates purls at submission time. The join script uses Python&rsquo;s <code>packaging</code> version ordering, which is close to, but not the same as, every ecosystem&rsquo;s own ordering.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>packageURL definition, &ldquo;MUST NOT include a version&rdquo;, examples; identifier and versions/defaultStatus requirements</td>
          <td><a href="https://github.com/CVEProject/cve-schema/blob/main/schema/CVE_Record_Format.json">CVE Record Format schema (main)</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Rationale, CVE Services validation, versions only in versions field</td>
          <td><a href="https://github.com/CVEProject/cve-schema/blob/main/rfds/0002-supporting-package-urls.md">RFD 0002: Supporting Package URLs in CVE</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>v5.2.0 tag commit dated 3 September 2025</td>
          <td><a href="https://github.com/CVEProject/cve-schema/releases/tag/v5.2.0">cve-schema tag v5.2.0</a></td>
          <td>Qualified (commit date, not an announcement)</td>
      </tr>
      <tr>
          <td>All counts (records, CNAs, types, normalization issues, monthly figures)</td>
          <td><a href="https://github.com/CVEProject/cvelistV5/releases/tag/cve_2026-11-05_0400Z">cvelistV5 release cve_2026-11-05_0400Z</a>, counted with Python</td>
          <td>Verified (our count)</td>
      </tr>
      <tr>
          <td>CVE-2026-110180 affected fragment</td>
          <td><a href="https://github.com/CVEProject/cvelistV5/blob/main/cves/2026/110xxx/CVE-2026-110180.json">CVE-2026-110180 record</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>GitHub and PyPI normalization rules; npm case sensitivity</td>
          <td><a href="https://github.com/package-url/purl-spec/tree/main/types">purl-spec type definitions</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>purl type is case-insensitive, lowercase canonical form</td>
          <td><a href="https://github.com/package-url/purl-spec/blob/main/docs/specification/standard/Clause-5-Package-URL-Specification.md">purl-spec, Clause 5</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>software-id type decision status &ldquo;proposed&rdquo;, dated 21 October 2026</td>
          <td><a href="https://github.com/package-url/purl-spec/blob/main/docs/decisions/001-Register-software-id-PURL-type.md">purl-spec decision 001</a></td>
          <td>Verified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>CVE cpeApplicability: CPE Match Nodes, Operators, Ranges</title><link>https://firewallsync.com/posts/cve-cpeapplicability-cpe-match-nodes-operators-version-ranges/</link><guid>https://firewallsync.com/posts/cve-cpeapplicability-cpe-match-nodes-operators-version-ranges/</guid><pubDate>Wed, 04 Nov 2026 04:30:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>Since CVE Record Format 5.1.1, a CNA can put CPE Applicability Language statements in a cpeApplicability block. Here is how nodes, operator, negate, vulnerable and the four version bounds combine, what a 2,000-record sample of 2026 CVEs contains, and a tested Python evaluator that shows where the block and the affected block disagree.</description><enclosure url="https://firewallsync.com/posts/cve-cpeapplicability-cpe-match-nodes-operators-version-ranges/cover_hu3781550322919701848.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/cve-cpeapplicability-cpe-match-nodes-operators-version-ranges/cover_hu3781550322919701848.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/cve-cpeapplicability-cpe-match-nodes-operators-version-ranges/cover_hu3781550322919701848.jpg" alt="Top-down view of a dense heap of loose jigsaw puzzle pieces, picture side and grey backs mixed together, in orange, yellow, green and red" width="1200" height="630"></p><p><strong>Scope.</strong> This is a specification explainer with measurements from public data. We read the CVE Record Format schema on the CVEProject/cve-schema main branch (default <code>dataVersion</code> 5.2.0) and its advanced example record, sampled 2,000 random 2026 records from the cvelistV5 repository at commit f0b25a9 (November 4, 2026), and ran the Python script below with Python 3.13 against real records. We did not test commercial scanners or NVD&rsquo;s own matching engine.</p>
<h2 id="the-short-answer">The short answer</h2>
<p><code>cpeApplicability</code> is where a CNA can say, in CPE terms, which configurations a CVE applies to. Each array element is one statement:</p>
<ol>
<li>A statement has <code>nodes</code>, and optionally an <code>operator</code> and <code>negate</code> for combining them.</li>
<li>Each node has a required <code>operator</code> (<code>AND</code> or <code>OR</code>), an optional <code>negate</code>, and a <code>cpeMatch</code> list.</li>
<li>Each <code>cpeMatch</code> has a CPE 2.3 <code>criteria</code> string, a required <code>vulnerable</code> boolean, and optional bounds: <code>versionStartIncluding</code>, <code>versionStartExcluding</code>, <code>versionEndIncluding</code>, <code>versionEndExcluding</code>.</li>
<li>A host is in scope when the logic evaluates true against its CPE inventory <strong>and</strong> at least one matched entry has <code>vulnerable: true</code>.</li>
</ol>
<p>If you already parse NVD configurations, the shape is familiar. The schema says so directly: the block is &ldquo;mostly copied/forked from the NIST NVD CVE API v2.0 schema&rdquo;.</p>
<h2 id="where-the-block-lives">Where the block lives</h2>
<p>The schema defines <code>cpeApplicability</code> as an optional array on both the published CNA container and the ADP container. It was added in CVE Record Format 5.1.1 (the <code>v5.1.1</code> tag in CVEProject/cve-schema dates from October 2024), which the release notes describe as adding &ldquo;support for Common Platform Enumeration (CPE) Identifiers using the CPE Applicability Language&rdquo;.</p>
<p>It is separate from the older <code>cpes</code> array inside each <code>affected[]</code> product. That array is a flat list of CPE names; its own schema description now ends: &ldquo;Consider using the newer cpeApplicability block for defining CPE data using the CPE Applicability Language which includes more options for defining CPE Names.&rdquo;</p>
<p>The schema adds one instruction that matters for consumers: &ldquo;it is recommended that it align with (as much as possible) the product data provided within the affected block.&rdquo; Recommended, not required. If you already read the <code>affected</code> block with the rules in <a href="/posts/cve-affected-versions-lessthan-defaultstatus-versiontype/">our guide to lessThan, defaultStatus and versionType</a>, treat <code>cpeApplicability</code> as a second encoding of the same claim, and expect the two to disagree sometimes.</p>
<h2 id="the-fields-one-by-one">The fields, one by one</h2>
<table>
  <thead>
      <tr>
          <th>Level</th>
          <th>Field</th>
          <th>Required</th>
          <th>Meaning</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Element</td>
          <td><code>nodes</code></td>
          <td>yes</td>
          <td>Array of nodes</td>
      </tr>
      <tr>
          <td>Element</td>
          <td><code>operator</code></td>
          <td>no</td>
          <td><code>AND</code> or <code>OR</code> between nodes</td>
      </tr>
      <tr>
          <td>Element</td>
          <td><code>negate</code></td>
          <td>no</td>
          <td>Invert the element&rsquo;s result</td>
      </tr>
      <tr>
          <td>Node</td>
          <td><code>operator</code></td>
          <td>yes</td>
          <td><code>AND</code> or <code>OR</code> between the node&rsquo;s cpeMatch entries</td>
      </tr>
      <tr>
          <td>Node</td>
          <td><code>negate</code></td>
          <td>no</td>
          <td>Invert the node&rsquo;s result</td>
      </tr>
      <tr>
          <td>Node</td>
          <td><code>cpeMatch</code></td>
          <td>yes</td>
          <td>Array of match entries</td>
      </tr>
      <tr>
          <td>cpeMatch</td>
          <td><code>criteria</code></td>
          <td>yes</td>
          <td>CPE 2.3 formatted string, e.g. <code>cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*</code></td>
      </tr>
      <tr>
          <td>cpeMatch</td>
          <td><code>vulnerable</code></td>
          <td>yes</td>
          <td><code>true</code> for the vulnerable product, <code>false</code> for a platform or context CPE</td>
      </tr>
      <tr>
          <td>cpeMatch</td>
          <td><code>versionStartIncluding</code> / <code>versionStartExcluding</code></td>
          <td>no</td>
          <td>Lower bound, inclusive or exclusive</td>
      </tr>
      <tr>
          <td>cpeMatch</td>
          <td><code>versionEndIncluding</code> / <code>versionEndExcluding</code></td>
          <td>no</td>
          <td>Upper bound, inclusive or exclusive</td>
      </tr>
      <tr>
          <td>cpeMatch</td>
          <td><code>matchCriteriaId</code></td>
          <td>no</td>
          <td>UUID, carried over from NVD&rsquo;s schema</td>
      </tr>
  </tbody>
</table>
<p>Two details trip people up.</p>
<p><strong>Version bounds apply only when the CPE&rsquo;s version field is a wildcard.</strong> A criteria string such as <code>cpe:2.3:a:ibm:mq_operator:3.4.1:*:...</code> names one exact version; a string with <code>*</code> in the version position plus bounds names a range. Our evaluator treats a specific version in the criteria as an exact match and ignores bounds in that case.</p>
<p><strong>There is no <code>versionType</code>.</strong> The <code>affected</code> block tells you whether versions are semver, git commits, or a custom scheme; <code>cpeMatch</code> does not. Whatever compares <code>6.18.9</code> with <code>6.19</code> has to guess the ordering, and for strings like <code>alpha_0.3</code> or <code>1.3-beta</code> there may be no safe guess.</p>
<h2 id="and-or-and-vulnerable-false">AND, OR and vulnerable: false</h2>
<p>The schema&rsquo;s advanced example record shows the pattern that makes this block more expressive than a flat list. Its single element has <code>&quot;operator&quot;: &quot;AND&quot;</code> and two nodes:</p>
<ul>
<li>Node 1 (<code>OR</code>): <code>example_org:example_enterprise</code> from 1.0.0 up to (excluding) 1.0.6, or from 2.1.6 up to 2.1.9, each with <code>vulnerable: true</code>.</li>
<li>Node 2 (<code>OR</code>): Windows, macOS or a hardware model <code>xt-4500</code>, each with <code>vulnerable: false</code>.</li>
</ul>
<p>Read it as: the application is vulnerable in those ranges <strong>when running on</strong> one of those platforms. The platform CPEs are conditions, not affected products. A tool that flags every host whose inventory contains <code>cpe:2.3:o:microsoft:windows:...</code> has misread the record.</p>
<h2 id="what-a-sample-of-2026-records-contains">What a sample of 2026 records contains</h2>
<p>We took a seeded random sample of 2,000 files from the 84,849 2026 records in cvelistV5 at commit f0b25a9; 1,969 were published. The counting script parsed each record&rsquo;s CNA and ADP containers.</p>
<table>
  <thead>
      <tr>
          <th>Measure</th>
          <th>Count</th>
          <th>Share</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Published records with a CNA <code>cpeApplicability</code> block</td>
          <td>538 of 1,969</td>
          <td>27.3%</td>
      </tr>
      <tr>
          <td>&hellip;of which from Linux, VulnCheck, Microsoft or Oracle (186, 110, 97, 84)</td>
          <td>477 of 538</td>
          <td>88.7%</td>
      </tr>
      <tr>
          <td>ADP containers carrying <code>cpeApplicability</code></td>
          <td>0</td>
          <td>0%</td>
      </tr>
      <tr>
          <td>Records still using the flat <code>affected[].cpes</code> list</td>
          <td>195 of 1,969</td>
          <td>9.9%</td>
      </tr>
      <tr>
          <td>Elements with no element-level <code>operator</code></td>
          <td>489 of 582</td>
          <td>84.0%</td>
      </tr>
      <tr>
          <td>Nodes, all with <code>operator: OR</code></td>
          <td>598</td>
          <td>100%</td>
      </tr>
      <tr>
          <td>cpeMatch entries</td>
          <td>2,667</td>
          <td></td>
      </tr>
      <tr>
          <td>&hellip;with <code>vulnerable: false</code></td>
          <td>10</td>
          <td>0.4%</td>
      </tr>
      <tr>
          <td>&hellip;with a specific version in <code>criteria</code> rather than <code>*</code></td>
          <td>170</td>
          <td>6.4%</td>
      </tr>
      <tr>
          <td>&hellip;with <code>versionStartIncluding</code> / <code>versionEndExcluding</code> / <code>versionEndIncluding</code></td>
          <td>2,397 / 2,333 / 113</td>
          <td>89.9% / 87.5% / 4.2%</td>
      </tr>
      <tr>
          <td>&hellip;with <code>matchCriteriaId</code></td>
          <td>0</td>
          <td>0%</td>
      </tr>
  </tbody>
</table>
<p>Three practical readings. First, most records have no CPE statement from the CNA at all, so a CPE-only matcher misses most of the corpus. Second, the real-world block is simpler than the spec allows: almost everything is one OR node of ranges, and <code>AND</code> with platform CPEs is rare. Third, coverage depends on a handful of CNAs, so your hit rate will swing with your vendor mix. An earlier 1,000-record sample we took for the affected-versions guide found 29.5%; treat the figure as a range, not a constant.</p>
<p>The absence in ADP containers fits CISA&rsquo;s stated policy. The Vulnrichment README says: &ldquo;as of December 10, 2024, CISA will no longer be adding CPE strings to the enriched dataset.&rdquo; See <a href="/posts/cve-adp-containers-cisa-adp-cve-program-supplier-sadp/">our explainer on CVE ADP containers</a> for what CISA-ADP does still add.</p>
<h2 id="a-tested-evaluator">A tested evaluator</h2>
<p>The script takes one CVE record and every CPE installed on one host, and answers <code>True</code>, <code>False</code> or <code>unknown</code>. It is illustrative: it compares only dotted numeric versions, handles the <code>\:</code>-style escaping in CPE 2.3 names only for splitting, and matches part, vendor and product but ignores the remaining CPE fields.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;Evaluate a CVE record&#39;s cna.cpeApplicability against one host&#39;s CPE inventory.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Illustrative: numeric-dotted versions only; anything else is reported as &#39;unknown&#39;.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json<span style="color:#f92672">,</span> re<span style="color:#f92672">,</span> sys
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">split_cpe</span>(cpe):
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># split on &#39;:&#39; not preceded by a backslash (CPE 2.3 formatted-string escaping)</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> re<span style="color:#f92672">.</span>split(<span style="color:#e6db74">r</span><span style="color:#e6db74">&#39;(?&lt;!</span><span style="color:#ae81ff">\\</span><span style="color:#e6db74">):&#39;</span>, cpe)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">vkey</span>(v):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> re<span style="color:#f92672">.</span>fullmatch(<span style="color:#e6db74">r</span><span style="color:#e6db74">&#39;\d+(\.\d+)*&#39;</span>, v):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(v)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> tuple(int(x) <span style="color:#66d9ef">for</span> x <span style="color:#f92672">in</span> v<span style="color:#f92672">.</span>split(<span style="color:#e6db74">&#39;.&#39;</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">cmp</span>(a, b):
</span></span><span style="display:flex;"><span>    a, b <span style="color:#f92672">=</span> vkey(a), vkey(b)
</span></span><span style="display:flex;"><span>    n <span style="color:#f92672">=</span> max(len(a), len(b))
</span></span><span style="display:flex;"><span>    a, b <span style="color:#f92672">=</span> a <span style="color:#f92672">+</span> (<span style="color:#ae81ff">0</span>,) <span style="color:#f92672">*</span> (n <span style="color:#f92672">-</span> len(a)), b <span style="color:#f92672">+</span> (<span style="color:#ae81ff">0</span>,) <span style="color:#f92672">*</span> (n <span style="color:#f92672">-</span> len(b))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> (a <span style="color:#f92672">&gt;</span> b) <span style="color:#f92672">-</span> (a <span style="color:#f92672">&lt;</span> b)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">match</span>(m, installed_cpe):
</span></span><span style="display:flex;"><span>    want, have <span style="color:#f92672">=</span> split_cpe(m[<span style="color:#e6db74">&#39;criteria&#39;</span>]), split_cpe(installed_cpe)
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># compare part, vendor, product (fields 2-4); &#39;*&#39; is ANY</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i <span style="color:#f92672">in</span> (<span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">3</span>, <span style="color:#ae81ff">4</span>):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> want[i] <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#39;*&#39;</span> <span style="color:#f92672">and</span> want[i] <span style="color:#f92672">!=</span> have[i]:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>    ver <span style="color:#f92672">=</span> have[<span style="color:#ae81ff">5</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> want[<span style="color:#ae81ff">5</span>] <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#39;*&#39;</span>, <span style="color:#e6db74">&#39;-&#39;</span>):            <span style="color:#75715e"># exact version in the criteria</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> want[<span style="color:#ae81ff">5</span>] <span style="color:#f92672">==</span> ver
</span></span><span style="display:flex;"><span>    checks <span style="color:#f92672">=</span> [(<span style="color:#e6db74">&#39;versionStartIncluding&#39;</span>, <span style="color:#66d9ef">lambda</span> c: c <span style="color:#f92672">&gt;=</span> <span style="color:#ae81ff">0</span>), (<span style="color:#e6db74">&#39;versionStartExcluding&#39;</span>, <span style="color:#66d9ef">lambda</span> c: c <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">0</span>),
</span></span><span style="display:flex;"><span>              (<span style="color:#e6db74">&#39;versionEndIncluding&#39;</span>, <span style="color:#66d9ef">lambda</span> c: c <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">0</span>), (<span style="color:#e6db74">&#39;versionEndExcluding&#39;</span>, <span style="color:#66d9ef">lambda</span> c: c <span style="color:#f92672">&lt;</span> <span style="color:#ae81ff">0</span>)]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> all(test(cmp(ver, m[k])) <span style="color:#66d9ef">for</span> k, test <span style="color:#f92672">in</span> checks <span style="color:#66d9ef">if</span> k <span style="color:#f92672">in</span> m)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">present</span>(m, inventory):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> any(<span style="color:#66d9ef">match</span>(m, c) <span style="color:#66d9ef">for</span> c <span style="color:#f92672">in</span> inventory)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">node</span>(n, inv):
</span></span><span style="display:flex;"><span>    hits <span style="color:#f92672">=</span> [present(m, inv) <span style="color:#66d9ef">for</span> m <span style="color:#f92672">in</span> n[<span style="color:#e6db74">&#39;cpeMatch&#39;</span>]]
</span></span><span style="display:flex;"><span>    r <span style="color:#f92672">=</span> all(hits) <span style="color:#66d9ef">if</span> n[<span style="color:#e6db74">&#39;operator&#39;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#39;AND&#39;</span> <span style="color:#66d9ef">else</span> any(hits)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#f92672">not</span> r <span style="color:#66d9ef">if</span> n<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;negate&#39;</span>) <span style="color:#66d9ef">else</span> r
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">element</span>(el, inv):
</span></span><span style="display:flex;"><span>    res <span style="color:#f92672">=</span> [node(n, inv) <span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> el[<span style="color:#e6db74">&#39;nodes&#39;</span>]]
</span></span><span style="display:flex;"><span>    r <span style="color:#f92672">=</span> all(res) <span style="color:#66d9ef">if</span> el<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;operator&#39;</span>) <span style="color:#f92672">==</span> <span style="color:#e6db74">&#39;AND&#39;</span> <span style="color:#66d9ef">else</span> any(res)  <span style="color:#75715e"># no operator: OR</span>
</span></span><span style="display:flex;"><span>    r <span style="color:#f92672">=</span> <span style="color:#f92672">not</span> r <span style="color:#66d9ef">if</span> el<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;negate&#39;</span>) <span style="color:#66d9ef">else</span> r
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># the statement must also hit at least one entry flagged vulnerable</span>
</span></span><span style="display:flex;"><span>    vuln <span style="color:#f92672">=</span> any(m[<span style="color:#e6db74">&#39;vulnerable&#39;</span>] <span style="color:#f92672">and</span> present(m, inv)
</span></span><span style="display:flex;"><span>               <span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> el[<span style="color:#e6db74">&#39;nodes&#39;</span>] <span style="color:#66d9ef">for</span> m <span style="color:#f92672">in</span> n[<span style="color:#e6db74">&#39;cpeMatch&#39;</span>])
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> r <span style="color:#f92672">and</span> vuln
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">evaluate</span>(record, inventory):
</span></span><span style="display:flex;"><span>    cna <span style="color:#f92672">=</span> record[<span style="color:#e6db74">&#39;containers&#39;</span>][<span style="color:#e6db74">&#39;cna&#39;</span>]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> any(element(el, inventory) <span style="color:#66d9ef">for</span> el <span style="color:#f92672">in</span> cna<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;cpeApplicability&#39;</span>, []))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">except</span> <span style="color:#a6e22e">ValueError</span> <span style="color:#66d9ef">as</span> e:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#39;unknown (cannot order version </span><span style="color:#e6db74">{</span>e<span style="color:#e6db74">}</span><span style="color:#e6db74">)&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#39;__main__&#39;</span>:
</span></span><span style="display:flex;"><span>    rec <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>load(open(sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">1</span>]))
</span></span><span style="display:flex;"><span>    inventory <span style="color:#f92672">=</span> sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">2</span>:]          <span style="color:#75715e"># every CPE installed on one host</span>
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">&#39; + &#39;</span><span style="color:#f92672">.</span>join(inventory), <span style="color:#e6db74">&#39;-&gt;&#39;</span>, evaluate(rec, inventory))
</span></span></code></pre></div><p>Saved as <code>cpe_eval.py</code>, we ran it against three records from the sample and the schema&rsquo;s example (output trimmed to the verdict):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>$ python3 -I cpe_eval.py CVE-2026-74994.json &#39;cpe:2.3:a:erlang:erlang\/otp:28.5.0.5:*:*:*:*:*:*:*&#39;
</span></span><span style="display:flex;"><span>... -&gt; True
</span></span><span style="display:flex;"><span>$ python3 -I cpe_eval.py CVE-2026-74994.json &#39;cpe:2.3:a:erlang:erlang\/otp:28.5.0.6:*:*:*:*:*:*:*&#39;
</span></span><span style="display:flex;"><span>... -&gt; False
</span></span><span style="display:flex;"><span>$ python3 -I cpe_eval.py full-record-advanced-example.json cpe:2.3:a:example_org:example_enterprise:1.0.5:*:*:*:*:*:*:*
</span></span><span style="display:flex;"><span>... -&gt; False
</span></span><span style="display:flex;"><span>$ python3 -I cpe_eval.py full-record-advanced-example.json cpe:2.3:a:example_org:example_enterprise:1.0.5:*:*:*:*:*:*:* cpe:2.3:o:microsoft:windows:11:*:*:*:*:*:*:*
</span></span><span style="display:flex;"><span>... -&gt; True
</span></span><span style="display:flex;"><span>$ python3 -I cpe_eval.py CVE-2026-115975.json cpe:2.3:a:cervantessec:cervantes:v.1.4.0-beta:*:*:*:*:*:*:*
</span></span><span style="display:flex;"><span>... -&gt; unknown (cannot order version v.1.4.0-beta)
</span></span></code></pre></div><p>The Erlang/OTP record (CVE-2026-74994) is the common case: three ranges in one OR node, one per release line, with <code>versionEndExcluding</code> set to the fixed release. The example record shows the AND rule working: the application version alone is not enough; the platform must be present too. The Cervantes record (CVE-2026-115975) uses version strings like <code>alpha_0.3</code> and <code>1.3-beta</code>, and the honest answer is &ldquo;cannot decide&rdquo;.</p>
<h2 id="where-cpeapplicability-and-affected-disagree">Where cpeApplicability and affected disagree</h2>
<p>CVE-2026-23122 is a Linux kernel record (published February 14, 2026). Its <code>cpeApplicability</code> block has one OR node with two ranges on <code>cpe:2.3:o:linux:linux_kernel</code>: 6.16 up to (excluding) 6.18.8, and 6.16 up to (excluding) 6.19. Its <code>affected</code> block for the semver product says 6.16 is affected, below 6.16 is unaffected, <strong>6.18.8 through 6.18.* is unaffected</strong>, and 6.19 onwards is unaffected.</p>
<p>Our evaluator, run on kernel versions (output trimmed to the version and verdict):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>6.15.4 -&gt; False
</span></span><span style="display:flex;"><span>6.18.7 -&gt; True
</span></span><span style="display:flex;"><span>6.18.9 -&gt; True     # affected block: unaffected (6.18.8 to 6.18.*)
</span></span><span style="display:flex;"><span>6.19   -&gt; False
</span></span></code></pre></div><p>The second CPE range (6.16 to before 6.19) swallows the stable-branch fix, because a CPE range has no way to say &ldquo;fixed in the 6.18 branch at 6.18.8 but not in mainline until 6.19&rdquo;. The <code>affected</code> block can, through ordered entries and a <code>6.18.*</code> upper limit. A scanner that trusts only CPE here raises a false positive on every patched 6.18 kernel.</p>
<p>That is the general lesson: CPE ranges are a lossy projection of branch-aware version data. Use them for discovery (which records mention a product I run?) and as a cross-check, then decide with the <code>affected</code> block.</p>
<h2 id="common-mistakes">Common mistakes</h2>
<ul>
<li><strong>Treating every matched CPE as vulnerable.</strong> Entries with <code>vulnerable: false</code> are context. In our sample they were rare (10 of 2,667), which is exactly why code that ignores the flag passes tests and fails later.</li>
<li><strong>Applying bounds to an exact-version criteria string.</strong> If the CPE&rsquo;s version field is not <code>*</code>, compare it directly.</li>
<li><strong>Assuming the element operator.</strong> 84% of sampled elements omit it. With a single node it does not matter; with several, decide and document what your tool does. Our script treats a missing element operator as OR.</li>
<li><strong>Comparing versions as strings.</strong> <code>&quot;6.9&quot; &gt; &quot;6.18&quot;</code> is true as text. Use a scheme-aware comparator and return &ldquo;unknown&rdquo; when the scheme is unknown.</li>
<li><strong>Mixing sources silently.</strong> CNA <code>cpeApplicability</code>, the older <code>affected[].cpes</code>, and NVD&rsquo;s configurations are three different statements. Store which one produced a match.</li>
</ul>
<h2 id="where-this-fits-in-triage">Where this fits in triage</h2>
<p>CPE matching answers &ldquo;does this CVE apply to an asset I run?&rdquo;. It does not answer &ldquo;how urgently?&rdquo;. Once a record matches, the next questions are whether it is in CISA KEV, its EPSS probability and its SSVC decision values; our <a href="/posts/vulnerability-prioritization-workflow-cve-record-to-patch-ticket/">vulnerability prioritization workflow</a> walks that path from record to ticket, and <a href="/posts/cisa-kev-json-feed-what-each-field-means-and-how-to-match-it-against-a-scanner-export/">the KEV JSON feed guide</a> covers the exploitation check.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<ul>
<li>The counts come from one random sample of 2026 records at one commit; CNA behaviour changes, so rerun the count on your own snapshot before relying on a share.</li>
<li>The evaluator is illustrative. It does not implement the full CPE 2.3 name-matching specification (NISTIR 7696), wildcard characters inside fields, or version schemes beyond dotted integers.</li>
<li>We describe NVD configurations only by the schema&rsquo;s own statement that this block was forked from the NVD CVE API v2.0 schema; we did not compare NVD&rsquo;s published configurations for these CVEs.</li>
</ul>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Field names, required fields, enums, &ldquo;mostly copied/forked from the NIST NVD CVE API v2.0 schema&rdquo;, alignment recommendation</td>
          <td><a href="https://github.com/CVEProject/cve-schema/blob/main/schema/CVE_Record_Format.json">CVE Record Format schema (cve-schema main)</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>cpeApplicability</code> allowed in CNA and ADP containers; <code>cpes</code> description recommends the newer block</td>
          <td><a href="https://github.com/CVEProject/cve-schema/blob/main/schema/CVE_Record_Format.json">CVE Record Format schema</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Added in 5.1.1; tag dated October 2024</td>
          <td><a href="https://github.com/CVEProject/cve-schema/releases">cve-schema releases</a>; <code>v5.1.1</code> tag commit date</td>
          <td>Verified (release-note text read via a mirror; qualified)</td>
      </tr>
      <tr>
          <td>AND/OR example with vulnerable: false platforms</td>
          <td><a href="https://github.com/CVEProject/cve-schema/blob/main/schema/docs/full-record-advanced-example.json">full-record-advanced-example.json</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>CISA stopped adding CPE strings on December 10, 2024</td>
          <td><a href="https://github.com/cisagov/vulnrichment">cisagov/vulnrichment README</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Sample counts and shares</td>
          <td><a href="https://github.com/CVEProject/cvelistV5">cvelistV5</a> at commit f0b25a9, our script</td>
          <td>Verified (computed)</td>
      </tr>
      <tr>
          <td>CVE-2026-23122 cpeApplicability vs affected disagreement</td>
          <td><a href="https://github.com/CVEProject/cvelistV5/blob/main/cves/2026/23xxx/CVE-2026-23122.json">CVE-2026-23122 record</a></td>
          <td>Verified (tested)</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>CycloneDX VEX: analysis.state and justification to OpenVEX</title><link>https://firewallsync.com/posts/cyclonedx-vex-analysis-state-justification-openvex-csaf-mapping/</link><guid>https://firewallsync.com/posts/cyclonedx-vex-analysis-state-justification-openvex-csaf-mapping/</guid><pubDate>Tue, 03 Nov 2026 04:40:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>CycloneDX VEX uses six analysis states and nine justifications; OpenVEX and CSAF use four statuses and five justifications. Here is a field-by-field mapping, which pairs line up and which do not, how Trivy reads CycloneDX VEX, and a tested Python converter that refuses to invent justifications.</description><enclosure url="https://firewallsync.com/posts/cyclonedx-vex-analysis-state-justification-openvex-csaf-mapping/cover_hu15575583779999317849.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/cyclonedx-vex-analysis-state-justification-openvex-csaf-mapping/cover_hu15575583779999317849.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/cyclonedx-vex-analysis-state-justification-openvex-csaf-mapping/cover_hu15575583779999317849.jpg" alt="Close-up of a silver modular synthesizer panel with black knobs, patched with pink, green, orange, red and yellow cables" width="1200" height="630"></p><p><strong>Scope.</strong> This is a field-level mapping guide. We compared the CycloneDX JSON schemas for 1.6 and 1.7 (identical enums for the fields below), OpenVEX specification v0.2.0 and its JSON schema, and the CSAF 2.0 JSON schema and VEX profile text from the OASIS TC repository, then read how Trivy (main branch, commit <code>1d1ab074</code>, November 2, 2026) consumes CycloneDX VEX. The converter below was run with Python 3.13; its output validated against the OpenVEX 0.2.0 JSON schema and its inputs against the CycloneDX 1.6 schema. Our <a href="/posts/vex-explained-what-a-not-affected-statement-means-and-how-to-read-one/">VEX explainer</a> covers what the statuses mean and how far to trust them; this article is only about moving statements between formats.</p>
<h2 id="the-short-answer">The short answer</h2>
<p>If you receive CycloneDX VEX and your pipeline speaks OpenVEX or CSAF (or the reverse), the states map almost one-to-one, but the justifications do not.</p>
<table>
  <thead>
      <tr>
          <th>CycloneDX <code>analysis.state</code></th>
          <th>OpenVEX <code>status</code></th>
          <th>CSAF <code>product_status</code></th>
          <th>Trivy (code)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>not_affected</code></td>
          <td><code>not_affected</code></td>
          <td><code>known_not_affected</code></td>
          <td>not_affected</td>
      </tr>
      <tr>
          <td><code>false_positive</code></td>
          <td><code>not_affected</code> (no exact match)</td>
          <td><code>known_not_affected</code> (no exact match)</td>
          <td>not_affected</td>
      </tr>
      <tr>
          <td><code>exploitable</code></td>
          <td><code>affected</code></td>
          <td><code>known_affected</code></td>
          <td>affected</td>
      </tr>
      <tr>
          <td><code>in_triage</code></td>
          <td><code>under_investigation</code></td>
          <td><code>under_investigation</code></td>
          <td>under_investigation</td>
      </tr>
      <tr>
          <td><code>resolved</code></td>
          <td><code>fixed</code></td>
          <td><code>fixed</code></td>
          <td>fixed</td>
      </tr>
      <tr>
          <td><code>resolved_with_pedigree</code></td>
          <td><code>fixed</code> (pedigree evidence is lost)</td>
          <td><code>fixed</code></td>
          <td>fixed</td>
      </tr>
  </tbody>
</table>
<p>Of CycloneDX&rsquo;s nine justifications, only <code>code_not_present</code> and <code>code_not_reachable</code> have a clear counterpart among the five CISA-derived labels that OpenVEX and CSAF share. Convert those two; carry the other seven as text.</p>
<h2 id="where-vex-sits-in-a-cyclonedx-document">Where VEX sits in a CycloneDX document</h2>
<p>CycloneDX has no separate VEX document type. VEX is the <code>vulnerabilities</code> array of a BOM, and the fields that matter are:</p>
<ul>
<li><code>id</code>: the vulnerability identifier, such as a CVE ID.</li>
<li><code>affects[].ref</code>: what the statement is about. The schema allows a <code>bom-ref</code> in the same document or a BOM-Link element. A BOM-Link has the form <code>urn:cdx:serialNumber/version#bom-ref</code>, and the schema&rsquo;s pattern requires the serial number UUID, a version of 1 or more, and a fragment.</li>
<li><code>analysis.state</code>: one of <code>resolved</code>, <code>resolved_with_pedigree</code>, <code>exploitable</code>, <code>in_triage</code>, <code>false_positive</code>, <code>not_affected</code>.</li>
<li><code>analysis.justification</code>: one of nine reasons, described below.</li>
<li><code>analysis.response</code>: zero or more of <code>can_not_fix</code>, <code>will_not_fix</code>, <code>update</code>, <code>rollback</code>, <code>workaround_available</code>.</li>
<li><code>analysis.detail</code>: free text. The schema says that if a vulnerability is not exploitable, &ldquo;this field should include specific details on why the component or service is not impacted.&rdquo;</li>
</ul>
<p>A VEX statement can sit inside the SBOM itself or in a separate BOM that only has <code>vulnerabilities</code> and refers back to the SBOM by BOM-Link. That second pattern matters for tooling, below.</p>
<h2 id="states-four-clean-pairs-and-two-lossy-ones">States: four clean pairs and two lossy ones</h2>
<p>The CycloneDX schema defines the six states in its <code>meta:enum</code>. Four map directly:</p>
<ul>
<li><code>not_affected</code>: &ldquo;The component or service is not affected by the vulnerability. Justification should be specified for all not_affected cases.&rdquo; This is OpenVEX <code>not_affected</code> and CSAF <code>known_not_affected</code>.</li>
<li><code>exploitable</code>: &ldquo;The vulnerability may be directly or indirectly exploitable.&rdquo; This is OpenVEX <code>affected</code> and CSAF <code>known_affected</code>.</li>
<li><code>in_triage</code>: &ldquo;The vulnerability is being investigated.&rdquo; This is <code>under_investigation</code> in both.</li>
<li><code>resolved</code>: &ldquo;The vulnerability has been remediated.&rdquo; This is <code>fixed</code> in both.</li>
</ul>
<p>Two lose meaning:</p>
<ul>
<li><strong><code>false_positive</code></strong>: &ldquo;The vulnerability is not specific to the component or service and was falsely identified or associated.&rdquo; OpenVEX and CSAF have no status for &ldquo;the scanner was wrong&rdquo;. The safe target is <code>not_affected</code>, which is also what Trivy&rsquo;s <code>cdxStatus</code> function does (<code>case cdx.IASFalsePositive, cdx.IASNotAffected: return types.FindingStatusNotAffected</code>). Keep the word <code>false_positive</code> in the impact statement so a reviewer can see it.</li>
<li><strong><code>resolved_with_pedigree</code></strong>: remediated &ldquo;and evidence of the changes are provided in the affected components pedigree&rdquo;. It becomes <code>fixed</code>, and the pointer to pedigree evidence is lost unless you keep it as text.</li>
</ul>
<h2 id="justifications-two-of-nine-line-up">Justifications: two of nine line up</h2>
<p>OpenVEX&rsquo;s five justification labels come from CISA&rsquo;s June 2022 status-justification document, and the CSAF 2.0 schema&rsquo;s <code>flags[].label</code> uses the same five strings. CycloneDX defines a different set of nine. Here is each one with its CycloneDX definition and our reading of the closest OpenVEX/CSAF label.</p>
<table>
  <thead>
      <tr>
          <th>CycloneDX <code>justification</code></th>
          <th>CycloneDX definition</th>
          <th>OpenVEX / CSAF label</th>
          <th>Our call</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>code_not_present</code></td>
          <td>&ldquo;The code has been removed or tree-shaked.&rdquo;</td>
          <td><code>vulnerable_code_not_present</code></td>
          <td>Map</td>
      </tr>
      <tr>
          <td><code>code_not_reachable</code></td>
          <td>&ldquo;The vulnerable code is not invoked at runtime.&rdquo;</td>
          <td><code>vulnerable_code_not_in_execute_path</code></td>
          <td>Map</td>
      </tr>
      <tr>
          <td><code>requires_dependency</code></td>
          <td>&ldquo;Exploitability requires a dependency that is not present.&rdquo;</td>
          <td>none exact (<code>component_not_present</code> means the vulnerable component itself is absent)</td>
          <td>Text</td>
      </tr>
      <tr>
          <td><code>requires_configuration</code></td>
          <td>&ldquo;Exploitability requires a configurable option to be set/unset.&rdquo;</td>
          <td>none</td>
          <td>Text</td>
      </tr>
      <tr>
          <td><code>requires_environment</code></td>
          <td>&ldquo;Exploitability requires a certain environment which is not present.&rdquo;</td>
          <td>none</td>
          <td>Text</td>
      </tr>
      <tr>
          <td><code>protected_by_compiler</code></td>
          <td>&ldquo;Exploitability requires a compiler flag to be set/unset.&rdquo;</td>
          <td>none exact</td>
          <td>Text</td>
      </tr>
      <tr>
          <td><code>protected_at_runtime</code></td>
          <td>&ldquo;Exploits are prevented at runtime.&rdquo;</td>
          <td><code>inline_mitigations_already_exist</code> only if the protection cannot be disabled</td>
          <td>Text</td>
      </tr>
      <tr>
          <td><code>protected_at_perimeter</code></td>
          <td>&ldquo;Attacks are blocked at physical, logical, or network perimeter.&rdquo;</td>
          <td>none</td>
          <td>Text</td>
      </tr>
      <tr>
          <td><code>protected_by_mitigating_control</code></td>
          <td>&ldquo;Preventative measures have been implemented that reduce the likelihood and/or impact of the vulnerability.&rdquo;</td>
          <td>none</td>
          <td>Text</td>
      </tr>
  </tbody>
</table>
<p>Why so strict? The OpenVEX spec defines <code>inline_mitigations_already_exist</code> as built-in protections that &ldquo;cannot be subverted by the attacker and cannot be configured or disabled by the user.&rdquo; A CycloneDX <code>protected_at_runtime</code> or <code>protected_by_compiler</code> claim does not tell you that. <code>protected_at_perimeter</code> and <code>protected_by_mitigating_control</code> describe the deployment, not the product, and a mitigating control that &ldquo;reduce[s] the likelihood and/or impact&rdquo; is weaker than &ldquo;not affected&rdquo; in the CISA sense.</p>
<p>This mapping is our editorial reading of the published definitions. We did not find a mapping table published by the CycloneDX project or CISA.</p>
<p>OpenVEX gives you a way out: for <code>not_affected</code>, &ldquo;a VEX statement MUST include either a status justification or an impact_statement.&rdquo; So you can emit a <code>not_affected</code> statement with no label and an impact statement that preserves the original CycloneDX reason. CSAF&rsquo;s VEX profile has the same escape hatch: each <code>known_not_affected</code> product needs either a machine-readable flag or a human-readable justification in <code>threats</code> with category <code>impact</code>.</p>
<h2 id="responses-and-action-statements">Responses and action statements</h2>
<p>For affected products, both targets require an action:</p>
<ul>
<li>OpenVEX: for <code>affected</code>, &ldquo;a VEX statement MUST include a statement that SHOULD describe actions to remediate or mitigate the vulnerability&rdquo; (<code>action_statement</code>).</li>
<li>CSAF VEX profile: for <code>known_affected</code>, &ldquo;additional product specific information SHALL be provided in <code>/vulnerabilities[]/remediations</code> as an action statement.&rdquo;</li>
</ul>
<p>CycloneDX&rsquo;s <code>response</code> values give you a starting point. <code>update</code> and <code>rollback</code> fit CSAF&rsquo;s <code>vendor_fix</code> remediation category, <code>workaround_available</code> fits <code>workaround</code>, and <code>will_not_fix</code> / <code>can_not_fix</code> fit <code>no_fix_planned</code> (or <code>none_available</code>), which the CSAF profile explicitly permits for an action statement. OpenVEX takes a free-text <code>action_statement</code>, so join the responses with <code>analysis.detail</code>. If an <code>exploitable</code> statement has neither a response nor detail, the converted statement will be valid but useless; send it back to the supplier.</p>
<h2 id="bom-link-the-part-that-silently-drops-statements">BOM-Link: the part that silently drops statements</h2>
<p>A CycloneDX VEX BOM that references components by BOM-Link is bound to one SBOM version. Trivy&rsquo;s docs (v0.67) say the tool &ldquo;only supports the Independent BOM and VEX BOM format, so you need to provide a separate VEX file alongside the SBOM,&rdquo; that &ldquo;BOM-Links must be placed in <code>affects.ref</code>,&rdquo; and that the input SBOM must be CycloneDX. The usage example is <code>trivy sbom trivy.sbom.cdx --vex trivy.vex.cdx</code>. The feature is marked experimental.</p>
<p>Trivy&rsquo;s code adds a detail the docs do not spell out: if the BOM-Link&rsquo;s serial number or version does not equal the scanned SBOM&rsquo;s, it logs <code>URN doesn't match with SBOM</code> and skips the statement. Regenerate the SBOM (new serial number or version) without regenerating the VEX, and every suppression stops applying. That fails safe, since findings come back rather than disappear, but it surprises teams.</p>
<p>OpenVEX has no BOM-Link. Products are identified by an <code>@id</code> such as a Package URL, so a converter has to resolve each BOM-Link to the referenced component&rsquo;s <code>purl</code> in the SBOM. That is why the converter below takes both files.</p>
<h2 id="a-tested-converter-cyclonedx-vex-to-openvex">A tested converter: CycloneDX VEX to OpenVEX</h2>
<p>This Python 3 script reads a CycloneDX SBOM and a separate CycloneDX VEX BOM and writes an OpenVEX 0.2.0 document. It maps states as in the first table, maps only the two clean justifications, and keeps everything else as text. It is illustrative, not production-ready: it does not handle version ranges in <code>affects[].versions</code>, services, or nested BOMs beyond <code>components</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e">#!/usr/bin/env python3</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;Convert a CycloneDX VEX BOM (independent VEX, BOM-Link refs) to OpenVEX v0.2.0.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Usage: python3 cdx2openvex.py sbom.cdx.json vex.cdx.json author &gt; out.openvex.json&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json<span style="color:#f92672">,</span> sys<span style="color:#f92672">,</span> hashlib
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> datetime <span style="color:#f92672">import</span> datetime, timezone
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>STATUS <span style="color:#f92672">=</span> {  <span style="color:#75715e"># CycloneDX analysis.state -&gt; OpenVEX status</span>
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;not_affected&#34;</span>: <span style="color:#e6db74">&#34;not_affected&#34;</span>, <span style="color:#e6db74">&#34;false_positive&#34;</span>: <span style="color:#e6db74">&#34;not_affected&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;exploitable&#34;</span>: <span style="color:#e6db74">&#34;affected&#34;</span>, <span style="color:#e6db74">&#34;in_triage&#34;</span>: <span style="color:#e6db74">&#34;under_investigation&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;resolved&#34;</span>: <span style="color:#e6db74">&#34;fixed&#34;</span>, <span style="color:#e6db74">&#34;resolved_with_pedigree&#34;</span>: <span style="color:#e6db74">&#34;fixed&#34;</span>,
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Only the pairs whose definitions line up; everything else is carried as text.</span>
</span></span><span style="display:flex;"><span>JUSTIFICATION <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;code_not_present&#34;</span>: <span style="color:#e6db74">&#34;vulnerable_code_not_present&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;code_not_reachable&#34;</span>: <span style="color:#e6db74">&#34;vulnerable_code_not_in_execute_path&#34;</span>,
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">purls_by_ref</span>(sbom):
</span></span><span style="display:flex;"><span>    serial, version <span style="color:#f92672">=</span> sbom[<span style="color:#e6db74">&#34;serialNumber&#34;</span>], sbom<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;version&#34;</span>, <span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>    out, stack <span style="color:#f92672">=</span> {}, list(sbom<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;components&#34;</span>, []))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">while</span> stack:
</span></span><span style="display:flex;"><span>        c <span style="color:#f92672">=</span> stack<span style="color:#f92672">.</span>pop()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;bom-ref&#34;</span> <span style="color:#f92672">in</span> c <span style="color:#f92672">and</span> <span style="color:#e6db74">&#34;purl&#34;</span> <span style="color:#f92672">in</span> c:
</span></span><span style="display:flex;"><span>            out[<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;urn:cdx:</span><span style="color:#e6db74">{</span>serial<span style="color:#f92672">.</span>removeprefix(<span style="color:#e6db74">&#39;urn:uuid:&#39;</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">{</span>version<span style="color:#e6db74">}</span><span style="color:#e6db74">#</span><span style="color:#e6db74">{</span>c[<span style="color:#e6db74">&#39;bom-ref&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>] <span style="color:#f92672">=</span> c[<span style="color:#e6db74">&#34;purl&#34;</span>]
</span></span><span style="display:flex;"><span>        stack<span style="color:#f92672">.</span>extend(c<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;components&#34;</span>, []))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> out
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">convert</span>(sbom, vex, author):
</span></span><span style="display:flex;"><span>    refs, statements, problems <span style="color:#f92672">=</span> purls_by_ref(sbom), [], []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> v <span style="color:#f92672">in</span> vex<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;vulnerabilities&#34;</span>, []):
</span></span><span style="display:flex;"><span>        a <span style="color:#f92672">=</span> v<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;analysis&#34;</span>, {})
</span></span><span style="display:flex;"><span>        state <span style="color:#f92672">=</span> a<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;state&#34;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> state <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> STATUS:
</span></span><span style="display:flex;"><span>            problems<span style="color:#f92672">.</span>append(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>v[<span style="color:#e6db74">&#39;id&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">: no analysis.state, skipped&#34;</span>); <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>        products <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> af <span style="color:#f92672">in</span> v<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;affects&#34;</span>, []):
</span></span><span style="display:flex;"><span>            purl <span style="color:#f92672">=</span> refs<span style="color:#f92672">.</span>get(af[<span style="color:#e6db74">&#34;ref&#34;</span>])
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> purl: products<span style="color:#f92672">.</span>append({<span style="color:#e6db74">&#34;@id&#34;</span>: purl})
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">else</span>: problems<span style="color:#f92672">.</span>append(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>v[<span style="color:#e6db74">&#39;id&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">: </span><span style="color:#e6db74">{</span>af[<span style="color:#e6db74">&#39;ref&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74"> not found in SBOM&#34;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> products: <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>        s <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;vulnerability&#34;</span>: {<span style="color:#e6db74">&#34;name&#34;</span>: v[<span style="color:#e6db74">&#34;id&#34;</span>]}, <span style="color:#e6db74">&#34;products&#34;</span>: products, <span style="color:#e6db74">&#34;status&#34;</span>: STATUS[state]}
</span></span><span style="display:flex;"><span>        detail <span style="color:#f92672">=</span> a<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;detail&#34;</span>, <span style="color:#e6db74">&#34;&#34;</span>)<span style="color:#f92672">.</span>strip()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> s[<span style="color:#e6db74">&#34;status&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;not_affected&#34;</span>:
</span></span><span style="display:flex;"><span>            j <span style="color:#f92672">=</span> JUSTIFICATION<span style="color:#f92672">.</span>get(a<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;justification&#34;</span>))
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> j: s[<span style="color:#e6db74">&#34;justification&#34;</span>] <span style="color:#f92672">=</span> j
</span></span><span style="display:flex;"><span>            note <span style="color:#f92672">=</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;CycloneDX state=</span><span style="color:#e6db74">{</span>state<span style="color:#e6db74">}</span><span style="color:#e6db74">, justification=</span><span style="color:#e6db74">{</span>a<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;justification&#39;</span>, <span style="color:#e6db74">&#39;none&#39;</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>            s[<span style="color:#e6db74">&#34;impact_statement&#34;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>note<span style="color:#e6db74">}</span><span style="color:#e6db74">. </span><span style="color:#e6db74">{</span>detail<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span><span style="color:#f92672">.</span>strip()
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">elif</span> s[<span style="color:#e6db74">&#34;status&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;affected&#34;</span>:
</span></span><span style="display:flex;"><span>            resp <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;, &#34;</span><span style="color:#f92672">.</span>join(a<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;response&#34;</span>, [])) <span style="color:#f92672">or</span> <span style="color:#e6db74">&#34;none given&#34;</span>
</span></span><span style="display:flex;"><span>            s[<span style="color:#e6db74">&#34;action_statement&#34;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;CycloneDX response: </span><span style="color:#e6db74">{</span>resp<span style="color:#e6db74">}</span><span style="color:#e6db74">. </span><span style="color:#e6db74">{</span>detail<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span><span style="color:#f92672">.</span>strip()
</span></span><span style="display:flex;"><span>        statements<span style="color:#f92672">.</span>append(s)
</span></span><span style="display:flex;"><span>    body <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>dumps(statements, sort_keys<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>)<span style="color:#f92672">.</span>encode()
</span></span><span style="display:flex;"><span>    doc <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;@context&#34;</span>: <span style="color:#e6db74">&#34;https://openvex.dev/ns/v0.2.0&#34;</span>,
</span></span><span style="display:flex;"><span>           <span style="color:#e6db74">&#34;@id&#34;</span>: <span style="color:#e6db74">&#34;https://example.com/vex/&#34;</span> <span style="color:#f92672">+</span> hashlib<span style="color:#f92672">.</span>sha256(body)<span style="color:#f92672">.</span>hexdigest()[:<span style="color:#ae81ff">16</span>],
</span></span><span style="display:flex;"><span>           <span style="color:#e6db74">&#34;author&#34;</span>: author,
</span></span><span style="display:flex;"><span>           <span style="color:#e6db74">&#34;timestamp&#34;</span>: datetime<span style="color:#f92672">.</span>now(timezone<span style="color:#f92672">.</span>utc)<span style="color:#f92672">.</span>isoformat(timespec<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;seconds&#34;</span>),
</span></span><span style="display:flex;"><span>           <span style="color:#e6db74">&#34;version&#34;</span>: <span style="color:#ae81ff">1</span>, <span style="color:#e6db74">&#34;statements&#34;</span>: statements}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> doc, problems
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    sbom, vex <span style="color:#f92672">=</span> (json<span style="color:#f92672">.</span>load(open(p)) <span style="color:#66d9ef">for</span> p <span style="color:#f92672">in</span> sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">1</span>:<span style="color:#ae81ff">3</span>])
</span></span><span style="display:flex;"><span>    doc, problems <span style="color:#f92672">=</span> convert(sbom, vex, sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">3</span>])
</span></span><span style="display:flex;"><span>    json<span style="color:#f92672">.</span>dump(doc, sys<span style="color:#f92672">.</span>stdout, indent<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> p <span style="color:#f92672">in</span> problems: print(<span style="color:#e6db74">&#34;WARN&#34;</span>, p, file<span style="color:#f92672">=</span>sys<span style="color:#f92672">.</span>stderr)
</span></span></code></pre></div><h3 id="test-run">Test run</h3>
<p>We fed it a three-component CycloneDX 1.6 SBOM (log4j-core 2.14.1, jackson-databind 2.13.0, snakeyaml 1.30) and a VEX BOM with four statements. The assessments in the VEX are invented test data, not real vendor findings:</p>
<ol>
<li>CVE-2021-44228 on log4j-core: <code>not_affected</code>, <code>code_not_reachable</code>.</li>
<li>CVE-2022-42003 on jackson-databind: <code>not_affected</code>, <code>requires_configuration</code>.</li>
<li>CVE-2022-1471 on snakeyaml: <code>exploitable</code>, response <code>update</code>.</li>
<li>CVE-2021-44228 again, but with a BOM-Link naming SBOM version <code>2</code> instead of <code>1</code>.</li>
</ol>
<p>Both input files passed <code>check-jsonschema</code> against the CycloneDX 1.6 schema. The run printed one warning to stderr and three statements; the output validated against <code>openvex_json_schema.json</code> (v0.2.0) with the Python <code>jsonschema</code> package:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>WARN CVE-2021-44228: urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/2#pkg-log4j not found in SBOM
</span></span></code></pre></div><p>The <code>statements</code> array of the output:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>[
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;vulnerability&#34;</span>: { <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;CVE-2021-44228&#34;</span> },
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;products&#34;</span>: [ { <span style="color:#f92672">&#34;@id&#34;</span>: <span style="color:#e6db74">&#34;pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1&#34;</span> } ],
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;not_affected&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;justification&#34;</span>: <span style="color:#e6db74">&#34;vulnerable_code_not_in_execute_path&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;impact_statement&#34;</span>: <span style="color:#e6db74">&#34;CycloneDX state=not_affected, justification=code_not_reachable. JNDI lookups are never invoked; the app logs through SLF4J with Logback.&#34;</span>
</span></span><span style="display:flex;"><span>},
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;vulnerability&#34;</span>: { <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;CVE-2022-42003&#34;</span> },
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;products&#34;</span>: [ { <span style="color:#f92672">&#34;@id&#34;</span>: <span style="color:#e6db74">&#34;pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.0&#34;</span> } ],
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;not_affected&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;impact_statement&#34;</span>: <span style="color:#e6db74">&#34;CycloneDX state=not_affected, justification=requires_configuration. The deserialization feature this issue depends on is disabled in our ObjectMapper configuration.&#34;</span>
</span></span><span style="display:flex;"><span>},
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;vulnerability&#34;</span>: { <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;CVE-2022-1471&#34;</span> },
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;products&#34;</span>: [ { <span style="color:#f92672">&#34;@id&#34;</span>: <span style="color:#e6db74">&#34;pkg:maven/org.yaml/snakeyaml@1.30&#34;</span> } ],
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;affected&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;action_statement&#34;</span>: <span style="color:#e6db74">&#34;CycloneDX response: update. Dependency upgrade ships in release 4.2.&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>]
</span></span></code></pre></div><p>The second statement is the important one: it stays <code>not_affected</code> with no invented label, and the reason survives as text. The fourth input statement was dropped with a warning instead of being applied to the wrong SBOM.</p>
<h2 id="common-mistakes">Common mistakes</h2>
<ul>
<li><strong>Mapping every <code>protected_*</code> justification to <code>inline_mitigations_already_exist</code>.</strong> That label claims the protection cannot be disabled. A WAF rule or a compiler flag can.</li>
<li><strong>Dropping <code>false_positive</code> statements</strong> because the target format has no matching status. Map them to <code>not_affected</code> and say why.</li>
<li><strong>Dropping the <code>detail</code> text.</strong> It is often the only evidence a reviewer has, and both OpenVEX and CSAF have a place for it.</li>
<li><strong>Reusing a VEX BOM after regenerating the SBOM.</strong> BOM-Links name an exact serial number and version.</li>
<li><strong>Treating converted VEX as first-party.</strong> Keep the original author. OpenVEX <code>author</code> should be the organization that made the assessment, not the converter.</li>
</ul>
<h2 id="where-this-fits">Where this fits</h2>
<p>VEX is the &ldquo;is my product actually affected&rdquo; step in our <a href="/posts/vulnerability-prioritization-workflow-cve-record-to-patch-ticket/">vulnerability prioritization workflow</a>, after the CVE record and before KEV, SSVC and EPSS. The SBOM that the VEX points at is covered in our guide to <a href="/posts/sbom-minimum-elements-2026-what-cisa-and-partners-changed-from-the-2021-ntia-baseline/">SBOM minimum elements in 2026</a>. If you want a signal for which <code>exploitable</code> statements to fix first, our <a href="/posts/cvss-vs-epss-vs-kev-which-signal-should-decide-what-you-patch-first/">CVSS vs EPSS vs KEV comparison</a> covers the trade-offs.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>The justification mapping is our reading of the published definitions; no standards body has published it. We read CSAF 2.0 from the OASIS TC&rsquo;s GitHub repository (schema and editor&rsquo;s draft prose) rather than the OASIS-hosted standard. We read Trivy&rsquo;s main branch, not a tagged release, and its docs for v0.67; behaviour may differ in the version you run. We did not run Trivy itself or test the reverse conversion (OpenVEX or CSAF to CycloneDX), which is harder because CycloneDX <code>affects</code> needs a component reference the OpenVEX product may not resolve to.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CycloneDX states, justifications, responses and their definitions; detail field; BOM-Link pattern</td>
          <td><a href="https://github.com/CycloneDX/specification/tree/master/schema">CycloneDX bom-1.6 / bom-1.7 JSON schemas</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>BOM-Link syntax <code>urn:cdx:serialNumber/version#bom-ref</code></td>
          <td><a href="https://www.cyclonedx.org/capabilities/bomlink/">CycloneDX BOM-Link</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>OpenVEX statuses, five justifications, justification or impact_statement rule, action_statement rule</td>
          <td><a href="https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md">OpenVEX spec v0.2.0</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>CSAF product_status categories, flag labels, remediation categories</td>
          <td><a href="https://github.com/oasis-tcs/csaf/blob/master/csaf_2.0/json_schema/csaf_json_schema.json">CSAF 2.0 JSON schema</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>CSAF VEX profile requirements for known_not_affected and known_affected</td>
          <td><a href="https://github.com/oasis-tcs/csaf/blob/master/csaf_2.0/prose/csaf-v2-editor-draft.md">CSAF 2.0 prose, section 4.5</a></td>
          <td>Qualified (TC repository draft text)</td>
      </tr>
      <tr>
          <td>Trivy maps false_positive to not_affected and skips BOM-Links that do not match the SBOM</td>
          <td><a href="https://github.com/aquasecurity/trivy/blob/main/pkg/vex/cyclonedx.go">Trivy pkg/vex/cyclonedx.go</a></td>
          <td>Verified (main branch, 2026-11-02)</td>
      </tr>
      <tr>
          <td>Trivy CycloneDX VEX: separate VEX BOM, BOM-Link in affects.ref, CycloneDX SBOM only, experimental</td>
          <td><a href="https://trivy.dev/docs/latest/supply-chain/vex/file">Trivy docs: VEX file</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Converter output and schema validation</td>
          <td>Our run, Python 3.13, jsonschema, check-jsonschema</td>
          <td>Verified (code)</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>GitHub Actions OIDC: Stop Storing Cloud Credentials in CI/CD</title><link>https://firewallsync.com/posts/github-actions-oidc-stop-storing-cloud-credentials-in-cicd/</link><guid>https://firewallsync.com/posts/github-actions-oidc-stop-storing-cloud-credentials-in-cicd/</guid><pubDate>Mon, 02 Nov 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>A static cloud credential stored as a CI secret is valid until someone notices it's wrong. An OIDC-issued credential is valid for one workflow run and expires whether anyone notices or not.</description><enclosure url="https://firewallsync.com/posts/github-actions-oidc-stop-storing-cloud-credentials-in-cicd/cover_hu11536215644407057025.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/github-actions-oidc-stop-storing-cloud-credentials-in-cicd/cover_hu11536215644407057025.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/github-actions-oidc-stop-storing-cloud-credentials-in-cicd/cover_hu11536215644407057025.jpg" alt="Two monitors displaying code" width="1200" height="630"></p><p>A static cloud credential — an AWS access key, a GCP service account key — stored as a GitHub Actions secret has one fundamental property that makes it a standing risk: it remains valid until someone actively notices it shouldn&rsquo;t be and rotates it. OpenID Connect (OIDC) support in GitHub Actions removes that credential from the equation entirely, replacing it with a token that&rsquo;s short-lived by design and requires no manual rotation discipline to stay safe.</p>
<h2 id="how-the-exchange-actually-works">How the exchange actually works</h2>
<p>When a workflow configured for OIDC runs, GitHub&rsquo;s Actions runner requests a JSON Web Token (JWT) from GitHub&rsquo;s own OIDC provider (<code>token.actions.githubusercontent.com</code>). This token contains claims about the workflow run — which repository, which branch or ref, which workflow file — and is signed by GitHub. The workflow then presents this token to the cloud provider (AWS STS, for example), which has been configured ahead of time to trust GitHub&rsquo;s OIDC provider and to check specific claims in the token before issuing temporary credentials. If the claims match what the trust policy expects, the cloud provider issues short-lived, scoped credentials good only for that specific workflow run.</p>
<p>No static credential is stored anywhere in this flow. The only thing GitHub holds is a role ARN or equivalent identifier telling it which role to request — the actual authentication happens fresh, per run, through the signed token exchange.</p>
<h2 id="setting-up-the-aws-side-of-the-trust-relationship">Setting up the AWS side of the trust relationship</h2>
<p>Configuring this requires two things in AWS: registering GitHub&rsquo;s OIDC provider as a trusted identity provider in IAM, and creating an IAM role with a trust policy that specifies exactly which GitHub repositories (and optionally, which branches or environments) are allowed to assume it. A typical trust policy condition restricts the <code>sub</code> claim to a specific pattern like <code>repo:your-org/your-repo:ref:refs/heads/main</code>, meaning only workflow runs on that exact branch, in that exact repository, can successfully assume the role — a workflow run from a fork, or from a different branch, will have a token with a <code>sub</code> claim that doesn&rsquo;t match, and the assume-role call will simply fail.</p>
<p>This scoping is what makes OIDC meaningfully more secure than a static credential even beyond the &ldquo;no long-lived secret&rdquo; property — the trust relationship itself constrains which workflows can even attempt to use it, rather than relying on the credential&rsquo;s holder being trustworthy by assumption.</p>
<h2 id="the-workflow-side-configuration">The workflow-side configuration</h2>
<p>The workflow needs explicit <code>permissions: id-token: write</code> set — this is the permission that allows the job to request an OIDC token from GitHub in the first place, and it&rsquo;s not granted by default even when other broad permissions are set. Combined with an action like <code>aws-actions/configure-aws-credentials</code>, specifying the target role&rsquo;s ARN, the workflow requests the token, performs the exchange with AWS STS, and receives temporary credentials scoped to that role — all without a static <code>AWS_ACCESS_KEY_ID</code> or <code>AWS_SECRET_ACCESS_KEY</code> appearing anywhere in the workflow or in GitHub&rsquo;s secret storage.</p>
<h2 id="why-the-short-lived-property-matters-beyond-convenience">Why the &ldquo;short-lived&rdquo; property matters beyond convenience</h2>
<p>A leaked static credential is a live problem until someone notices and rotates it — which could be minutes or months, depending entirely on whether monitoring catches the exposure. An OIDC-issued credential, even if somehow captured mid-flight during a specific run, expires on its own shortly after that run completes, typically within the hour. This changes the actual risk calculus: the attacker&rsquo;s usable window is bounded by design, rather than bounded by however quickly your team happens to detect and respond to a leak.</p>
<h2 id="what-oidc-does-not-solve">What OIDC does not solve</h2>
<p>OIDC removes the specific risk of a long-lived credential leaking from CI storage. It doesn&rsquo;t address other CI/CD security concerns: a compromised workflow can still assume the role and act with whatever permissions that role grants during its valid window, so scoping the IAM role&rsquo;s own permissions narrowly still matters exactly as much as it did with static credentials. It also doesn&rsquo;t protect against a malicious pull request modifying the workflow file itself to add unauthorized steps, if the workflow&rsquo;s trigger configuration allows untrusted code to influence what runs — that&rsquo;s a separate class of risk (<code>pull_request_target</code> misuse, in particular) that OIDC doesn&rsquo;t touch.</p>
<h2 id="multi-cloud-and-cross-provider-considerations">Multi-cloud and cross-provider considerations</h2>
<p>AWS, Google Cloud, and Azure all support this pattern under slightly different names (AWS calls it &ldquo;Web Identity Federation,&rdquo; GCP calls it &ldquo;Workload Identity Federation&rdquo;) but the underlying mechanism — trusting GitHub&rsquo;s OIDC provider and issuing short-lived credentials based on token claims — is conceptually the same across all three. If your pipeline deploys to multiple clouds, this pattern is worth implementing consistently across all of them rather than only for whichever provider had the most convenient existing tutorial.</p>
<h2 id="a-practical-checklist">A practical checklist</h2>
<ul>
<li>Register GitHub&rsquo;s OIDC provider in your cloud provider&rsquo;s IAM configuration</li>
<li>Write the trust policy&rsquo;s conditions narrowly — specific repository, specific branch or environment, not a broad wildcard match</li>
<li>Set <code>permissions: id-token: write</code> explicitly on any workflow using OIDC</li>
<li>Remove any existing static cloud credentials stored as GitHub secrets once OIDC is confirmed working, rather than leaving both in place indefinitely</li>
<li>Scope the IAM role&rsquo;s own permissions narrowly — OIDC changes how the credential is obtained, not how much access the resulting credential should have</li>
</ul>
<p>Migrating from static credentials to OIDC is one of the highest-leverage changes available for CI/CD security, precisely because it eliminates an entire category of leak (a long-lived secret sitting in storage) rather than just making that category harder to exploit.</p>
]]></content:encoded></item><item><title>CVE Affected Versions: lessThan, defaultStatus, versionType</title><link>https://firewallsync.com/posts/cve-affected-versions-lessthan-defaultstatus-versiontype/</link><guid>https://firewallsync.com/posts/cve-affected-versions-lessthan-defaultstatus-versiontype/</guid><pubDate>Mon, 02 Nov 2026 04:40:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>The affected block in a CVE record is a small algorithm, not a list. Here is how version, lessThan, lessThanOrEqual, changes, versionType and defaultStatus combine, what a sample of 1,000 2026 CVE records actually contains, and a tested Python evaluator that refuses version types it cannot order.</description><enclosure url="https://firewallsync.com/posts/cve-affected-versions-lessthan-defaultstatus-versiontype/cover_hu16366087011500977870.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/cve-affected-versions-lessthan-defaultstatus-versiontype/cover_hu16366087011500977870.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/cve-affected-versions-lessthan-defaultstatus-versiontype/cover_hu16366087011500977870.jpg" alt="Macro close-up of the steel scales of a vernier caliper, with inch and centimetre graduations and a sliding vernier scale" width="1200" height="630"></p><p><strong>Scope.</strong> This is a specification explainer with measurements from public data. We read the CVE JSON schema (version 5.2.0, CVEProject/cve-schema main branch) and its version-encoding guide, sampled 1,000 random 2026 records from the cvelistV5 repository at commit 451e97a (November 2, 2026), and ran the Python script below against real records. We did not test commercial scanners.</p>
<h2 id="the-short-answer">The short answer</h2>
<p>A CVE record&rsquo;s <code>affected</code> block does not hand you a list of bad versions. For each product it gives a small decision procedure:</p>
<ol>
<li>Walk the <code>versions</code> list in order.</li>
<li>An entry with only <code>version</code> and <code>status</code> matches that exact version.</li>
<li>An entry with <code>versionType</code> plus <code>lessThan</code> or <code>lessThanOrEqual</code> matches a range, and an optional <code>changes</code> list can flip the status partway through it.</li>
<li>The first matching entry wins. If nothing matches, the answer is <code>defaultStatus</code>, and if that is missing, the answer is <code>unknown</code>.</li>
</ol>
<p>The possible answers are <code>affected</code>, <code>unaffected</code> and <code>unknown</code>. Your tooling needs a fourth, &ldquo;I cannot order these versions&rdquo;, because the record does not always give you enough to decide.</p>
<h2 id="where-the-fields-live">Where the fields live</h2>
<p>The CNA (CVE Numbering Authority) writes <code>containers.cna.affected</code>, an array of product objects. The schema requires each product to be identified by either <code>vendor</code> and <code>product</code> or <code>collectionURL</code> and <code>packageName</code>, and to carry <code>versions</code>, <code>defaultStatus</code> or both. A 5.2 record can also carry a <code>packageURL</code> (a purl, which the schema says must not include a version).</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;vendor&#34;</span>: <span style="color:#e6db74">&#34;GitoxideLabs&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;product&#34;</span>: <span style="color:#e6db74">&#34;gitoxide&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;defaultStatus&#34;</span>: <span style="color:#e6db74">&#34;unaffected&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;packageURL&#34;</span>: <span style="color:#e6db74">&#34;pkg:cargo/gix-fs&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;versions&#34;</span>: [
</span></span><span style="display:flex;"><span>    {<span style="color:#f92672">&#34;version&#34;</span>: <span style="color:#e6db74">&#34;0&#34;</span>, <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;affected&#34;</span>, <span style="color:#f92672">&#34;versionType&#34;</span>: <span style="color:#e6db74">&#34;semver&#34;</span>, <span style="color:#f92672">&#34;lessThan&#34;</span>: <span style="color:#e6db74">&#34;0.23.0&#34;</span>},
</span></span><span style="display:flex;"><span>    {<span style="color:#f92672">&#34;version&#34;</span>: <span style="color:#e6db74">&#34;0.23.0&#34;</span>, <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;unaffected&#34;</span>, <span style="color:#f92672">&#34;versionType&#34;</span>: <span style="color:#e6db74">&#34;semver&#34;</span>}
</span></span><span style="display:flex;"><span>  ]
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That is the product entry from CVE-2026-100419 (a gix-fs symlink worktree escape), shown in full. Read it as: semantic versions from 0 up to but not including 0.23.0 are affected, 0.23.0 is unaffected, and anything else is unaffected by default.</p>
<h2 id="the-fields-one-by-one">The fields, one by one</h2>
<table>
  <thead>
      <tr>
          <th>Field</th>
          <th>What the schema says</th>
          <th>What it means in practice</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>version</code></td>
          <td>The single version, or the start of a range. &ldquo;By convention, typically 0 denotes the earliest possible version.&rdquo;</td>
          <td><code>&quot;version&quot;: &quot;0&quot;</code> means &ldquo;no lower bound&rdquo;</td>
      </tr>
      <tr>
          <td><code>status</code></td>
          <td>One of <code>affected</code>, <code>unaffected</code>, <code>unknown</code></td>
          <td><code>unknown</code> can mean nobody investigated or the vendor did not disclose</td>
      </tr>
      <tr>
          <td><code>versionType</code></td>
          <td>&ldquo;The version numbering system used for specifying the range&rdquo;; examples include <code>semver</code>, <code>maven</code>, <code>python</code>, <code>rpm</code>, <code>git</code>, <code>custom</code></td>
          <td>Picks the comparison rules. Required for a range</td>
      </tr>
      <tr>
          <td><code>lessThan</code></td>
          <td>&ldquo;The non-inclusive upper limit of the range. This is the least version NOT in the range.&rdquo;</td>
          <td>Usually the first fixed release</td>
      </tr>
      <tr>
          <td><code>lessThanOrEqual</code></td>
          <td>&ldquo;The inclusive upper limit of the range&rdquo;</td>
          <td>Usually the last vulnerable release</td>
      </tr>
      <tr>
          <td><code>changes</code></td>
          <td>Status changes inside a range, each with <code>at</code> and <code>status</code>; &ldquo;clients must re-sort the list themselves rather than assume it is sorted&rdquo;</td>
          <td>Lets one entry say &ldquo;affected, then fixed from X&rdquo;</td>
      </tr>
      <tr>
          <td><code>defaultStatus</code></td>
          <td>&ldquo;The default status for versions that are not otherwise listed&hellip; If not specified, defaultStatus defaults to &lsquo;unknown&rsquo;.&rdquo;</td>
          <td>The answer for every version no entry matched</td>
      </tr>
  </tbody>
</table>
<p>The upper limit can end in <code>*</code>, which the schema defines as &ldquo;an arbitrarily large number in the version ordering&rdquo;. <code>{version: 1.0, lessThan: 1.*}</code> describes the whole 1.x branch, and <code>lessThan: *</code> means &ldquo;and everything after&rdquo;.</p>
<h3 id="why-versiontype-is-not-optional">Why versionType is not optional</h3>
<p>The schema guide explains that &ldquo;there is no single definition of &rsquo;less than&rsquo; for versions&rdquo; and gives the example that in semantic versioning <code>1.0.0-cr1</code> sorts before <code>1.0.0-m1</code>, while in Maven the opposite is true. If your code compares every version string with one generic routine, it will be wrong for some ecosystems.</p>
<p>The guide is blunt about <code>custom</code>: it &ldquo;means that the version status algorithm cannot be executed, so its use is discouraged&rdquo;. It exists to convert older data.</p>
<h2 id="three-records-three-encodings">Three records, three encodings</h2>
<p><strong>Allow-list style (gix-fs, CVE-2026-100419).</strong> The range is affected and <code>defaultStatus</code> is <code>unaffected</code>. Version 0.22.1 is affected, 0.23.0 is unaffected by exact match, and 0.25.4 is unaffected by default.</p>
<p><strong>List without a default (quickjs, CVE-2026-0821).</strong> The CNA lists eleven single versions from 0.1 to 0.11.0 as affected and sets no <code>defaultStatus</code>. Version 0.10 is affected. Version 0.12.0 is <code>unknown</code>: the record makes no claim about it. A naive matcher that treats &ldquo;not listed&rdquo; as &ldquo;not affected&rdquo; would clear it.</p>
<p><strong>Deny-list style (Linux, CVE-2026-100075).</strong> The second product entry sets <code>&quot;defaultStatus&quot;: &quot;affected&quot;</code> and lists unaffected ranges per stable branch:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{<span style="color:#f92672">&#34;version&#34;</span>: <span style="color:#e6db74">&#34;4.7&#34;</span>, <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;affected&#34;</span>}<span style="color:#960050;background-color:#1e0010">,</span>
</span></span><span style="display:flex;"><span>{<span style="color:#f92672">&#34;version&#34;</span>: <span style="color:#e6db74">&#34;0&#34;</span>, <span style="color:#f92672">&#34;lessThan&#34;</span>: <span style="color:#e6db74">&#34;4.7&#34;</span>, <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;unaffected&#34;</span>, <span style="color:#f92672">&#34;versionType&#34;</span>: <span style="color:#e6db74">&#34;semver&#34;</span>}<span style="color:#960050;background-color:#1e0010">,</span>
</span></span><span style="display:flex;"><span>{<span style="color:#f92672">&#34;version&#34;</span>: <span style="color:#e6db74">&#34;6.6.157&#34;</span>, <span style="color:#f92672">&#34;lessThanOrEqual&#34;</span>: <span style="color:#e6db74">&#34;6.6.*&#34;</span>, <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;unaffected&#34;</span>, <span style="color:#f92672">&#34;versionType&#34;</span>: <span style="color:#e6db74">&#34;semver&#34;</span>}<span style="color:#960050;background-color:#1e0010">,</span>
</span></span><span style="display:flex;"><span>{<span style="color:#f92672">&#34;version&#34;</span>: <span style="color:#e6db74">&#34;7.3&#34;</span>, <span style="color:#f92672">&#34;lessThanOrEqual&#34;</span>: <span style="color:#e6db74">&#34;*&#34;</span>, <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;unaffected&#34;</span>, <span style="color:#f92672">&#34;versionType&#34;</span>: <span style="color:#e6db74">&#34;original_commit_for_fix&#34;</span>}
</span></span></code></pre></div><p>(Excerpt; the record also lists 5.10, 5.15, 6.1, 6.12, 6.18 and 7.2 branches.) Kernels before 4.7 are unaffected, 6.6.157 and later 6.6 releases are unaffected, and anything not covered, such as 6.6.150 or a 5.4 kernel, falls through to <code>affected</code>. The first product entry in the same record describes the same flaw with <code>versionType: git</code> commit ranges, which you cannot evaluate without the kernel&rsquo;s commit history.</p>
<h2 id="what-a-sample-of-2026-records-contains">What a sample of 2026 records contains</h2>
<p>We drew 1,000 CVE IDs at random (seeded) from the 83,665 record files under <code>cves/2026</code> at cvelistV5 commit 451e97a. 16 were <code>REJECTED</code>; the 984 published records held 2,061 product entries, 4,555 version entries and 2,831 ranges. Counts were computed with a Python script over the JSON.</p>
<table>
  <thead>
      <tr>
          <th>Measure</th>
          <th>Count</th>
          <th>Share</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Product entries with no <code>defaultStatus</code> (so <code>unknown</code> applies)</td>
          <td>1,130 of 2,061</td>
          <td>54.8%</td>
      </tr>
      <tr>
          <td><code>defaultStatus: unaffected</code></td>
          <td>585</td>
          <td>28.4%</td>
      </tr>
      <tr>
          <td><code>defaultStatus: affected</code></td>
          <td>311</td>
          <td>15.1%</td>
      </tr>
      <tr>
          <td>Product entries with only single versions and no <code>defaultStatus</code></td>
          <td>315</td>
          <td>15.3%</td>
      </tr>
      <tr>
          <td>Version entries that are single versions / <code>lessThan</code> ranges / <code>lessThanOrEqual</code> ranges</td>
          <td>1,724 / 1,929 / 902 of 4,555</td>
          <td>37.8% / 42.3% / 19.8%</td>
      </tr>
      <tr>
          <td>Ranges with <code>versionType: custom</code></td>
          <td>1,006 of 2,831</td>
          <td>35.5%</td>
      </tr>
      <tr>
          <td>Ranges with <code>semver</code> / <code>git</code> / <code>rpm</code></td>
          <td>971 / 560 / 171</td>
          <td>34.3% / 19.8% / 6.0%</td>
      </tr>
      <tr>
          <td>Ranges with a <code>*</code> in the upper limit</td>
          <td>717</td>
          <td>25.3%</td>
      </tr>
      <tr>
          <td>Version entries using <code>changes</code></td>
          <td>48</td>
          <td>1.1%</td>
      </tr>
      <tr>
          <td>Published records with a <code>cpeApplicability</code> block</td>
          <td>290 of 984</td>
          <td>29.5%</td>
      </tr>
      <tr>
          <td>Published records with a <code>packageURL</code> on any product</td>
          <td>61</td>
          <td>6.2%</td>
      </tr>
  </tbody>
</table>
<p>Two numbers matter most for automation. More than half of product entries leave unlisted versions as <code>unknown</code>, and about a third of ranges use <code>custom</code>, so a generic comparator cannot decide them. This is one sample at one point in time; the mix varies by CNA, and a different year or sample would give different shares.</p>
<h2 id="a-tested-evaluator">A tested evaluator</h2>
<p>The script implements the schema&rsquo;s algorithm. It handles dotted numeric versions (so 1.2.10 sorts after 1.2.9) and <code>*</code>, and it refuses any range whose <code>versionType</code> it has not been told how to order. It does not implement semver pre-release ordering, RPM epochs or Maven qualifiers; treat it as a reference, not a drop-in matcher. Python 3.10 or later, standard library only.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e">#!/usr/bin/env python3</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;Decide affected / unaffected / unknown for one product version from a CVE record,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">following the algorithm in the CVE JSON 5 schema (product.versions + defaultStatus).
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Usage: affected_status.py RECORD.json VERSION [vendor/product filter] [--numeric TYPE,...]
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> json<span style="color:#f92672">,</span> sys
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>SUPPORTED <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;semver&#34;</span>}  <span style="color:#75715e"># extend with real comparators (rpm, maven, python...) before use</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">key</span>(v):
</span></span><span style="display:flex;"><span>    parts <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> p <span style="color:#f92672">in</span> v<span style="color:#f92672">.</span>split(<span style="color:#e6db74">&#34;.&#34;</span>):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> p <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;*&#34;</span>:
</span></span><span style="display:flex;"><span>            parts<span style="color:#f92672">.</span>append(float(<span style="color:#e6db74">&#34;inf&#34;</span>))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">elif</span> p<span style="color:#f92672">.</span>isdigit():
</span></span><span style="display:flex;"><span>            parts<span style="color:#f92672">.</span>append(int(p))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">else</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;non-numeric version part </span><span style="color:#e6db74">{</span>p<span style="color:#e6db74">!r}</span><span style="color:#e6db74"> in </span><span style="color:#e6db74">{</span>v<span style="color:#e6db74">!r}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> parts
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">cmp</span>(a, b):
</span></span><span style="display:flex;"><span>    ka, kb <span style="color:#f92672">=</span> key(a), key(b)
</span></span><span style="display:flex;"><span>    n <span style="color:#f92672">=</span> max(len(ka), len(kb))
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># pad: a missing part is 0, except after a wildcard, which stays infinite</span>
</span></span><span style="display:flex;"><span>    ka <span style="color:#f92672">+=</span> [float(<span style="color:#e6db74">&#34;inf&#34;</span>) <span style="color:#66d9ef">if</span> ka <span style="color:#f92672">and</span> ka[<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>] <span style="color:#f92672">==</span> float(<span style="color:#e6db74">&#34;inf&#34;</span>) <span style="color:#66d9ef">else</span> <span style="color:#ae81ff">0</span>] <span style="color:#f92672">*</span> (n <span style="color:#f92672">-</span> len(ka))
</span></span><span style="display:flex;"><span>    kb <span style="color:#f92672">+=</span> [float(<span style="color:#e6db74">&#34;inf&#34;</span>) <span style="color:#66d9ef">if</span> kb <span style="color:#f92672">and</span> kb[<span style="color:#f92672">-</span><span style="color:#ae81ff">1</span>] <span style="color:#f92672">==</span> float(<span style="color:#e6db74">&#34;inf&#34;</span>) <span style="color:#66d9ef">else</span> <span style="color:#ae81ff">0</span>] <span style="color:#f92672">*</span> (n <span style="color:#f92672">-</span> len(kb))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> (ka <span style="color:#f92672">&gt;</span> kb) <span style="color:#f92672">-</span> (ka <span style="color:#f92672">&lt;</span> kb)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">status</span>(product, v):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> e <span style="color:#f92672">in</span> product<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;versions&#34;</span>, []):
</span></span><span style="display:flex;"><span>        is_range <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;lessThan&#34;</span> <span style="color:#f92672">in</span> e <span style="color:#f92672">or</span> <span style="color:#e6db74">&#34;lessThanOrEqual&#34;</span> <span style="color:#f92672">in</span> e
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> is_range:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> v <span style="color:#f92672">==</span> e[<span style="color:#e6db74">&#34;version&#34;</span>]:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">return</span> e[<span style="color:#e6db74">&#34;status&#34;</span>], <span style="color:#e6db74">&#34;exact match&#34;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>        vt <span style="color:#f92672">=</span> e<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;versionType&#34;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> vt <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> SUPPORTED:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#e6db74">&#34;undetermined&#34;</span>, <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;range uses versionType </span><span style="color:#e6db74">{</span>vt<span style="color:#e6db74">!r}</span><span style="color:#e6db74">; needs that ordering&#34;</span>
</span></span><span style="display:flex;"><span>        lo_ok <span style="color:#f92672">=</span> cmp(e[<span style="color:#e6db74">&#34;version&#34;</span>], v) <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>        hi_ok <span style="color:#f92672">=</span> (cmp(v, e[<span style="color:#e6db74">&#34;lessThan&#34;</span>]) <span style="color:#f92672">&lt;</span> <span style="color:#ae81ff">0</span>) <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;lessThan&#34;</span> <span style="color:#f92672">in</span> e <span style="color:#66d9ef">else</span> (cmp(v, e[<span style="color:#e6db74">&#34;lessThanOrEqual&#34;</span>]) <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">0</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> lo_ok <span style="color:#f92672">and</span> hi_ok:
</span></span><span style="display:flex;"><span>            s <span style="color:#f92672">=</span> e[<span style="color:#e6db74">&#34;status&#34;</span>]
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">for</span> c <span style="color:#f92672">in</span> sorted(e<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;changes&#34;</span>, []), key<span style="color:#f92672">=</span><span style="color:#66d9ef">lambda</span> c: key(c[<span style="color:#e6db74">&#34;at&#34;</span>])):  <span style="color:#75715e"># schema: re-sort yourself</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> cmp(c[<span style="color:#e6db74">&#34;at&#34;</span>], v) <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">0</span>:
</span></span><span style="display:flex;"><span>                    s <span style="color:#f92672">=</span> c[<span style="color:#e6db74">&#34;status&#34;</span>]
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> s, <span style="color:#e6db74">&#34;range match&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> product<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;defaultStatus&#34;</span>, <span style="color:#e6db74">&#34;unknown&#34;</span>), <span style="color:#e6db74">&#34;defaultStatus&#34;</span> <span style="color:#f92672">+</span> (<span style="color:#e6db74">&#34;&#34;</span> <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;defaultStatus&#34;</span> <span style="color:#f92672">in</span> product <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34; (absent, so unknown)&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    args <span style="color:#f92672">=</span> sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">1</span>:]
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;--numeric&#34;</span> <span style="color:#f92672">in</span> args:  <span style="color:#75715e"># opt-in: treat these extra versionTypes as dotted numeric</span>
</span></span><span style="display:flex;"><span>        i <span style="color:#f92672">=</span> args<span style="color:#f92672">.</span>index(<span style="color:#e6db74">&#34;--numeric&#34;</span>)
</span></span><span style="display:flex;"><span>        SUPPORTED<span style="color:#f92672">.</span>update(args[i <span style="color:#f92672">+</span> <span style="color:#ae81ff">1</span>]<span style="color:#f92672">.</span>split(<span style="color:#e6db74">&#34;,&#34;</span>))
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">del</span> args[i:i <span style="color:#f92672">+</span> <span style="color:#ae81ff">2</span>]
</span></span><span style="display:flex;"><span>    sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">1</span>:] <span style="color:#f92672">=</span> args
</span></span><span style="display:flex;"><span>    rec <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>load(open(sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">1</span>]))
</span></span><span style="display:flex;"><span>    version <span style="color:#f92672">=</span> sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">2</span>]
</span></span><span style="display:flex;"><span>    want <span style="color:#f92672">=</span> sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">3</span>]<span style="color:#f92672">.</span>lower() <span style="color:#66d9ef">if</span> len(sys<span style="color:#f92672">.</span>argv) <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">3</span> <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> rec[<span style="color:#e6db74">&#34;cveMetadata&#34;</span>][<span style="color:#e6db74">&#34;state&#34;</span>] <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#34;PUBLISHED&#34;</span>:
</span></span><span style="display:flex;"><span>        sys<span style="color:#f92672">.</span>exit(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>rec[<span style="color:#e6db74">&#39;cveMetadata&#39;</span>][<span style="color:#e6db74">&#39;cveId&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74"> is </span><span style="color:#e6db74">{</span>rec[<span style="color:#e6db74">&#39;cveMetadata&#39;</span>][<span style="color:#e6db74">&#39;state&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> i, p <span style="color:#f92672">in</span> enumerate(rec[<span style="color:#e6db74">&#34;containers&#34;</span>][<span style="color:#e6db74">&#34;cna&#34;</span>]<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;affected&#34;</span>, [])):
</span></span><span style="display:flex;"><span>        name <span style="color:#f92672">=</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>p<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;vendor&#39;</span>, <span style="color:#e6db74">&#39;&#39;</span>)<span style="color:#e6db74">}</span><span style="color:#e6db74">/</span><span style="color:#e6db74">{</span>p<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;product&#39;</span>, p<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;packageName&#39;</span>, <span style="color:#e6db74">&#39;&#39;</span>))<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> want <span style="color:#f92672">and</span> want <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> name<span style="color:#f92672">.</span>lower():
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>        print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>rec[<span style="color:#e6db74">&#39;cveMetadata&#39;</span>][<span style="color:#e6db74">&#39;cveId&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74"> [</span><span style="color:#e6db74">{</span>i<span style="color:#e6db74">}</span><span style="color:#e6db74">] </span><span style="color:#e6db74">{</span>name<span style="color:#e6db74">}</span><span style="color:#e6db74"> </span><span style="color:#e6db74">{</span>version<span style="color:#e6db74">}</span><span style="color:#e6db74">: %s (%s)&#34;</span> <span style="color:#f92672">%</span> status(p, version))
</span></span></code></pre></div><p>We ran it with Python 3.13 on November 2, 2026 against records downloaded from cvelistV5 (<code>raw.githubusercontent.com/CVEProject/cvelistV5/&lt;commit&gt;/cves/2026/&lt;block&gt;/&lt;CVE&gt;.json</code>). Selected output:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>CVE-2026-100419 [0] GitoxideLabs/gitoxide 0.22.1: affected (range match)
</span></span><span style="display:flex;"><span>CVE-2026-100419 [0] GitoxideLabs/gitoxide 0.23.0: unaffected (exact match)
</span></span><span style="display:flex;"><span>CVE-2026-100419 [0] GitoxideLabs/gitoxide 0.25.4: unaffected (defaultStatus)
</span></span><span style="display:flex;"><span>CVE-2026-0821 [0] quickjs-ng/quickjs 0.12.0: unknown (defaultStatus (absent, so unknown))
</span></span><span style="display:flex;"><span>CVE-2026-100075 [0] Linux/Linux 6.6.150: undetermined (range uses versionType &#39;git&#39;; needs that ordering)
</span></span><span style="display:flex;"><span>CVE-2026-100075 [1] Linux/Linux 6.6.150: undetermined (range uses versionType &#39;original_commit_for_fix&#39;; needs that ordering)
</span></span><span style="display:flex;"><span>CVE-2026-103345 [0] Shamim Rajani/Pie Register 3.8.4.13: undetermined (range uses versionType &#39;custom&#39;; needs that ordering)
</span></span><span style="display:flex;"><span>CVE-2026-23915 is REJECTED
</span></span></code></pre></div><p>The Linux result is the instructive one. For 6.6.150 the evaluator reaches the <code>7.3 ... *</code> entry, whose <code>versionType</code> is <code>original_commit_for_fix</code>, a value that is not among the schema&rsquo;s examples. A strict implementation stops there. If you decide, for this CNA&rsquo;s data, that those values are ordinary kernel version numbers, you can opt in with <code>--numeric original_commit_for_fix</code>, and the second product entry then gives 4.4.302 unaffected, 4.7 affected, 6.6.150 affected (by <code>defaultStatus</code>), 6.6.157 unaffected and 7.3 unaffected. That opt-in is our judgment about one CNA&rsquo;s encoding, not something the schema states.</p>
<h2 id="common-mistakes">Common mistakes</h2>
<ul>
<li><strong>Treating &ldquo;not listed&rdquo; as &ldquo;not affected&rdquo;.</strong> Without <code>defaultStatus: unaffected</code>, unlisted versions are <code>unknown</code>.</li>
<li><strong>Off-by-one on the upper bound.</strong> <code>lessThan: 0.23.0</code> excludes 0.23.0; <code>lessThanOrEqual: 3.8.4.13</code> includes 3.8.4.13.</li>
<li><strong>Comparing strings.</strong> As text, &ldquo;0.10&rdquo; sorts before &ldquo;0.9&rdquo;. Parse versions with the ecosystem&rsquo;s rules.</li>
<li><strong>Assuming <code>changes</code> is sorted.</strong> The schema tells clients to re-sort.</li>
<li><strong>Reading only the first product entry.</strong> The same record can describe a product twice, as the Linux record does with git ranges and version ranges.</li>
<li><strong>Ignoring the record state.</strong> A <code>REJECTED</code> record has no <code>affected</code> block to evaluate; see <a href="/posts/cve-record-states-published-rejected-and-disputed-what-each-means-for-vulnerability-triage/">CVE record states</a>.</li>
</ul>
<h2 id="where-this-fits-in-triage">Where this fits in triage</h2>
<p>Version matching answers &ldquo;do we run an affected version?&rdquo;, which comes before any priority signal. If the answer is <code>unknown</code> or undetermined, check the vendor advisory, or a supplier VEX statement that says whether the product is affected (see <a href="/posts/vex-explained-what-a-not-affected-statement-means-and-how-to-read-one/">how to read a VEX not-affected statement</a>). An SBOM with accurate component versions is what makes this lookup possible at scale; our summary of the <a href="/posts/sbom-minimum-elements-2026-what-cisa-and-partners-changed-from-the-2021-ntia-baseline/">2026 SBOM minimum elements</a> covers what to ask suppliers for. Once a finding is confirmed as affected, our <a href="/posts/vulnerability-prioritization-workflow-cve-record-to-patch-ticket/">vulnerability prioritization workflow</a> takes it from record to ticket using KEV, EPSS and SSVC.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>The sample covers 2026 records only and reflects the repository on November 2, 2026; CNAs update records, so shares will drift. Our evaluator is a reference implementation of the schema&rsquo;s algorithm for numeric versions, not a full comparator for semver pre-releases, RPM, Maven, Python or git ranges. We did not examine ADP containers, which can carry their own <code>affected</code> data (15 of the 984 sampled records had one), or NVD&rsquo;s separate CPE configurations.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Field definitions, the status algorithm, <code>defaultStatus</code> defaults to unknown, <code>*</code> wildcard, <code>changes</code> must be re-sorted, <code>packageURL</code> must not include a version</td>
          <td><a href="https://github.com/CVEProject/cve-schema/blob/main/schema/CVE_Record_Format.json">CVE JSON schema, CVE_Record_Format.json (v5.2.0)</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>version: 0</code> convention, Maven vs semver ordering example, <code>custom</code> means the algorithm cannot be executed</td>
          <td><a href="https://github.com/CVEProject/cve-schema/blob/main/schema/docs/versions.md">CVE schema version-encoding guide (versions.md)</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Example records CVE-2026-100419, CVE-2026-0821, CVE-2026-100075, CVE-2026-103345, CVE-2026-23915</td>
          <td><a href="https://github.com/CVEProject/cvelistV5">cvelistV5 repository</a>, commit 451e97a</td>
          <td>Verified (read November 2, 2026)</td>
      </tr>
      <tr>
          <td>Sample counts and percentages</td>
          <td>Our analysis of 1,000 randomly sampled 2026 records from cvelistV5 at commit 451e97a</td>
          <td>Verified (computed with code); a sample, not a census</td>
      </tr>
      <tr>
          <td>Evaluator output</td>
          <td>Script above, run with Python 3.13</td>
          <td>Verified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>How to Secure GitHub Actions: Permissions, Secrets, and OIDC</title><link>https://firewallsync.com/posts/how-to-secure-github-actions-permissions-secrets-and-oidc/</link><guid>https://firewallsync.com/posts/how-to-secure-github-actions-permissions-secrets-and-oidc/</guid><pubDate>Sun, 01 Nov 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>A GitHub Actions workflow often runs with more access than the task actually requires, simply because the defaults are permissive and nobody scoped them down before shipping.</description><enclosure url="https://firewallsync.com/posts/how-to-secure-github-actions-permissions-secrets-and-oidc/cover_hu14058028644612349825.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/how-to-secure-github-actions-permissions-secrets-and-oidc/cover_hu14058028644612349825.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/how-to-secure-github-actions-permissions-secrets-and-oidc/cover_hu14058028644612349825.jpg" alt="Laptop on a desk showing code" width="1200" height="630"></p><p>A GitHub Actions workflow, by default, often runs with more access than the task it performs actually requires — not because anyone deliberately over-provisioned it, but because the defaults are permissive and narrowing them down takes deliberate configuration that&rsquo;s easy to skip while getting a pipeline working.</p>
<h2 id="scope-the-github_tokens-permissions-explicitly">Scope the GITHUB_TOKEN&rsquo;s permissions explicitly</h2>
<p>Every workflow run gets an automatically generated <code>GITHUB_TOKEN</code> with permissions to the repository, and its default scope has historically been broad (write access to most repository resources) unless explicitly restricted. Setting explicit <code>permissions</code> at the workflow or job level — for example, <code>contents: read</code> for a job that only needs to check out code, without write access it doesn&rsquo;t use — limits what a compromised or misbehaving step in that workflow could actually do. Treat &ldquo;no permissions block at all&rdquo; as equivalent to &ldquo;unreviewed, possibly excessive default access,&rdquo; and set it explicitly on every workflow rather than relying on whatever the default happens to be.</p>
<h2 id="third-party-actions-run-with-your-workflows-access">Third-party actions run with your workflow&rsquo;s access</h2>
<p>An action referenced with <code>uses: someorg/some-action@v1</code> executes with the same permissions and secrets access as the rest of your workflow — it isn&rsquo;t sandboxed away from your repository&rsquo;s context. A compromised or malicious action has the same practical reach as a line of code you wrote directly into the workflow yourself. Pin third-party actions to a specific commit SHA rather than a mutable version tag; a tag can be moved to point at different code without your workflow file changing at all, while a commit SHA cannot be silently swapped underneath you.</p>
<h2 id="be-deliberate-about-pull_request-vs-pull_request_target">Be deliberate about pull_request vs pull_request_target</h2>
<p>Workflows triggered by <code>pull_request</code> from a fork do not have access to repository secrets by default — this is intentional, specifically to prevent a malicious fork PR from exfiltrating secrets through a workflow run. <code>pull_request_target</code>, by contrast, does grant secrets access and runs with the target repository&rsquo;s permissions, which is necessary for some legitimate use cases (like commenting on PRs from forks) but requires careful handling of any code checked out from the fork, since that code is now running with elevated access it wouldn&rsquo;t otherwise have. Mixing this up — using <code>pull_request_target</code> without the corresponding caution around what fork-supplied code gets executed — is a well-documented way workflows get exploited.</p>
<h2 id="oidc-removes-the-need-for-long-lived-cloud-credentials-entirely">OIDC removes the need for long-lived cloud credentials entirely</h2>
<p>Storing static cloud credentials (an AWS access key, a GCP service account key) as a GitHub secret means that credential is a standing target — if it leaks, through any of the usual channels, it remains valid until someone notices and manually rotates it. GitHub Actions supports OpenID Connect (OIDC), which lets a workflow request a short-lived token from GitHub&rsquo;s OIDC provider and exchange it for temporary cloud credentials at request time, without any static credential stored in GitHub at all. AWS, GCP, and Azure all support configuring a trust relationship that allows this exchange, scoped to specific repositories or even specific branches.</p>
<p>This is a meaningfully different security posture, not just a convenience improvement: there&rsquo;s no long-lived credential to leak, because none exists in GitHub&rsquo;s secret storage in the first place — the token issued for each workflow run is scoped to that run and expires shortly after.</p>
<h2 id="secrets-scoping-beyond-just-using-github-secrets">Secrets scoping beyond just using GitHub Secrets</h2>
<p>Even with secrets correctly stored (rather than hardcoded), using repository-level secrets for everything means every workflow in the repo has access to every secret, regardless of whether that specific workflow needs it. GitHub&rsquo;s environment-level secrets, combined with required reviewers or branch restrictions on that environment, let you scope a deployment credential so it&rsquo;s only accessible to workflow runs specifically targeting a protected environment — meaningfully narrower than blanket repository-level access.</p>
<h2 id="reusable-and-self-hosted-runner-risks">Reusable and self-hosted runner risks</h2>
<p>Self-hosted runners execute workflow code on infrastructure you control, which means a compromised workflow (through a malicious PR, a compromised dependency pulled in during the job, or a compromised action) can potentially affect that underlying infrastructure directly, not just an ephemeral GitHub-hosted VM that&rsquo;s destroyed after the run. If self-hosted runners are used for anything handling untrusted input (like workflows triggered by external contributions), the isolation and cleanup between runs needs explicit attention that GitHub-hosted runners provide automatically.</p>
<h2 id="a-practical-checklist">A practical checklist</h2>
<ul>
<li>Set explicit, minimal <code>permissions</code> on every workflow rather than relying on defaults</li>
<li>Pin third-party actions to a commit SHA, not a mutable tag</li>
<li>Understand the secrets-access difference between <code>pull_request</code> and <code>pull_request_target</code>, and handle fork-supplied code with corresponding caution in the latter</li>
<li>Adopt OIDC for cloud provider authentication instead of storing long-lived static credentials as secrets</li>
<li>Use environment-level secrets with protection rules for anything tied to deployment, rather than defaulting to repository-level scope</li>
<li>Treat self-hosted runner isolation as something requiring explicit configuration, not something GitHub-hosted runners&rsquo; automatic cleanup provides for you</li>
</ul>
<p>None of these individually make a pipeline &ldquo;secure&rdquo; — together, they reduce a CI/CD system&rsquo;s blast radius from &ldquo;one compromised link exposes everything&rdquo; to something meaningfully more contained.</p>
]]></content:encoded></item><item><title>Escape User Input in Regex and SQL LIKE: Tested Fixes</title><link>https://firewallsync.com/posts/escape-user-input-regex-sql-like/</link><guid>https://firewallsync.com/posts/escape-user-input-regex-sql-like/</guid><pubDate>Sun, 01 Nov 2026 04:40:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>A search box that drops user text into new RegExp() or a LIKE pattern is a small injection bug: a dot matches anything, .* matches everything, and a % turns a filtered search into a full table scan that returns every row. Parameterised queries do not fix it. This guide shows the correct escaping function in Python, JavaScript and Go, the LIKE ESCAPE rules for PostgreSQL and SQLite, and the replacement-string trap that catches people after they have escaped the pattern.</description><enclosure url="https://firewallsync.com/posts/escape-user-input-regex-sql-like/cover_hu10145603437681109998.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/escape-user-input-regex-sql-like/cover_hu10145603437681109998.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/escape-user-input-regex-sql-like/cover_hu10145603437681109998.jpg" alt="Dozens of wooden letterpress type blocks, letters and numerals in warm brown tones, scattered on a wooden surface" width="1200" height="630"></p><p>Search features have a habit of taking a user&rsquo;s text and dropping it straight into a pattern: <code>new RegExp(query)</code>, <code>re.search(term, line)</code>, <code>WHERE name LIKE '%' || $1 || '%'</code>. The user meant &ldquo;find this text&rdquo;. The engine reads &ldquo;match this pattern&rdquo;. A dot becomes &ldquo;any character&rdquo;, <code>.*</code> becomes &ldquo;everything&rdquo;, and <code>%</code> in a LIKE search returns every row you were filtering.</p>
<p><strong>The short answer:</strong> when user text should match literally, escape it with the platform function (<code>re.escape</code>, <code>RegExp.escape</code>, <code>regexp.QuoteMeta</code>) before building the pattern. For SQL <code>LIKE</code>, bind the value as a parameter <strong>and</strong> escape <code>\</code>, <code>%</code> and <code>_</code> in it, with an explicit <code>ESCAPE '\'</code>. If the text is also used as a replacement string, pass a function, not a string.</p>
<p>This article is about user text that should be treated as literal. If your product lets users write real regular expressions, escaping is the wrong tool; see <a href="/posts/user-supplied-regex-redos-safe-engines/">User-Supplied Regex: How to Run It Without ReDoS</a>.</p>
<h2 id="what-goes-wrong-without-escaping">What goes wrong without escaping</h2>
<p>We ran each example below and show the output. Test environment: Python 3.13.18, Node.js 24.22.2 (V8 13.6) and 22.23.4, Go 1.24.7, SQLite 3.53.1 (Python&rsquo;s <code>sqlite3</code> module) and PostgreSQL 16.15, all on Linux.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Python 3.13: search log lines for a user-supplied term</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> re
</span></span><span style="display:flex;"><span>logs <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#34;user=alice&#34;</span>, <span style="color:#e6db74">&#34;user=al.ce&#34;</span>, <span style="color:#e6db74">&#34;user=bob&#34;</span>, <span style="color:#e6db74">&#34;user=admin&#34;</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>q <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;al.ce&#34;</span>
</span></span><span style="display:flex;"><span>[l <span style="color:#66d9ef">for</span> l <span style="color:#f92672">in</span> logs <span style="color:#66d9ef">if</span> re<span style="color:#f92672">.</span>search(q, l)]             <span style="color:#75715e"># [&#39;user=alice&#39;, &#39;user=al.ce&#39;]</span>
</span></span><span style="display:flex;"><span>[l <span style="color:#66d9ef">for</span> l <span style="color:#f92672">in</span> logs <span style="color:#66d9ef">if</span> re<span style="color:#f92672">.</span>search(re<span style="color:#f92672">.</span>escape(q), l)]  <span style="color:#75715e"># [&#39;user=al.ce&#39;]</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>q <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;.*&#34;</span>
</span></span><span style="display:flex;"><span>all(re<span style="color:#f92672">.</span>search(q, l) <span style="color:#66d9ef">for</span> l <span style="color:#f92672">in</span> logs)               <span style="color:#75715e"># True: matches every line</span>
</span></span><span style="display:flex;"><span>[l <span style="color:#66d9ef">for</span> l <span style="color:#f92672">in</span> logs <span style="color:#66d9ef">if</span> re<span style="color:#f92672">.</span>search(re<span style="color:#f92672">.</span>escape(q), l)]  <span style="color:#75715e"># []</span>
</span></span></code></pre></div><p>The consequences depend on where the pattern is used. In a search box it is a wrong result. In a filter that decides which records a user may see, a match against tenant or account names, or a log-redaction rule, a wildcard where you expected a literal is an authorisation or data-exposure bug. Unescaped input also lets the user add nested quantifiers to your pattern, which is a route to ReDoS in backtracking engines.</p>
<h2 id="regex-use-the-platform-escaper">Regex: use the platform escaper</h2>
<h3 id="python-reescape">Python: <code>re.escape</code></h3>
<p>The <a href="https://docs.python.org/3/library/re.html#re.escape">Python docs</a> describe <code>re.escape</code> as escaping &ldquo;special characters in <em>pattern</em>&rdquo;, useful &ldquo;if you want to match an arbitrary literal string that may have regular expression metacharacters in it.&rdquo; Since Python 3.7, &ldquo;only characters that can have special meaning in a regular expression are escaped.&rdquo;</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>re<span style="color:#f92672">.</span>escape(<span style="color:#e6db74">&#34;a.b*c(d)&#34;</span>)   <span style="color:#75715e"># &#39;a\\.b\\*c\\(d\\)&#39;</span>
</span></span><span style="display:flex;"><span>pattern <span style="color:#f92672">=</span> re<span style="color:#f92672">.</span>compile(<span style="color:#e6db74">r</span><span style="color:#e6db74">&#34;\b&#34;</span> <span style="color:#f92672">+</span> re<span style="color:#f92672">.</span>escape(user_term) <span style="color:#f92672">+</span> <span style="color:#e6db74">r</span><span style="color:#e6db74">&#34;\b&#34;</span>, re<span style="color:#f92672">.</span>IGNORECASE)
</span></span></code></pre></div><h3 id="go-regexpquotemeta">Go: <code>regexp.QuoteMeta</code></h3>
<p>Go&rsquo;s <a href="https://pkg.go.dev/regexp#QuoteMeta"><code>QuoteMeta</code></a> &ldquo;returns a string that escapes all regular expression metacharacters inside the argument text; the returned string is a regular expression matching the literal text.&rdquo;</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// Go 1.24.7
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Println</span>(<span style="color:#a6e22e">regexp</span>.<span style="color:#a6e22e">QuoteMeta</span>(<span style="color:#e6db74">&#34;al.ce*(x)&#34;</span>))                                  <span style="color:#75715e">// al\.ce\*\(x\)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Println</span>(<span style="color:#a6e22e">regexp</span>.<span style="color:#a6e22e">MustCompile</span>(<span style="color:#a6e22e">regexp</span>.<span style="color:#a6e22e">QuoteMeta</span>(<span style="color:#e6db74">&#34;al.ce&#34;</span>)).<span style="color:#a6e22e">MatchString</span>(<span style="color:#e6db74">&#34;alice&#34;</span>)) <span style="color:#75715e">// false
</span></span></span></code></pre></div><h3 id="javascript-regexpescape-and-why-the-old-snippet-is-not-enough">JavaScript: <code>RegExp.escape</code>, and why the old snippet is not enough</h3>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape">MDN</a> describes <code>RegExp.escape()</code> as returning &ldquo;a new string that can be safely used as a literal pattern for the <code>RegExp()</code> constructor&rdquo;, and marks it Baseline 2025. It is present in Node.js 24 and absent in Node.js 22:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>node v24.22.2 RegExp.escape: function
</span></span><span style="display:flex;"><span>node v22.23.4 RegExp.escape: undefined
</span></span></code></pre></div><p>The output looks odd at first: <code>RegExp.escape(&quot;foo.bar&quot;)</code> returns <code>\x66oo\.bar</code>. MDN explains that a leading digit or ASCII letter is written as a <code>\x</code> escape so that, if the result is &ldquo;immediately preceded by <code>\1</code>, <code>\x0</code>, <code>\u000</code>, etc., the leading character doesn&rsquo;t get interpreted as part of the escape sequence.&rdquo; Other punctuators such as <code>-</code> are also written as <code>\x</code> escapes.</p>
<p>That is exactly where the widely copied replace-based escaper falls short. We compared the two in Node.js 24:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#75715e">// Node.js 24.22.2
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">escapeRegExp</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">s</span> =&gt; <span style="color:#a6e22e">s</span>.<span style="color:#a6e22e">replace</span>(<span style="color:#e6db74">/[.*+?^${}()|[\]\\]/g</span>, <span style="color:#e6db74">&#34;\\$&amp;&#34;</span>); <span style="color:#75715e">// common fallback
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 1. User text placed right after a backreference
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">new</span> RegExp(<span style="color:#e6db74">&#34;(a)\\1&#34;</span> <span style="color:#f92672">+</span> <span style="color:#a6e22e">escapeRegExp</span>(<span style="color:#e6db74">&#34;1&#34;</span>)).<span style="color:#a6e22e">source</span>;   <span style="color:#75715e">// &#39;(a)\\11&#39;  -&gt; test(&#34;aa1&#34;) is false
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">new</span> RegExp(<span style="color:#e6db74">&#34;(a)\\1&#34;</span> <span style="color:#f92672">+</span> RegExp.<span style="color:#a6e22e">escape</span>(<span style="color:#e6db74">&#34;1&#34;</span>)).<span style="color:#a6e22e">source</span>;  <span style="color:#75715e">// &#39;(a)\\1\\x31&#39; -&gt; test(&#34;aa1&#34;) is true
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 2. User text placed inside a character class
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">new</span> RegExp(<span style="color:#e6db74">&#34;[&#34;</span> <span style="color:#f92672">+</span> <span style="color:#a6e22e">escapeRegExp</span>(<span style="color:#e6db74">&#34;a-z&#34;</span>) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;]&#34;</span>).<span style="color:#a6e22e">test</span>(<span style="color:#e6db74">&#34;m&#34;</span>);   <span style="color:#75715e">// true: became the range a-z
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">new</span> RegExp(<span style="color:#e6db74">&#34;[&#34;</span> <span style="color:#f92672">+</span> RegExp.<span style="color:#a6e22e">escape</span>(<span style="color:#e6db74">&#34;a-z&#34;</span>) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;]&#34;</span>).<span style="color:#a6e22e">test</span>(<span style="color:#e6db74">&#34;m&#34;</span>);  <span style="color:#75715e">// false: matches only a, -, z
</span></span></span></code></pre></div><p>In both cases the fallback produced a valid pattern that matched something different from what was intended. MDN&rsquo;s advice is direct: &ldquo;don&rsquo;t try to re-implement its functionality,&rdquo; because <code>RegExp.escape()</code> &ldquo;is designed to use escape sequences that work in many more edge cases/contexts than hand-crafted code is likely to achieve.&rdquo;</p>
<p>For code that must also run on older runtimes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#75715e">// Prefer the built-in; the fallback is only safe when the escaped text
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// is not placed inside [...] or directly after a backslash escape.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">escapeRegExp</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">typeof</span> RegExp.<span style="color:#a6e22e">escape</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#34;function&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">?</span> RegExp.<span style="color:#a6e22e">escape</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">:</span> <span style="color:#a6e22e">s</span> =&gt; <span style="color:#a6e22e">s</span>.<span style="color:#a6e22e">replace</span>(<span style="color:#e6db74">/[.*+?^${}()|[\]\\]/g</span>, <span style="color:#e6db74">&#34;\\$&amp;&#34;</span>);
</span></span></code></pre></div><h2 id="escaping-does-not-cover-the-replacement-string">Escaping does not cover the replacement string</h2>
<p>Teams that escape the pattern often forget that the replacement argument has its own syntax.</p>
<p><strong>JavaScript.</strong> In <code>String.prototype.replace</code>, a replacement <em>string</em> interprets <code>$&amp;</code>, <code>$1</code>, <code>$&lt;name&gt;</code> and similar. A function&rsquo;s return value is used as is:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#75715e">// Node.js 24.22.2
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">name</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;$&amp;$&amp;&#34;</span>;                               <span style="color:#75715e">// user-controlled
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#e6db74">&#34;Hello NAME&#34;</span>.<span style="color:#a6e22e">replace</span>(<span style="color:#e6db74">&#34;NAME&#34;</span>, <span style="color:#a6e22e">name</span>);                <span style="color:#75715e">// &#39;Hello NAMENAME&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#e6db74">&#34;Hello NAME&#34;</span>.<span style="color:#a6e22e">replace</span>(<span style="color:#e6db74">&#34;NAME&#34;</span>, () =&gt; <span style="color:#a6e22e">name</span>);          <span style="color:#75715e">// &#39;Hello $&amp;$&amp;&#39;
</span></span></span></code></pre></div><p><strong>Python.</strong> The <code>re.escape</code> docs say it &ldquo;must not be used for the replacement string in <code>sub()</code> and <code>subn()</code>, only backslashes should be escaped.&rdquo; In practice, passing a function avoids the problem entirely:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>s <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;name: NAME&#34;</span>
</span></span><span style="display:flex;"><span>re<span style="color:#f92672">.</span>sub(<span style="color:#e6db74">&#34;NAME&#34;</span>, <span style="color:#e6db74">r</span><span style="color:#e6db74">&#34;C:\new\1&#34;</span>, s)          <span style="color:#75715e"># re.error: invalid group reference 1 at position 7</span>
</span></span><span style="display:flex;"><span>re<span style="color:#f92672">.</span>sub(<span style="color:#e6db74">&#34;NAME&#34;</span>, <span style="color:#66d9ef">lambda</span> m: <span style="color:#e6db74">r</span><span style="color:#e6db74">&#34;C:\new\1&#34;</span>, s)  <span style="color:#75715e"># &#39;name: C:\\new\\1&#39;</span>
</span></span></code></pre></div><p>A replacement string that happens to contain <code>\n</code> or <code>\1</code> either changes the output or raises, depending on the text. Either is a bug when the text comes from a user. The same caution applies to anything that renders model or user output into templates; see <a href="/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/">treating LLM output as untrusted input</a>.</p>
<h2 id="sql-like-parameters-are-not-enough">SQL LIKE: parameters are not enough</h2>
<p>A bind parameter keeps the value out of the SQL grammar, so <code>'; DROP TABLE</code> is just text. But <code>LIKE</code> interprets <code>%</code> (any sequence) and <code>_</code> (any single character) inside the value, bound or not. The <a href="https://www.postgresql.org/docs/current/functions-matching.html">PostgreSQL docs</a> state: &ldquo;The default escape character is the backslash but a different one can be selected by using the ESCAPE clause.&rdquo; Writing <code>ESCAPE ''</code> &ldquo;effectively disables the escape mechanism.&rdquo;</p>
<p>Escape the escape character first, then the wildcards:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">like_escape</span>(s: str, esc: str <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\\</span><span style="color:#e6db74">&#34;</span>) <span style="color:#f92672">-&gt;</span> str:
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> s<span style="color:#f92672">.</span>replace(esc, esc <span style="color:#f92672">*</span> <span style="color:#ae81ff">2</span>)<span style="color:#f92672">.</span>replace(<span style="color:#e6db74">&#34;%&#34;</span>, esc <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;%&#34;</span>)<span style="color:#f92672">.</span>replace(<span style="color:#e6db74">&#34;_&#34;</span>, esc <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;_&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># SQLite: no escape character unless you declare one</span>
</span></span><span style="display:flex;"><span>cur<span style="color:#f92672">.</span>execute(
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;SELECT name FROM u WHERE name LIKE ? ESCAPE &#39;</span><span style="color:#ae81ff">\\</span><span style="color:#e6db74">&#39;&#34;</span>,
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;%&#34;</span> <span style="color:#f92672">+</span> like_escape(term) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;%&#34;</span>,),
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>Test table: <code>alice</code>, <code>bob</code>, <code>50% off</code>, <code>a_b</code>, <code>axb</code>. Results with the search value bound as a parameter:</p>
<table>
  <thead>
      <tr>
          <th>Search term</th>
          <th>SQLite, bound, not escaped</th>
          <th>SQLite, escaped + <code>ESCAPE '\'</code></th>
          <th>SQLite, escaped, no <code>ESCAPE</code></th>
          <th>PostgreSQL, escaped (default <code>\</code>)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>%</code></td>
          <td>all 5 rows</td>
          <td><code>50% off</code></td>
          <td>none</td>
          <td><code>50% off</code></td>
      </tr>
      <tr>
          <td><code>_</code></td>
          <td>all 5 rows</td>
          <td><code>a_b</code></td>
          <td>none</td>
          <td>not run</td>
      </tr>
      <tr>
          <td><code>a_b</code></td>
          <td><code>a_b</code>, <code>axb</code></td>
          <td><code>a_b</code></td>
          <td>none</td>
          <td><code>a_b</code></td>
      </tr>
  </tbody>
</table>
<p>Two things stand out. First, a bound <code>%</code> or <code>_</code> returns every row: that turns a filtered search into an export of the table. Second, the escaping and the <code>ESCAPE</code> clause must agree. SQLite has no default escape character, so escaped input without <code>ESCAPE '\'</code> matched nothing. PostgreSQL uses backslash by default, but stating <code>ESCAPE '\'</code> anyway makes the query portable and removes the dependency on the default.</p>
<p>Two more differences we observed: SQLite&rsquo;s <code>LIKE</code> was case-insensitive for ASCII (<code>'ALICE' LIKE 'alice'</code> returned 1), while PostgreSQL&rsquo;s was case-sensitive (<code>false</code>; use <code>ILIKE</code> there). If your ORM offers a &ldquo;contains&rdquo; or &ldquo;starts with&rdquo; helper, check whether it escapes wildcards for your database before relying on it.</p>
<p>The same applies to other pattern operators: PostgreSQL&rsquo;s docs warn, &ldquo;Be wary of accepting regular-expression search patterns from hostile sources&rdquo;, for <code>SIMILAR TO</code> and POSIX regex operators.</p>
<h2 id="checklist">Checklist</h2>
<ul>
<li>Decide whether the user is supplying <strong>text</strong> or a <strong>pattern</strong>. Text gets escaped; patterns need a safe engine and limits.</li>
<li>Use <code>re.escape</code>, <code>RegExp.escape</code> or <code>regexp.QuoteMeta</code>. Feature-detect <code>RegExp.escape</code> on Node.js 22 and older browsers.</li>
<li>Never put fallback-escaped text inside <code>[...]</code> or directly after a backslash escape.</li>
<li>For <code>LIKE</code>: bind the value, escape <code>\</code>, <code>%</code>, <code>_</code> in that order, and always write <code>ESCAPE '\'</code>.</li>
<li>Use a function, not a string, when user text is the replacement in <code>replace</code> or <code>re.sub</code>.</li>
<li>Add a test with <code>.</code>, <code>.*</code>, <code>%</code>, <code>_</code>, <code>$&amp;</code> and a backslash in the input. The bug is invisible with ordinary names.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We tested the runtimes and versions listed above; MySQL, SQL Server and Oracle have their own LIKE escape rules and were not tested. Other regex dialects (Java, .NET, PCRE) have their own quoting functions not covered here. The JavaScript fallback is shown to illustrate its limits, not as a recommendation.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>re.escape</code> purpose, 3.7 change, replacement-string warning</td>
          <td><a href="https://docs.python.org/3/library/re.html#re.escape">Python <code>re</code> docs</a> (cpython main, Doc/library/re.rst)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>QuoteMeta</code> behaviour</td>
          <td><a href="https://pkg.go.dev/regexp#QuoteMeta">Go <code>regexp</code> package</a> (src/regexp/regexp.go)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>RegExp.escape</code> behaviour, leading-character rule, Baseline 2025, re-implementation warning</td>
          <td><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape">MDN RegExp.escape()</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>PostgreSQL default escape character, <code>ESCAPE ''</code>, regex caution</td>
          <td><a href="https://www.postgresql.org/docs/current/functions-matching.html">PostgreSQL pattern matching docs</a> (read from the 17.1 text)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Fallback escaper failing after <code>\1</code> and inside <code>[...]</code>; replacement-string behaviour; LIKE results; case sensitivity</td>
          <td>Our tests on the versions listed</td>
          <td>Verified (tested)</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Dependency Confusion: How Package Managers Can Become an Attack Path</title><link>https://firewallsync.com/posts/dependency-confusion-how-package-managers-can-become-an-attack-path/</link><guid>https://firewallsync.com/posts/dependency-confusion-how-package-managers-can-become-an-attack-path/</guid><pubDate>Sat, 31 Oct 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>Dependency confusion doesn't exploit a vulnerability in any package. It exploits a resolution assumption most package managers share, and it was proven against 35+ major companies without a single CVE.</description><enclosure url="https://firewallsync.com/posts/dependency-confusion-how-package-managers-can-become-an-attack-path/cover_hu15105596631507036774.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/dependency-confusion-how-package-managers-can-become-an-attack-path/cover_hu15105596631507036774.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/dependency-confusion-how-package-managers-can-become-an-attack-path/cover_hu15105596631507036774.jpg" alt="Stack of cargo containers" width="1200" height="630"></p><p>In February 2021, security researcher Alex Birsan demonstrated a technique that compromised build systems at Apple, Microsoft, PayPal, Tesla, Uber, Yelp, and more than 30 other major companies — earning over $130,000 in bug bounties in the process. None of it involved a zero-day exploit, stolen credentials, or malware hidden inside a legitimate package. It exploited a single, widely shared assumption in how package managers resolve dependency names.</p>
<h2 id="the-assumption-being-exploited">The assumption being exploited</h2>
<p>Many organizations use internal, unscoped package names for private code — something like <code>auth-utils</code> or <code>internal-logger</code>, hosted on a private registry alongside public packages resolved from npm, PyPI, or similar. Several package managers, when configured to check both public and private registries, will resolve to whichever source has the higher version number, or will check the public registry as part of normal resolution regardless of whether a private package with the same name exists. If an attacker publishes a public package with the exact same name as your private one, at a deliberately high version number, some build configurations will pull the attacker&rsquo;s public package instead of your legitimate internal one — with no error, and no obvious signal that anything went wrong.</p>
<h2 id="how-birsan-actually-found-the-names">How Birsan actually found the names</h2>
<p>The hardest part of this attack isn&rsquo;t the publishing step — it&rsquo;s knowing what internal package names to target in the first place. Birsan&rsquo;s research found that internal package names leaked through remarkably mundane channels: <code>package.json</code> files accidentally committed to public repositories, internal package names appearing inside minified JavaScript bundled and shipped to production (since build tools sometimes embed dependency manifests into bundled output), error messages exposing internal paths, and even job postings or forum discussions mentioning internal tooling by name. None of this required any kind of breach — it was all information organizations had, in effect, published themselves without realizing what it enabled.</p>
<h2 id="why-this-isnt-caught-by-normal-vulnerability-scanning">Why this isn&rsquo;t caught by normal vulnerability scanning</h2>
<p>A dependency confusion package has no known vulnerability associated with it — it&rsquo;s often a brand-new package with no CVE, no flagged malicious signature, and nothing that would trip a traditional CVE-based scanner. The &ldquo;vulnerability&rdquo; isn&rsquo;t in any package&rsquo;s code at all; it&rsquo;s in the resolution logic deciding which package to install. This is why teams that already run vulnerability scanning in CI can still be fully exposed to this attack — the two problems are structurally different, and one doesn&rsquo;t provide any coverage for the other.</p>
<h2 id="closing-the-gap">Closing the gap</h2>
<p><strong>Scope your private packages</strong> using your registry&rsquo;s namespace/scope feature (npm&rsquo;s <code>@your-org/package-name</code> scoping, for example) rather than unscoped names — a properly scoped private package doesn&rsquo;t collide with anything in the public registry&rsquo;s global namespace, closing the ambiguity the attack depends on.</p>
<p><strong>Explicitly configure your package manager to resolve specific scopes only from your private registry</strong>, rather than allowing it to check public sources as a fallback or alongside private ones for anything internal. Most package managers support this configuration (a <code>.npmrc</code> scope-to-registry mapping, for example), and it removes the resolution ambiguity at the source rather than relying on naming conventions alone.</p>
<p><strong>Register placeholder packages for your internal names on public registries</strong>, even if they contain nothing but a notice — a defensive squatting move that closes the door on an attacker registering that name later, though this doesn&rsquo;t scale well as an organization&rsquo;s internal package count grows and shouldn&rsquo;t be relied on as the primary defense.</p>
<p><strong>Audit what your internal package names actually are, and whether any have already leaked</strong> through the same channels Birsan documented — committed manifest files, bundled minified output, error messages. This audit is worth doing once deliberately, since these leaks tend to be historical accidents rather than ongoing practices, and finding them after the fact still closes future exposure.</p>
<h2 id="why-this-attack-pattern-hasnt-gone-away">Why this attack pattern hasn&rsquo;t gone away</h2>
<p>Once Birsan&rsquo;s technique became public, hundreds of copycat packages appeared on public registries using names patterned after other companies&rsquo; likely internal package naming — some from researchers replicating the technique for further bug bounties, some with less benign intent. The underlying resolution ambiguity in package managers that don&rsquo;t enforce strict scope-based routing hasn&rsquo;t been universally fixed at the tooling level, which means the exposure Birsan demonstrated in 2021 remains present in any organization that hasn&rsquo;t specifically configured against it, regardless of how much time has passed since the original disclosure.</p>
<h2 id="a-practical-checklist">A practical checklist</h2>
<ul>
<li>Use registry scoping for all internal/private packages, never unscoped names</li>
<li>Configure your package manager to route specific scopes exclusively to your private registry</li>
<li>Audit historical leaks of internal package names in committed files, bundled output, and public discussions</li>
<li>Treat this as a distinct control from vulnerability scanning — one does not provide coverage for the other</li>
<li>Consider defensive registration of internal package names on public registries as a secondary, not primary, mitigation</li>
</ul>
]]></content:encoded></item><item><title>User-Supplied Regex: How to Run It Without ReDoS</title><link>https://firewallsync.com/posts/user-supplied-regex-redos-safe-engines/</link><guid>https://firewallsync.com/posts/user-supplied-regex-redos-safe-engines/</guid><pubDate>Sat, 31 Oct 2026 04:50:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>Search filters, alert rules, routing configs and log tools all end up accepting regular expressions from users. In a backtracking engine that is a denial-of-service switch you hand to whoever can type a pattern. This guide shows which engines are built for untrusted patterns (RE2, Go, Rust), which ones say plainly that they are not (.NET), what timeouts and possessive quantifiers can and cannot do, and two tested patterns for Python and Node.js.</description><enclosure url="https://firewallsync.com/posts/user-supplied-regex-redos-safe-engines/cover_hu13911530470990273703.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/user-supplied-regex-redos-safe-engines/cover_hu13911530470990273703.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/user-supplied-regex-redos-safe-engines/cover_hu13911530470990273703.jpg" alt="Taut strands of red, orange, pink, purple, yellow and white thread crossing at angles to form a dense grid against a blurred grey background" width="1200" height="630"></p><p>If your product lets users type a regular expression (a log search box, an alerting rule, a URL routing rule, a &ldquo;block messages matching&rdquo; filter), you have handed them a way to burn your CPU. In a backtracking engine, a short pattern and a short input can keep a thread busy for seconds or years. The fix is not a better regex review process, because you are not the one writing the patterns.</p>
<p><strong>The short answer:</strong> run user-supplied patterns on an engine that guarantees linear-time matching (RE2, Go&rsquo;s <code>regexp</code>, Rust&rsquo;s <code>regex</code>), cap pattern length, and reject features those engines do not support instead of quietly falling back. If you are stuck with a backtracking engine, run the match somewhere you can kill it on a deadline.</p>
<h2 id="why-a-backtracking-engine-is-the-problem">Why a backtracking engine is the problem</h2>
<p>Python&rsquo;s <code>re</code>, JavaScript&rsquo;s <code>RegExp</code> (V8&rsquo;s Irregexp) and .NET&rsquo;s default engine are backtracking engines. When a match fails, they back up and try every other way the pattern could have split the input. For most patterns that is fast. For patterns with nested or overlapping repetition, the number of ways to split the input grows exponentially.</p>
<p>OWASP&rsquo;s wiki page on <a href="https://wiki.owasp.org/index.php/Regular_expression_Denial_of_Service_-_ReDoS">ReDoS</a> describes an &ldquo;evil&rdquo; regex as one with grouping with repetition where the repeated group contains repetition or overlapping alternation. Its examples include <code>(a+)+</code>, <code>([a-zA-Z]+)*</code>, <code>(a|aa)+</code> and <code>(a|a?)+</code>.</p>
<p>We timed the first one in Python 3.13.18 against a string of <code>a</code> characters followed by <code>!</code>, so the match has to fail:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> re<span style="color:#f92672">,</span> time
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>pat <span style="color:#f92672">=</span> re<span style="color:#f92672">.</span>compile(<span style="color:#e6db74">r</span><span style="color:#e6db74">&#39;^(a+)+$&#39;</span>)
</span></span><span style="display:flex;"><span>pos <span style="color:#f92672">=</span> re<span style="color:#f92672">.</span>compile(<span style="color:#e6db74">r</span><span style="color:#e6db74">&#39;^(?:a++)+$&#39;</span>)   <span style="color:#75715e"># possessive quantifier, Python 3.11+</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> (<span style="color:#ae81ff">18</span>, <span style="color:#ae81ff">20</span>, <span style="color:#ae81ff">22</span>, <span style="color:#ae81ff">24</span>):
</span></span><span style="display:flex;"><span>    s <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;a&#39;</span> <span style="color:#f92672">*</span> n <span style="color:#f92672">+</span> <span style="color:#e6db74">&#39;!&#39;</span>
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>perf_counter(); pat<span style="color:#f92672">.</span><span style="color:#66d9ef">match</span>(s); t1 <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>perf_counter() <span style="color:#f92672">-</span> t
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>perf_counter(); pos<span style="color:#f92672">.</span><span style="color:#66d9ef">match</span>(s); t2 <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>perf_counter() <span style="color:#f92672">-</span> t
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;n=</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">:</span><span style="color:#e6db74">2d</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> backtracking=</span><span style="color:#e6db74">{</span>t1<span style="color:#e6db74">:</span><span style="color:#e6db74">8.4f</span><span style="color:#e6db74">}</span><span style="color:#e6db74">s possessive=</span><span style="color:#e6db74">{</span>t2<span style="color:#e6db74">:</span><span style="color:#e6db74">.6f</span><span style="color:#e6db74">}</span><span style="color:#e6db74">s&#34;</span>)
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>n=18 backtracking=  0.0088s possessive=0.000005s
</span></span><span style="display:flex;"><span>n=20 backtracking=  0.0429s possessive=0.000005s
</span></span><span style="display:flex;"><span>n=22 backtracking=  0.1680s possessive=0.000003s
</span></span><span style="display:flex;"><span>n=24 backtracking=  0.5356s possessive=0.000005s
</span></span></code></pre></div><p>Each additional two characters roughly quadrupled the time. Extrapolate to a 40-character input and one request ties up a worker for hours. Node.js 22 behaves the same way: <code>/^(a+)+$/.test('a'.repeat(24) + '!')</code> took 495 ms in our sandbox.</p>
<p>The possessive rewrite fixes this pattern because <code>a++</code> refuses to give characters back. That is a valid fix when <strong>you</strong> write the pattern. It does nothing when a user writes it, because the user will not write the possessive version.</p>
<h2 id="two-different-threats">Two different threats</h2>
<p>It helps to separate the cases, because the defences differ.</p>
<table>
  <thead>
      <tr>
          <th>Situation</th>
          <th>Who controls the pattern</th>
          <th>Main fix</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Your validator runs on user input</td>
          <td>You</td>
          <td>Review the pattern, use possessive quantifiers or atomic groups, limit input length</td>
      </tr>
      <tr>
          <td>Users submit patterns (search, rules, filters)</td>
          <td>The attacker</td>
          <td>Use a linear-time engine, cap pattern size, isolate matching</td>
      </tr>
      <tr>
          <td>User text is spliced into your pattern</td>
          <td>Partly the attacker</td>
          <td>Escape it (<code>re.escape</code>, or an escape helper in JavaScript) or use a plain substring search</td>
      </tr>
  </tbody>
</table>
<p>This article is about the second row. The third row is the most common way the second row happens by accident: someone builds <code>new RegExp(userInput)</code> to implement a &ldquo;contains&rdquo; search that never needed a regex.</p>
<h2 id="engines-that-are-built-for-untrusted-patterns">Engines that are built for untrusted patterns</h2>
<p><strong>RE2.</strong> Google&rsquo;s <a href="https://github.com/google/re2">RE2 README</a> states the design goal directly: it was built &ldquo;to handle regular expressions from untrusted users without risk&rdquo;, and &ldquo;one of its primary guarantees is that the match time is linear in the length of the input string&rdquo;. It also says its parser, compiler and execution engines &ldquo;limit their memory usage by working within a configurable budget&rdquo;. The price is features: RE2 &ldquo;does not support constructs for which only backtracking solutions are known to exist&rdquo;. That means no backreferences and no look-around.</p>
<p><strong>Go&rsquo;s <code>regexp</code>.</strong> The package uses RE2 syntax, and its source documents that the implementation &ldquo;is guaranteed to run in time linear in the size of the input&rdquo;.</p>
<p><strong>Rust&rsquo;s <code>regex</code> crate.</strong> Its <a href="https://github.com/rust-lang/regex">README</a> says &ldquo;all regex searches in this crate have worst case <code>O(m * n)</code> time complexity, where <code>m</code> is proportional to the size of the regex and <code>n</code> is proportional to the size of the string being searched&rdquo;. Its documentation says the crate &ldquo;also, to a certain extent, supports untrusted patterns&rdquo;, mainly &ldquo;by limiting their size by default&rdquo;, which you can tune with <code>RegexBuilder::size_limit</code>.</p>
<p>Note the <code>m</code> in that complexity. Linear in the input is not the same as free: a huge pattern still costs more to compile and run. That is why a pattern length cap belongs in front of every engine, safe or not.</p>
<h2 id="engines-that-say-they-are-not">Engines that say they are not</h2>
<p><strong>.NET.</strong> Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/dotnet/standard/base-types/best-practices-regex">regex best-practices page</a> is unusually direct:</p>
<blockquote>
<p>The .NET regular expression engine does not offer protection against untrusted <em>patterns</em>, and applications should not create regular expression patterns from untrusted user-provided values.</p>
</blockquote>
<p>It adds that time-out values and <code>RegexOptions.NonBacktracking</code> (introduced in .NET 7, which &ldquo;guarantees linear-time processing in the length of the input&rdquo;) &ldquo;help protect against accidental performance problems in developer-authored patterns. They are not intended as a security boundary against malicious patterns.&rdquo; Use them for your own patterns, and do not treat them as permission to accept user-written ones.</p>
<p><strong>Ruby.</strong> Ruby 3.2 added two ReDoS mitigations, described in the <a href="https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/">3.2.0 release notes</a>. A memoization-based matcher completes &ldquo;most Regexp matching (about 90% in our experiments)&rdquo; in linear time, at the cost of memory &ldquo;proportional to the input length&rdquo;. Because that optimization &ldquo;cannot be applied to some kind of regular expressions, such as those including advanced features (e.g., back-references or look-around)&rdquo;, Ruby also added <code>Regexp.timeout</code>, a global setting, and a per-regex <code>timeout:</code> keyword on <code>Regexp.new</code>. Both help. Neither turns Ruby&rsquo;s engine into one designed for hostile patterns.</p>
<p><strong>Python and JavaScript.</strong> Python&rsquo;s <code>re</code> documentation describes no match timeout. Python 3.11 added possessive quantifiers and atomic groups <code>(?&gt;...)</code>, which help you write safer patterns but do nothing about patterns you did not write. JavaScript&rsquo;s <code>RegExp</code> has no timeout either. V8 has shipped an <a href="https://v8.dev/blog/non-backtracking-regexp">experimental non-backtracking engine</a> since V8 8.8, behind <code>--enable-experimental-regexp-engine</code>, which adds a non-standard <code>l</code> (&ldquo;linear&rdquo;) flag. V8&rsquo;s own post calls it experimental &ldquo;because Irregexp is orders of magnitude faster than the new engine on most common patterns&rdquo;. With the flag on, <code>/^(a+)+$/l</code> failed on a 50,000-character evil input in about 3 ms in our Node.js 22.23 test. Running production on an experimental V8 flag is a decision to make deliberately, not a default.</p>
<p>Accepting patterns also exposes you to bugs in the regex parser and compiler themselves, not just slow matching. Our write-up of <a href="/posts/gitlab-cve-2026-97229-ci-regex-rce-patch-and-hunt/">GitLab CVE-2026-97229</a> covers a remote code execution flaw in how GitLab compiled regular expressions from CI/CD configuration. Using a small, heavily fuzzed engine reduces that risk too.</p>
<h2 id="pattern-1-python-with-re2">Pattern 1: Python with RE2</h2>
<p>This uses the <code>google-re2</code> package (we tested 1.1.20251105 with Python 3.13). It caps pattern length, compiles with RE2, and turns unsupported syntax into a clear error your API can return as a 400.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> re2
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>MAX_PATTERN <span style="color:#f92672">=</span> <span style="color:#ae81ff">256</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">compile_user_pattern</span>(pattern: str):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Compile a user-supplied pattern with RE2 or raise ValueError.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> len(pattern) <span style="color:#f92672">&gt;</span> MAX_PATTERN:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">&#34;pattern too long&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> re2<span style="color:#f92672">.</span>compile(pattern)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">except</span> re2<span style="color:#f92672">.</span>error <span style="color:#66d9ef">as</span> e:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;unsupported or invalid pattern: </span><span style="color:#e6db74">{</span>e<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>) <span style="color:#f92672">from</span> None
</span></span></code></pre></div><p>What happened when we ran it:</p>
<table>
  <thead>
      <tr>
          <th>Input</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>^(a+)+$</code> searched against 1,000,000 <code>a</code> characters plus <code>!</code></td>
          <td>No match, returned in about 2 ms</td>
      </tr>
      <tr>
          <td><code>(\w+)\s\1</code> (backreference)</td>
          <td>Rejected: <code>invalid escape sequence: \1</code></td>
      </tr>
      <tr>
          <td><code>^(?=.*\d).+$</code> (look-ahead)</td>
          <td>Rejected: <code>invalid perl operator: (?=</code></td>
      </tr>
      <tr>
          <td>300-character pattern</td>
          <td>Rejected by the length cap</td>
      </tr>
  </tbody>
</table>
<p>The same evil pattern that took half a second on 24 characters in <code>re</code> finished a million characters in milliseconds. Two practical notes from the test: RE2 logs parse errors to stderr through its own logging, so expect noise in your logs when users submit bad patterns. And do not catch the error and retry with <code>re</code>. That fallback reopens the exact hole you closed.</p>
<h2 id="pattern-2-nodejs-with-a-killable-worker">Pattern 2: Node.js with a killable worker</h2>
<p>If you cannot add a native RE2 binding, isolate the backtracking engine. A worker thread can be terminated mid-match, which a <code>setTimeout</code> on the main thread cannot do: a regex blocks the event loop until it returns. This is tested on Node.js 22.23.4:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#75715e">// safe_match.mjs: run an untrusted pattern in a worker and kill it after a deadline.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">Worker</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;node:worker_threads&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">WORKER_SRC</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  const { parentPort, workerData } = require(&#39;node:worker_threads&#39;);
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  const { pattern, flags, input } = workerData;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  try {
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    parentPort.postMessage({ ok: true, match: new RegExp(pattern, flags).test(input) });
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  } catch (e) {
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    parentPort.postMessage({ ok: false, error: e.message });
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  }
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">`</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">safeTest</span>(<span style="color:#a6e22e">pattern</span>, <span style="color:#a6e22e">input</span>, { <span style="color:#a6e22e">flags</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;</span>, <span style="color:#a6e22e">timeoutMs</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">100</span>, <span style="color:#a6e22e">maxPattern</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">256</span> } <span style="color:#f92672">=</span> {}) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">pattern</span>.<span style="color:#a6e22e">length</span> <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">maxPattern</span>) <span style="color:#66d9ef">return</span> Promise.<span style="color:#a6e22e">resolve</span>({ <span style="color:#a6e22e">ok</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">false</span>, <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;pattern too long&#39;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#e6db74">/^[gimsuy]*$/</span>.<span style="color:#a6e22e">test</span>(<span style="color:#a6e22e">flags</span>)) <span style="color:#66d9ef">return</span> Promise.<span style="color:#a6e22e">resolve</span>({ <span style="color:#a6e22e">ok</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">false</span>, <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;bad flags&#39;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> Promise((<span style="color:#a6e22e">resolve</span>) =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">w</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Worker</span>(<span style="color:#a6e22e">WORKER_SRC</span>, {
</span></span><span style="display:flex;"><span>      eval<span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">workerData</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">pattern</span>, <span style="color:#a6e22e">flags</span>, <span style="color:#a6e22e">input</span> },
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">resourceLimits</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">maxOldGenerationSizeMb</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">32</span> },
</span></span><span style="display:flex;"><span>    });
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">timer</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">setTimeout</span>(() =&gt; { <span style="color:#a6e22e">w</span>.<span style="color:#a6e22e">terminate</span>(); <span style="color:#a6e22e">resolve</span>({ <span style="color:#a6e22e">ok</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">false</span>, <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;timeout&#39;</span> }); }, <span style="color:#a6e22e">timeoutMs</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">w</span>.<span style="color:#a6e22e">once</span>(<span style="color:#e6db74">&#39;message&#39;</span>, (<span style="color:#a6e22e">m</span>) =&gt; { <span style="color:#a6e22e">clearTimeout</span>(<span style="color:#a6e22e">timer</span>); <span style="color:#a6e22e">w</span>.<span style="color:#a6e22e">terminate</span>(); <span style="color:#a6e22e">resolve</span>(<span style="color:#a6e22e">m</span>); });
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">w</span>.<span style="color:#a6e22e">once</span>(<span style="color:#e6db74">&#39;error&#39;</span>, (<span style="color:#a6e22e">e</span>) =&gt; { <span style="color:#a6e22e">clearTimeout</span>(<span style="color:#a6e22e">timer</span>); <span style="color:#a6e22e">resolve</span>({ <span style="color:#a6e22e">ok</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">false</span>, <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">e</span>.<span style="color:#a6e22e">message</span> }); });
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Results:</p>
<ul>
<li><code>safeTest('^(a+)+$', 'a'.repeat(40) + '!')</code> returned <code>{ ok: false, error: 'timeout' }</code> after 103 ms. On the main thread that match would not finish.</li>
<li><code>safeTest('^[a-z]+@example\\.com$', 'alice@example.com')</code> returned <code>{ ok: true, match: true }</code>.</li>
<li><code>safeTest('(unclosed', 'x')</code> returned the syntax error instead of throwing.</li>
</ul>
<p>The cost: spawning a worker per call averaged 25.4 ms in our sandbox, over 20 calls. That is fine for saving a rule. It is too slow for matching every log line. For high-volume matching, keep a small pool of long-lived workers and replace any worker you have to terminate, or move to an RE2 binding. This is a containment pattern, not a fix: an attacker can still make every match hit the timeout, so rate-limit pattern submissions and count timeouts per user.</p>
<h2 id="a-checklist-for-features-that-accept-patterns">A checklist for features that accept patterns</h2>
<ol>
<li><strong>Ask whether you need regex at all.</strong> Substring, prefix or glob matching covers most &ldquo;search&rdquo; features and has no ReDoS surface.</li>
<li><strong>Pick a linear-time engine</strong> (RE2 or a binding, Go <code>regexp</code>, Rust <code>regex</code>) for any pattern a user can write, including admins and API clients.</li>
<li><strong>Cap pattern length</strong> and, if you can, the number of patterns per user or rule set.</li>
<li><strong>Reject unsupported features with a clear message.</strong> Never fall back to a backtracking engine.</li>
<li><strong>Cap input length</strong> for each match. Linear time on a 2 GB input is still a lot of time. Our post on <a href="/posts/nodejs-decompression-bomb-zlib-limits/">decompression bombs in Node.js</a> covers the related problem of inputs that grow after you accept them.</li>
<li><strong>If you must backtrack, isolate it.</strong> Use a worker or process you can kill, a hard deadline, and a memory limit. Ruby&rsquo;s <code>Regexp.timeout</code> and .NET&rsquo;s <code>matchTimeout</code> are useful extra layers.</li>
<li><strong>Watch for it.</strong> Log match timeouts and compile failures by user, and alert on spikes.</li>
</ol>
<h2 id="limits-of-this-article">Limits of this article</h2>
<ul>
<li>Timings are from a shared cloud sandbox and will differ on your hardware. The growth rate, not the absolute numbers, is the point.</li>
<li>We did not test .NET or Ruby directly, and we do not cover Java, PCRE or other engines here. Statements about them come from their documentation, quoted above.</li>
<li>We tested one Python RE2 binding (<code>google-re2</code>). Node.js RE2 bindings exist, but we did not test one for this article.</li>
<li>A linear-time engine removes catastrophic backtracking. It does not remove the need for size limits, rate limits or patching the engine itself.</li>
</ul>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Backtracking times for <code>^(a+)+$</code> in Python 3.13.18 and Node.js 22.23.4; RE2 and worker results</td>
          <td>Our tests, code above</td>
          <td>Verified (our sandbox)</td>
      </tr>
      <tr>
          <td>&ldquo;Evil&rdquo; regex conditions and examples; attacker can inject an evil regex</td>
          <td><a href="https://wiki.owasp.org/index.php/Regular_expression_Denial_of_Service_-_ReDoS">OWASP wiki: ReDoS</a></td>
          <td>Verified (archived wiki page)</td>
      </tr>
      <tr>
          <td>RE2: built for untrusted users, linear in input, memory budget, no backtracking-only constructs</td>
          <td><a href="https://github.com/google/re2">google/re2 README</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Go regexp: linear in the size of the input</td>
          <td><a href="https://github.com/golang/go/blob/master/src/regexp/regexp.go">Go source, regexp.go</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Rust regex: worst case O(m * n); untrusted patterns handled mainly by size limit</td>
          <td><a href="https://github.com/rust-lang/regex">rust-lang/regex README and docs</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>.NET: no protection against untrusted patterns; timeouts and NonBacktracking not a security boundary</td>
          <td><a href="https://learn.microsoft.com/en-us/dotnet/standard/base-types/best-practices-regex">Microsoft Learn: regex best practices</a></td>
          <td>Verified (docs source on GitHub)</td>
      </tr>
      <tr>
          <td>Ruby 3.2 memoization (about 90% linear) and Regexp.timeout</td>
          <td><a href="https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/">Ruby 3.2.0 release notes</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Python 3.11 possessive quantifiers and atomic groups; no timeout documented</td>
          <td><a href="https://docs.python.org/3/library/re.html">Python re documentation</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>V8 experimental non-backtracking engine, <code>l</code> flag, slower than Irregexp</td>
          <td><a href="https://v8.dev/blog/non-backtracking-regexp">V8 blog</a></td>
          <td>Verified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Software Supply Chain Attacks: Where Modern Build Pipelines Break</title><link>https://firewallsync.com/posts/software-supply-chain-attacks-where-modern-build-pipelines-break/</link><guid>https://firewallsync.com/posts/software-supply-chain-attacks-where-modern-build-pipelines-break/</guid><pubDate>Fri, 30 Oct 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>A build pipeline trusts a long chain of things by default: the package registry, the CI runner, every plugin, every base image. Each link is a place a supply chain attack can enter without ever touching your own code.</description><enclosure url="https://firewallsync.com/posts/software-supply-chain-attacks-where-modern-build-pipelines-break/cover_hu6751882606804104270.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/software-supply-chain-attacks-where-modern-build-pipelines-break/cover_hu6751882606804104270.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/software-supply-chain-attacks-where-modern-build-pipelines-break/cover_hu6751882606804104270.jpg" alt="Rows of stacked intermodal shipping containers" width="1200" height="630"></p><p>A modern build pipeline trusts a long chain of things by default, mostly without anyone deciding to trust them explicitly: the package registry resolving your dependencies, the CI runner executing your build steps, every third-party action or plugin pulled into that process, the base image your container starts from. A supply chain attack doesn&rsquo;t need to compromise your own code at all — it just needs to compromise one link in that chain, because your pipeline will trust whatever that link delivers.</p>
<h2 id="dependency-resolution-as-an-attack-surface">Dependency resolution as an attack surface</h2>
<p>Package managers resolve dependencies by name and version, largely trusting that a package&rsquo;s identity maps to what it claims to be. This assumption is exactly what dependency confusion attacks exploit — publishing a public package under the same name as an internal one, at a higher version number, so the build system pulls the attacker&rsquo;s code instead of the legitimate internal package. This isn&rsquo;t a bug in any specific package manager; it&rsquo;s a structural consequence of how most of them are designed to resolve names across multiple registries.</p>
<p>Typosquatting works on the same trust assumption from a different angle — publishing a malicious package with a name one character off from a popular legitimate one, betting that enough developers will mistype it or copy a typo from a tutorial to make the attack worthwhile at scale.</p>
<h2 id="build-time-script-execution">Build-time script execution</h2>
<p>Most package managers support scripts that run automatically during install — npm&rsquo;s <code>postinstall</code>, for example. This means installing a dependency can execute arbitrary code on the machine running the install, before your application code ever runs, and before any application-level security control has a chance to matter. A compromised or malicious package doesn&rsquo;t need to be imported and called by your code to do damage; simply being present in <code>node_modules</code> (or the equivalent for your ecosystem) at install time can be enough.</p>
<h2 id="cicd-runners-and-third-party-actions">CI/CD runners and third-party actions</h2>
<p>CI pipelines commonly pull in third-party actions, plugins, or shared workflow steps maintained outside your organization. Each one runs with whatever permissions and secrets access your pipeline configuration grants it — meaning a compromised or malicious third-party action has the same practical reach as a compromised dependency, but often with direct access to CI secrets (cloud credentials, signing keys, deployment tokens) that application dependencies don&rsquo;t typically touch. A popular action being compromised at the source — through a maintainer account takeover, for example — propagates instantly to every pipeline that references it, especially ones pinned to a mutable tag rather than a specific commit.</p>
<h2 id="base-images-and-the-layers-you-didnt-build">Base images and the layers you didn&rsquo;t build</h2>
<p>A container build starting <code>FROM</code> a public base image inherits everything already baked into that image — and if that base image is compromised upstream, at the registry or at the maintainer level, every image built from it inherits the compromise without any change to your own Dockerfile. This is the same trust-chain problem as dependency resolution, just one layer further removed from your own code.</p>
<h2 id="why-this-class-of-attack-is-different-from-a-typical-vulnerability">Why this class of attack is different from a typical vulnerability</h2>
<p>A traditional vulnerability is a flaw in code that needs to be found and exploited. A supply chain attack often requires no vulnerability at all — it just requires the attacker to get their code accepted as a trusted input somewhere in the pipeline, at which point it executes with whatever trust and access that input normally receives. This means traditional vulnerability scanning, which looks for known-bad code patterns or known CVEs, often doesn&rsquo;t catch a well-executed supply chain attack at all, since the malicious package or action may have no publicly known vulnerability associated with it — it&rsquo;s simply doing something it wasn&rsquo;t supposed to do, using access it was never denied.</p>
<h2 id="where-this-leaves-defenses">Where this leaves defenses</h2>
<p><strong>Pin dependencies and actions to exact versions or commit hashes</strong>, not mutable tags — a tag like <code>v2</code> or <code>latest</code> can silently point to different, potentially compromised code over time, while a pinned commit hash cannot change without the reference itself being updated.</p>
<p><strong>Scope your private package names to a registry that doesn&rsquo;t allow public namespace collision</strong>, or explicitly configure your package manager to only resolve certain scopes from your private registry, closing the specific gap dependency confusion exploits.</p>
<p><strong>Restrict what CI pipeline secrets a given workflow or job actually needs</strong>, rather than granting broad access by default — a compromised third-party action in a job with narrowly scoped secrets can do meaningfully less damage than one running with access to everything.</p>
<p><strong>Generate and monitor SBOMs across your build pipeline</strong>, not just your application dependencies, so a newly disclosed compromise in a base image or build tool can be checked against your actual exposure quickly.</p>
<p>Supply chain security isn&rsquo;t a single control — it&rsquo;s an acknowledgment that your pipeline&rsquo;s trust boundary extends far beyond your own code, to every registry, runner, action, and base image it depends on, and each one needs to be treated as a real part of your attack surface rather than an assumed-safe utility.</p>
]]></content:encoded></item><item><title>Secure File Uploads in Node.js: A Tested Express Setup</title><link>https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/</link><guid>https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/</guid><pubDate>Fri, 30 Oct 2026 04:30:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>Most upload bugs come from trusting something the client sent: the filename, the Content-Type, or the idea that a file sitting in a public folder won't be run. This guide builds an Express 5 and Multer 2 upload endpoint that limits size and count, checks magic bytes against an allowlist, renames and stores files outside the webroot, and serves them back safely. We ran ten attack requests against it, and one of them got through the type check, which is why the storage rules matter more.</description><enclosure url="https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/cover_hu17749453253162896976.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/cover_hu17749453253162896976.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/cover_hu17749453253162896976.jpg" alt="Close-up of a weathered green roadside mailbox at the end of a row of mailboxes on wooden posts, with fallen leaves and a blurred road behind" width="1200" height="630"></p><p>A secure file upload accepts only the types you need, decides the filename and extension itself, stores the bytes somewhere nothing will execute or serve them directly, and checks authorization again on download. The <a href="https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html">OWASP File Upload Cheat Sheet</a> says it plainly: &ldquo;There is no silver bullet in validating user content.&rdquo; No single check is enough, so you stack them.</p>
<p>This guide builds that stack in Node.js and tests it. Versions: Node.js 22.23.4, Express 5.3.0, Multer 2.5.0, file-type 22.3.0 and sharp 0.35.5, installed from npm on 2026-10-30.</p>
<h2 id="why-uploads-keep-turning-into-breaches">Why uploads keep turning into breaches</h2>
<p>Unrestricted upload is <a href="https://cwe.mitre.org/data/definitions/434.html">CWE-434</a>. In the CISA KEV catalog (version 2026.10.29), 24 entries carry CWE-434, eight of them added in 2026. The count is a floor: not every KEV entry has a CWE assigned. The most recent is the Magento &ldquo;PolyShell&rdquo; bug we covered in <a href="/posts/magento-cve-2026-48356-polyshell-patch-and-hunt/">Magento CVE-2026-48356: Patch and Hunt</a>, where the fix included making sure upload directories can&rsquo;t execute code.</p>
<p>The OWASP cheat sheet groups the risks into two kinds:</p>
<ul>
<li><strong>Malicious files</strong>: exploits for the parser that processes the file, phishing documents, zip and XML bombs, overwriting existing files, and active content such as HTML or SVG that attacks other users.</li>
<li><strong>Public retrieval</strong>: if uploads are publicly reachable, attackers can enumerate other users&rsquo; files, use them for DoS, or host illegal content on your domain.</li>
</ul>
<h2 id="the-endpoint-step-by-step">The endpoint, step by step</h2>
<p>Here is the full server. Each control is explained below.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#75715e">// Node.js 22, Express 5.3.0, Multer 2.5.0, file-type 22.3.0 (package.json: &#34;type&#34;: &#34;module&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">express</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;express&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">multer</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;multer&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">fileTypeFromBuffer</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;file-type&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">randomUUID</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;node:crypto&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">writeFile</span>, <span style="color:#a6e22e">mkdir</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;node:fs/promises&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">path</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;node:path&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">UPLOAD_DIR</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">process</span>.<span style="color:#a6e22e">env</span>.<span style="color:#a6e22e">UPLOAD_DIR</span> <span style="color:#f92672">??</span> <span style="color:#e6db74">&#39;/srv/app-uploads&#39;</span>; <span style="color:#75715e">// not under any static/public dir
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">ALLOWED</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Map</span>([            <span style="color:#75715e">// detected type -&gt; extension we will store
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  [<span style="color:#e6db74">&#39;image/png&#39;</span>, <span style="color:#e6db74">&#39;png&#39;</span>],
</span></span><span style="display:flex;"><span>  [<span style="color:#e6db74">&#39;image/jpeg&#39;</span>, <span style="color:#e6db74">&#39;jpg&#39;</span>],
</span></span><span style="display:flex;"><span>  [<span style="color:#e6db74">&#39;application/pdf&#39;</span>, <span style="color:#e6db74">&#39;pdf&#39;</span>],
</span></span><span style="display:flex;"><span>]);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">files</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Map</span>();             <span style="color:#75715e">// id -&gt; { owner, mime, ext }; use your database in production
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">upload</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">multer</span>({
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">storage</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">multer</span>.<span style="color:#a6e22e">memoryStorage</span>(),   <span style="color:#75715e">// bytes stay in memory until they pass checks
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#a6e22e">limits</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">fileSize</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">5</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span>, <span style="color:#a6e22e">files</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1</span>, <span style="color:#a6e22e">fields</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">5</span>, <span style="color:#a6e22e">parts</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">6</span> },
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Stand-in for your real auth middleware
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">requireUser</span>(<span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>, <span style="color:#a6e22e">next</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">user</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">get</span>(<span style="color:#e6db74">&#39;x-demo-user&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">user</span>) <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">401</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;login required&#39;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">user</span> <span style="color:#f92672">=</span> { <span style="color:#a6e22e">id</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">user</span> };
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">next</span>();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">app</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">express</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">post</span>(<span style="color:#e6db74">&#39;/uploads&#39;</span>, <span style="color:#a6e22e">requireUser</span>, <span style="color:#a6e22e">upload</span>.<span style="color:#a6e22e">single</span>(<span style="color:#e6db74">&#39;file&#39;</span>), <span style="color:#66d9ef">async</span> (<span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">file</span>) <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">400</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;no file&#39;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">detected</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">fileTypeFromBuffer</span>(<span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">file</span>.<span style="color:#a6e22e">buffer</span>);   <span style="color:#75715e">// magic bytes, not the client&#39;s Content-Type
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">detected</span> <span style="color:#f92672">||</span> <span style="color:#f92672">!</span><span style="color:#a6e22e">ALLOWED</span>.<span style="color:#a6e22e">has</span>(<span style="color:#a6e22e">detected</span>.<span style="color:#a6e22e">mime</span>)) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">415</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;file type not allowed&#39;</span> });
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">id</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">randomUUID</span>();
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">ext</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">ALLOWED</span>.<span style="color:#a6e22e">get</span>(<span style="color:#a6e22e">detected</span>.<span style="color:#a6e22e">mime</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">mkdir</span>(<span style="color:#a6e22e">UPLOAD_DIR</span>, { <span style="color:#a6e22e">recursive</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0o700</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Server-chosen name; &#39;wx&#39; fails instead of overwriting; 0o600 = no execute bit
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">writeFile</span>(<span style="color:#a6e22e">path</span>.<span style="color:#a6e22e">join</span>(<span style="color:#a6e22e">UPLOAD_DIR</span>, <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">id</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">ext</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>), <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">file</span>.<span style="color:#a6e22e">buffer</span>, { <span style="color:#a6e22e">flag</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;wx&#39;</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0o600</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">files</span>.<span style="color:#a6e22e">set</span>(<span style="color:#a6e22e">id</span>, { <span style="color:#a6e22e">owner</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">user</span>.<span style="color:#a6e22e">id</span>, <span style="color:#a6e22e">mime</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">detected</span>.<span style="color:#a6e22e">mime</span>, <span style="color:#a6e22e">ext</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">201</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">id</span> });
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">UUID</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">get</span>(<span style="color:#e6db74">&#39;/files/:id&#39;</span>, <span style="color:#a6e22e">requireUser</span>, (<span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">meta</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">UUID</span>.<span style="color:#a6e22e">test</span>(<span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">params</span>.<span style="color:#a6e22e">id</span>) <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">files</span>.<span style="color:#a6e22e">get</span>(<span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">params</span>.<span style="color:#a6e22e">id</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">meta</span> <span style="color:#f92672">||</span> <span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">owner</span> <span style="color:#f92672">!==</span> <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">user</span>.<span style="color:#a6e22e">id</span>) <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">404</span>).<span style="color:#a6e22e">end</span>();  <span style="color:#75715e">// authz on every download
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">set</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;Content-Type&#39;</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">mime</span>,                       <span style="color:#75715e">// the type we detected, never the client&#39;s
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#e6db74">&#39;Content-Disposition&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">`attachment; filename=&#34;</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">params</span>.<span style="color:#a6e22e">id</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">ext</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;`</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;X-Content-Type-Options&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;nosniff&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;Content-Security-Policy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;default-src &#39;none&#39;; sandbox&#34;</span>,
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">sendFile</span>(<span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">params</span>.<span style="color:#a6e22e">id</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">ext</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>, { <span style="color:#a6e22e">root</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">UPLOAD_DIR</span> });
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">use</span>((<span style="color:#a6e22e">err</span>, <span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>, <span style="color:#a6e22e">next</span>) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">err</span> <span style="color:#66d9ef">instanceof</span> <span style="color:#a6e22e">multer</span>.<span style="color:#a6e22e">MulterError</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">status</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">err</span>.<span style="color:#a6e22e">code</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;LIMIT_FILE_SIZE&#39;</span> <span style="color:#f92672">?</span> <span style="color:#ae81ff">413</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">400</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#a6e22e">status</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">err</span>.<span style="color:#a6e22e">code</span> });
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">next</span>(<span style="color:#a6e22e">err</span>);
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">listen</span>(<span style="color:#ae81ff">3000</span>, () =&gt; <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#e6db74">&#39;listening on :3000&#39;</span>));
</span></span></code></pre></div><p>The <code>x-demo-user</code> header and the in-memory <code>files</code> map exist only so the example runs on its own. Replace them with your session middleware and a database table.</p>
<h3 id="1-authenticate-before-parsing-and-only-on-the-routes-that-need-it">1. Authenticate before parsing, and only on the routes that need it</h3>
<p><code>requireUser</code> runs before <code>upload.single()</code>, so an anonymous request is rejected before Multer reads a byte of the body. Multer&rsquo;s README warns: &ldquo;Never add multer as a global middleware since a malicious user could upload files to a route that you didn&rsquo;t anticipate.&rdquo; Mount it per route, as above.</p>
<h3 id="2-set-every-limit">2. Set every limit</h3>
<p>Multer passes <code>limits</code> straight to busboy, and the defaults in the Multer 2.5.0 README are generous: <code>fileSize</code>, <code>files</code>, <code>parts</code> and <code>fields</code> all default to <code>Infinity</code>. The README also notes that &ldquo;Limits apply to the whole request; there is no per-file limit.&rdquo; Set them to what the feature needs. Map <code>LIMIT_FILE_SIZE</code> to 413 and other Multer errors to 400 in your error handler, as the example does.</p>
<p><code>memoryStorage()</code> holds the whole file in RAM, which is why <code>fileSize</code> matters. For large files, use disk storage into a private temp directory and run the same checks before moving the file into place. If you also accept compressed data, the limit has to apply after decompression; see <a href="/posts/nodejs-decompression-bomb-zlib-limits/">Node.js decompression bombs</a>.</p>
<h3 id="3-detect-the-type-from-content-against-an-allowlist">3. Detect the type from content, against an allowlist</h3>
<p>The <code>Content-Type</code> of each part is whatever the client says. OWASP: &ldquo;it is trivial to spoof.&rdquo; <code>fileTypeFromBuffer()</code> reads the file&rsquo;s magic number instead, and we compare the result to a short allowlist of types the feature actually needs.</p>
<p>Know the limits of this check. The file-type readme says detection &ldquo;is a best-effort hint. It does not guarantee the file is actually of that type or that the file is valid/not malformed.&rdquo; It also covers only binary formats, &ldquo;not text-based formats like <code>.txt</code>, <code>.csv</code>, <code>.svg</code>&rdquo;. If you need text formats, validate them with a real parser.</p>
<h3 id="4-choose-the-name-and-extension-yourself">4. Choose the name and extension yourself</h3>
<p>The stored name is a UUID plus the extension mapped from the detected type. The user&rsquo;s <code>originalname</code> is never used on disk, which removes the whole class of tricks OWASP lists for extensions (<code>.jpg.php</code>, <code>.php%00.jpg</code>, <code>.pHp</code>, <code>.phtml</code>, <code>shell.asp:.jpg</code>) and path traversal in filenames. If you need to show the original name, store it as data in your database and escape it on output.</p>
<p>The <code>wx</code> flag makes <code>writeFile</code> fail if the path exists rather than overwrite it. Mode <code>0o600</code> means no execute bit, and your process umask can only make it stricter.</p>
<h3 id="5-store-outside-anything-that-serves-or-executes-files">5. Store outside anything that serves or executes files</h3>
<p>OWASP ranks storage options in this order: a different host, then outside the webroot, then inside the webroot with write-only permissions. <code>UPLOAD_DIR</code> must not be inside <code>express.static()</code>, your nginx <code>root</code>, or a PHP document root. Private object storage (an S3 bucket with <a href="/posts/s3-block-public-access-what-the-four-settings-do-and-how-to-enforce-them/">Block Public Access</a> on, served by short-lived signed URLs) satisfies &ldquo;different host&rdquo;.</p>
<p>If files have to live under a web server&rsquo;s document root, also turn off per-directory overrides there. OWASP&rsquo;s example is Apache <code>AllowOverride None</code>, so an uploaded <code>.htaccess</code> can&rsquo;t remap <code>.png</code> to the PHP handler. For PHP stacks, our <a href="/posts/php-ini-security-hardening-production-settings/">php.ini hardening guide</a> covers the upload-related settings.</p>
<h3 id="6-serve-through-a-handler-with-defensive-headers">6. Serve through a handler with defensive headers</h3>
<p>The download route checks ownership on every request. Without that, a guessable or leaked ID becomes an <a href="/posts/idor-vulnerabilities-how-to-find-and-fix-insecure-direct-object-references/">IDOR</a>. It then sets four headers:</p>
<table>
  <thead>
      <tr>
          <th>Header</th>
          <th>What it does here</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>Content-Type</code> from detection</td>
          <td>The browser gets the type you verified, not the one the uploader claimed</td>
      </tr>
      <tr>
          <td><code>Content-Disposition: attachment</code></td>
          <td>The browser downloads the file instead of rendering it on your origin</td>
      </tr>
      <tr>
          <td><code>X-Content-Type-Options: nosniff</code></td>
          <td>Stops the browser second-guessing the type; see <a href="/posts/http-security-headers-hsts-nosniff-referrer-policy-and-permissions-policy-explained/">HTTP security headers</a></td>
      </tr>
      <tr>
          <td><code>Content-Security-Policy: default-src 'none'; sandbox</code></td>
          <td>If the file is rendered anyway, it gets no scripts, no subresources and an opaque origin</td>
      </tr>
  </tbody>
</table>
<p>Serving user files from a separate domain (for example a dedicated <code>usercontent</code> domain) adds one more layer: active content never runs on the same origin as your session cookies.</p>
<h2 id="what-we-tested-and-what-got-through">What we tested, and what got through</h2>
<p>We started the server with <code>UPLOAD_DIR</code> set to a temp directory and sent ten requests with curl:</p>
<table>
  <thead>
      <tr>
          <th>#</th>
          <th>Request</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>Valid 1x1 PNG</td>
          <td>201, stored as <code>&lt;uuid&gt;.png</code></td>
      </tr>
      <tr>
          <td>2</td>
          <td>PHP script named <code>shell.jpg</code>, sent as <code>image/jpeg</code></td>
          <td>415, type not allowed</td>
      </tr>
      <tr>
          <td>3</td>
          <td>SVG containing <code>&lt;script&gt;</code>, sent as <code>image/svg+xml</code></td>
          <td>415, type not allowed</td>
      </tr>
      <tr>
          <td>4</td>
          <td>Valid PNG with a PHP payload appended, filename <code>../../shell.php</code></td>
          <td><strong>201</strong>, stored as <code>&lt;uuid&gt;.png</code></td>
      </tr>
      <tr>
          <td>5</td>
          <td>6 MB file</td>
          <td>413, <code>LIMIT_FILE_SIZE</code></td>
      </tr>
      <tr>
          <td>6</td>
          <td>Two files in one request</td>
          <td>400, <code>LIMIT_FILE_COUNT</code></td>
      </tr>
      <tr>
          <td>7</td>
          <td>Upload without a user</td>
          <td>401</td>
      </tr>
      <tr>
          <td>8</td>
          <td>Owner downloads file from test 1</td>
          <td>200 with all four headers above</td>
      </tr>
      <tr>
          <td>9</td>
          <td>Another user requests the same ID</td>
          <td>404</td>
      </tr>
      <tr>
          <td>10</td>
          <td>ID of <code>../../etc/passwd</code> (URL-encoded)</td>
          <td>404</td>
      </tr>
  </tbody>
</table>
<p>Both stored files were <code>-rw-------</code>. Test 4 is the one to remember. A PNG with code appended is still a valid PNG, so magic-byte detection passed it. It did no harm because of what came after: the traversal filename was ignored, the stored name ended in <code>.png</code>, the directory isn&rsquo;t served or interpreted, and downloads go out as attachments with a sandbox CSP. Remove any one of those and the polyglot becomes more dangerous.</p>
<h2 id="re-encode-images-when-you-can">Re-encode images when you can</h2>
<p>For images, OWASP recommends decoding and re-encoding to strip anything that isn&rsquo;t pixels. With sharp, metadata is dropped by default (&ldquo;The default behaviour, when withMetadata is not used, is to strip all metadata&rdquo;, per its type definitions):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">sharp</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;sharp&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#75715e">// limitInputPixels guards against decompression-style image bombs; sharp&#39;s default is 268402689 pixels
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">clean</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">sharp</span>(<span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">file</span>.<span style="color:#a6e22e">buffer</span>, { <span style="color:#a6e22e">limitInputPixels</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">40_000_000</span> }).<span style="color:#a6e22e">png</span>().<span style="color:#a6e22e">toBuffer</span>();
</span></span></code></pre></div><p>Run on the polyglot from test 4, the 97-byte input contained <code>&lt;?php</code>; the 90-byte output did not. OWASP adds a caveat worth keeping: the image library &ldquo;itself handles untrusted input&rdquo;, so keep it patched and, for high-volume or high-risk uploads, run processing in a separate worker or sandbox. The file-type readme makes the same point for its own parser, recommending a size limit and &ldquo;a worker thread with a timeout&rdquo; for untrusted files.</p>
<h2 id="checklist">Checklist</h2>
<ul>
<li>Authenticate before parsing; mount Multer only on upload routes.</li>
<li>Set <code>fileSize</code>, <code>files</code>, <code>fields</code> and <code>parts</code>.</li>
<li>Detect type from content; compare to a short allowlist; reject text formats you can&rsquo;t parse.</li>
<li>Generate the stored name and extension; never write <code>originalname</code> to disk.</li>
<li>Store outside the webroot or in private object storage, without execute permissions.</li>
<li>Serve via a handler that checks ownership and sets <code>attachment</code>, <code>nosniff</code> and a sandbox CSP.</li>
<li>Re-encode images; scan or sandbox documents if they will be opened by staff.</li>
<li>Protect the upload form from <a href="/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/">CSRF</a> if it uses cookie auth.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<ul>
<li>We tested one stack (Express 5.3.0, Multer 2.5.0). Other frameworks have different defaults; check their docs for limits and filename handling.</li>
<li>We did not test antivirus scanning or Content Disarm and Reconstruction for Office or PDF files, which OWASP also recommends where applicable.</li>
<li>The in-memory metadata map and header-based &ldquo;auth&rdquo; are placeholders and are not production-ready.</li>
</ul>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Extension bypasses, Content-Type spoofing, storage order, AllowOverride None, image re-encoding caveat</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html">OWASP File Upload Cheat Sheet</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Multer limit defaults (Infinity), &ldquo;no per-file limit&rdquo;, never mount globally</td>
          <td>Multer 2.5.0 README, as installed from npm (<a href="https://github.com/expressjs/multer">repository</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>file-type detection is &ldquo;a best-effort hint&rdquo;; binary formats only; worker-thread advice</td>
          <td>file-type 22.3.0 readme (<a href="https://github.com/sindresorhus/file-type">repository</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>sharp strips metadata by default; limitInputPixels default 268402689</td>
          <td>sharp 0.35.5 <code>lib/index.d.ts</code> (<a href="https://github.com/lovell/sharp">repository</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>24 KEV entries with CWE-434, 8 added in 2026</td>
          <td><a href="https://github.com/cisagov/kev-data/blob/main/known_exploited_vulnerabilities.json">CISA KEV catalog (kev-data)</a>, version 2026.10.29, counted with Python</td>
          <td>Verified (floor; CWE field not always set)</td>
      </tr>
      <tr>
          <td>Results of the ten test requests and the re-encode test</td>
          <td>Our run on Node.js 22.23.4, 2026-10-30</td>
          <td>Verified (our test)</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Node.js Decompression Bombs: zlib Limits That Work</title><link>https://firewallsync.com/posts/nodejs-decompression-bomb-zlib-limits/</link><guid>https://firewallsync.com/posts/nodejs-decompression-bomb-zlib-limits/</guid><pubDate>Thu, 29 Oct 2026 04:40:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>AppSec &amp; APIs</category><description>A 100 KB gzip body can decompress to 100 MB. Node's maxOutputLength stops that in the one-shot zlib functions, but streams silently ignore it, and fetch() decompresses responses with no cap at all. We tested where the limits hold in Node.js 22 and Express 5, with code you can copy.</description><enclosure url="https://firewallsync.com/posts/nodejs-decompression-bomb-zlib-limits/cover_hu6171799600051493283.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/nodejs-decompression-bomb-zlib-limits/cover_hu6171799600051493283.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/nodejs-decompression-bomb-zlib-limits/cover_hu6171799600051493283.jpg" alt="Water pouring over a low concrete weir below a calm lake, with a metal railing on one side and bare trees on the far shore" width="1200" height="630"></p><p>A decompression bomb is a small compressed payload that expands into far more data than your service expects, enough to exhaust memory or tie up a CPU core. MITRE files it as <a href="https://cwe.mitre.org/data/definitions/409.html">CWE-409</a>, &ldquo;Improper Handling of Highly Compressed Data (Data Amplification)&rdquo;. In Node.js, the defence is to <strong>count decompressed bytes and stop at a limit you choose</strong>. Node gives you that limit in one place, <code>maxOutputLength</code> on the one-shot zlib functions, and nowhere else. Streams, HTTP clients and custom middleware all need their own cap.</p>
<p>We ran every example below on Node.js 22.23.4. Results for each are in the text.</p>
<h2 id="how-big-the-amplification-gets">How big the amplification gets</h2>
<p>We compressed 100 MiB (104,857,600 bytes) of zeros with each codec Node ships:</p>
<table>
  <thead>
      <tr>
          <th>Codec</th>
          <th>Compressed size</th>
          <th>Ratio</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>gzip (level 9)</td>
          <td>101,941 bytes</td>
          <td>about 1,029 : 1</td>
      </tr>
      <tr>
          <td>zstd (default level)</td>
          <td>3,219 bytes</td>
          <td>about 32,575 : 1</td>
      </tr>
      <tr>
          <td>Brotli (default quality)</td>
          <td>172 bytes</td>
          <td>about 609,637 : 1</td>
      </tr>
  </tbody>
</table>
<p>Zeros are the best case for a compressor, and the ratio for real data is far lower. That&rsquo;s the point: an attacker picks the input. A request-body limit of 1 MB on the wire tells you nothing about the decompressed size.</p>
<h2 id="the-one-shot-functions-maxoutputlength-works">The one-shot functions: maxOutputLength works</h2>
<p>Node&rsquo;s one-shot functions (<code>zlib.gunzip</code>, <code>zlib.inflate</code>, <code>zlib.brotliDecompress</code>, <code>zlib.zstdDecompress</code> and their <code>*Sync</code> forms) accept <code>maxOutputLength</code>. The <a href="https://nodejs.org/api/zlib.html">zlib docs</a> describe it as an option that &ldquo;Limits output size when using convenience methods&rdquo;. The default is <code>buffer.kMaxLength</code>, the largest Buffer Node can allocate, so without it you have no practical limit.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">zlib</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">require</span>(<span style="color:#e6db74">&#39;node:zlib&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">LIMIT</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">1024</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span>; <span style="color:#75715e">// 1 MiB of decompressed output
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">safeGunzip</span>(<span style="color:#a6e22e">buf</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">zlib</span>.<span style="color:#a6e22e">gunzipSync</span>(<span style="color:#a6e22e">buf</span>, { <span style="color:#a6e22e">maxOutputLength</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">LIMIT</span> });
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">err</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">err</span>.<span style="color:#a6e22e">code</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;ERR_BUFFER_TOO_LARGE&#39;</span>) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">throw</span> Object.<span style="color:#a6e22e">assign</span>(<span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#39;decompressed payload too large&#39;</span>), { <span style="color:#a6e22e">status</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">413</span> });
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#a6e22e">err</span>; <span style="color:#75715e">// corrupt input, wrong format, etc.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>With the 100 MiB bombs from the table, <code>gunzipSync</code>, <code>brotliDecompressSync</code> and <code>zstdDecompressSync</code> all threw <code>RangeError</code> with code <code>ERR_BUFFER_TOO_LARGE</code> and the message &ldquo;Cannot create a Buffer larger than 1048576 bytes&rdquo;. The async <code>zlib.gunzip()</code> passed the same error to its callback.</p>
<p>Use the one-shot functions when the whole payload is already in memory and you can name a sensible maximum: a webhook body, a cached blob, a small config file.</p>
<h2 id="streams-maxoutputlength-is-ignored">Streams: maxOutputLength is ignored</h2>
<p>The stream classes accept the same options object, so this looks right:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#75715e">// Looks safe. Isn&#39;t.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">zlib</span>.<span style="color:#a6e22e">createGunzip</span>({ <span style="color:#a6e22e">maxOutputLength</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1024</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span> });
</span></span></code></pre></div><p>In our test, piping the gzip bomb through that stream delivered all 104,857,600 bytes and finished without an error. The option is documented for the convenience methods only, and the stream path doesn&rsquo;t enforce it. Nothing warns you.</p>
<p>The fix is a Transform that counts bytes after the decompressor and fails the pipeline when the count passes your limit:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">zlib</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">require</span>(<span style="color:#e6db74">&#39;node:zlib&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> { <span style="color:#a6e22e">pipeline</span>, <span style="color:#a6e22e">Transform</span> } <span style="color:#f92672">=</span> <span style="color:#a6e22e">require</span>(<span style="color:#e6db74">&#39;node:stream&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Fail the pipeline once decompressed output exceeds maxBytes.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">byteLimit</span>(<span style="color:#a6e22e">maxBytes</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">seen</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Transform</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">transform</span>(<span style="color:#a6e22e">chunk</span>, <span style="color:#a6e22e">_enc</span>, <span style="color:#a6e22e">cb</span>) {
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">seen</span> <span style="color:#f92672">+=</span> <span style="color:#a6e22e">chunk</span>.<span style="color:#a6e22e">length</span>;
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">seen</span> <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">maxBytes</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">RangeError</span>(<span style="color:#e6db74">`decompressed body exceeds </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">maxBytes</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> bytes`</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">err</span>.<span style="color:#a6e22e">code</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;ERR_DECOMPRESSED_TOO_LARGE&#39;</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">cb</span>(<span style="color:#a6e22e">err</span>);
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">cb</span>(<span style="color:#66d9ef">null</span>, <span style="color:#a6e22e">chunk</span>);
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">pipeline</span>(
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">source</span>,                 <span style="color:#75715e">// e.g. req, a file stream, an upstream response
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#a6e22e">zlib</span>.<span style="color:#a6e22e">createGunzip</span>(),
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">byteLimit</span>(<span style="color:#ae81ff">1024</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span>),
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">destination</span>,
</span></span><span style="display:flex;"><span>  (<span style="color:#a6e22e">err</span>) =&gt; { <span style="color:#75715e">/* err.code === &#39;ERR_DECOMPRESSED_TOO_LARGE&#39; on a bomb */</span> },
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>Against the 100 MiB bomb, the pipeline failed with <code>ERR_DECOMPRESSED_TOO_LARGE</code> after exactly 1,048,576 bytes had reached the destination. <code>pipeline()</code> destroys every stream in the chain on error, so the decompressor stops as well.</p>
<p>Two details make this work. The limiter must sit <strong>after</strong> the decompressor, because counting compressed bytes is the mistake you&rsquo;re avoiding. And use <code>pipeline()</code>, not chained <code>.pipe()</code> calls, which don&rsquo;t propagate errors or destroy the upstream streams.</p>
<h2 id="express-the-body-parser-already-counts-decompressed-bytes">Express: the body parser already counts decompressed bytes</h2>
<p>Express&rsquo;s <code>express.json()</code>, <code>express.urlencoded()</code>, <code>express.text()</code> and <code>express.raw()</code> come from <a href="https://github.com/expressjs/body-parser">body-parser</a>. Its <code>inflate</code> option defaults to <code>true</code>, and in body-parser 2.4.0 it decodes <code>deflate</code>, <code>gzip</code>, <code>br</code> and, where Node supports it, <code>zstd</code>.</p>
<p>We sent <code>express.json()</code> (default <code>limit</code> of <code>'100kb'</code>) a 10 MB JSON body compressed each way:</p>
<table>
  <thead>
      <tr>
          <th>Content-Encoding</th>
          <th>Bytes on the wire</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>gzip</td>
          <td>10,232</td>
          <td>413, <code>entity.too.large</code></td>
      </tr>
      <tr>
          <td>br</td>
          <td>41</td>
          <td>413, <code>entity.too.large</code></td>
      </tr>
      <tr>
          <td>zstd</td>
          <td>355</td>
          <td>413, <code>entity.too.large</code></td>
      </tr>
      <tr>
          <td>gzip with <code>inflate: false</code></td>
          <td>10,232</td>
          <td>415, <code>encoding.unsupported</code></td>
      </tr>
  </tbody>
</table>
<p>All three compressed bodies were under 100 KB on the wire and still got 413, so the limit is applied to the decoded body. That&rsquo;s the behaviour you want. Tested with Express 5.3.0, body-parser 2.4.0 and raw-body 3.0.2.</p>
<p>Two cautions. The body-parser README warns that limits &ldquo;above the default, for example payloads of 5 MB or more&rdquo; increase memory use and response times, so raise <code>limit</code> per route, not globally. And if you don&rsquo;t expect compressed request bodies, <code>inflate: false</code> turns them away with 415 before any decoding.</p>
<p>Custom code is where the gaps are: a handler that does <code>req.pipe(zlib.createGunzip())</code> for an upload endpoint, or a framework plugin with its own decoder. Give each one the byte limiter above.</p>
<h2 id="fetch-responses-are-decompressed-with-no-cap">fetch(): responses are decompressed with no cap</h2>
<p>Node&rsquo;s built-in <code>fetch()</code> decodes <code>Content-Encoding: gzip</code> for you. We served the 100 MiB gzip bomb from a local server:</p>
<ul>
<li><code>res.headers.get('content-length')</code> returned <code>101941</code>, the compressed size.</li>
<li><code>await res.arrayBuffer()</code> returned 104,857,600 bytes.</li>
</ul>
<p>There&rsquo;s no size option on <code>arrayBuffer()</code>, <code>json()</code> or <code>text()</code>. If you fetch URLs you don&rsquo;t control (webhooks, link previews, feed readers, anything with a user-supplied URL), read the body with a cap:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#75715e">// Read at most maxBytes of a fetch() response body (after fetch&#39;s own decompression).
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">readCapped</span>(<span style="color:#a6e22e">res</span>, <span style="color:#a6e22e">maxBytes</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">reader</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">body</span>.<span style="color:#a6e22e">getReader</span>();
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">chunks</span> <span style="color:#f92672">=</span> [];
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">total</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">for</span> (;;) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> { <span style="color:#a6e22e">done</span>, <span style="color:#a6e22e">value</span> } <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">reader</span>.<span style="color:#a6e22e">read</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">done</span>) <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">total</span> <span style="color:#f92672">+=</span> <span style="color:#a6e22e">value</span>.<span style="color:#a6e22e">byteLength</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">total</span> <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">maxBytes</span>) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">reader</span>.<span style="color:#a6e22e">cancel</span>();
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">RangeError</span>(<span style="color:#e6db74">`response exceeds </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">maxBytes</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> bytes`</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">chunks</span>.<span style="color:#a6e22e">push</span>(<span style="color:#a6e22e">value</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Buffer</span>.<span style="color:#a6e22e">concat</span>(<span style="color:#a6e22e">chunks</span>);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">res</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">url</span>, { <span style="color:#a6e22e">signal</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">AbortSignal</span>.<span style="color:#a6e22e">timeout</span>(<span style="color:#ae81ff">10_000</span>) });
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">body</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">readCapped</span>(<span style="color:#a6e22e">res</span>, <span style="color:#ae81ff">1024</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span>);
</span></span></code></pre></div><p>Against the bomb, <code>readCapped(res, 1 MiB)</code> threw &ldquo;response exceeds 1048576 bytes&rdquo;. Pair it with a timeout, because a slow trickle is a different attack that a byte cap doesn&rsquo;t stop. Fetching arbitrary URLs has bigger risks than size, too; our <a href="/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/">SSRF guide</a> covers the network side.</p>
<h2 id="a-checklist-for-node-services">A checklist for Node services</h2>
<table>
  <thead>
      <tr>
          <th>Where you decompress</th>
          <th>What enforces the limit</th>
          <th>Default</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>zlib.*Sync()</code> / <code>zlib.*()</code> one-shot</td>
          <td><code>maxOutputLength</code></td>
          <td><code>buffer.kMaxLength</code> (no practical limit)</td>
      </tr>
      <tr>
          <td><code>zlib.create*()</code> streams</td>
          <td>Your own byte-counting Transform</td>
          <td>None; <code>maxOutputLength</code> is ignored</td>
      </tr>
      <tr>
          <td><code>express.json()</code> and other body-parser parsers</td>
          <td><code>limit</code>, counted after decoding</td>
          <td><code>'100kb'</code></td>
      </tr>
      <tr>
          <td><code>fetch()</code> response bodies</td>
          <td>A capped reader</td>
          <td>None</td>
      </tr>
      <tr>
          <td>Uploaded archives (zip, tar.gz)</td>
          <td>Per-entry and total extracted-size limits in your extractor</td>
          <td>Depends on the library</td>
      </tr>
  </tbody>
</table>
<p>Also consider:</p>
<ul>
<li><strong>Keep the decoder patched.</strong> Size limits don&rsquo;t help against bugs in the decompressor itself. CVE-2026-111497 was an out-of-bounds write in Brotli&rsquo;s decoder, fixed in Node.js 22.23.4, 24.22.1 and 26.12.1; see our <a href="/posts/brotli-cve-2026-111497-find-and-patch-every-copy/">Brotli guide</a>.</li>
<li><strong>Decide whether you need each codec.</strong> zstd arrived in Node.js 22.15.0 and is marked Experimental in the Node 22 docs, yet body-parser 2.4.0 accepts it automatically when the runtime supports it. If clients never send it, <code>inflate: false</code> or a proxy rule removes that path.</li>
<li><strong>Push the limit to the edge as well.</strong> A reverse proxy that decompresses request bodies should cap the decoded size too, so the application isn&rsquo;t your only control.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>All results come from Node.js 22.23.4 on Linux with Express 5.3.0, body-parser 2.4.0 and raw-body 3.0.2, using 100 MiB of zeros as the bomb. Other Node release lines, frameworks (Fastify, Koa, NestJS adapters) and HTTP clients (axios, undici&rsquo;s <code>request</code>) may behave differently. Test them the same way. We measured whether limits held, not peak memory use or CPU time.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>maxOutputLength &ldquo;Limits output size when using convenience methods&rdquo;; default <code>buffer.kMaxLength</code></td>
          <td><a href="https://nodejs.org/api/zlib.html">Node.js zlib docs</a> (<a href="https://github.com/nodejs/node/blob/v22.23.4/doc/api/zlib.md">v22.23.4 source</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>zstd added in v22.15.0; Stability 1, Experimental</td>
          <td>Node.js v22.23.4 zlib docs</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Compression ratios; ERR_BUFFER_TOO_LARGE; streams ignore maxOutputLength; byte limiter; fetch() results</td>
          <td>Our tests on Node.js 22.23.4 (code above)</td>
          <td>Verified (tested)</td>
      </tr>
      <tr>
          <td>Express limit applies after decompression; 413/415 results</td>
          <td>Our tests on Express 5.3.0 / body-parser 2.4.0</td>
          <td>Verified (tested)</td>
      </tr>
      <tr>
          <td>body-parser decodes deflate, gzip, br, zstd; inflate default true; limit default &lsquo;100kb&rsquo;; 5 MB warning</td>
          <td><a href="https://github.com/expressjs/body-parser">body-parser</a> 2.4.0 source and README</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>CWE-409 title</td>
          <td><a href="https://cwe.mitre.org/data/definitions/409.html">MITRE CWE-409</a></td>
          <td>Verified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>PHP session_regenerate_id: Stop Session Fixation Right</title><link>https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/</link><guid>https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/</guid><pubDate>Wed, 28 Oct 2026 04:45:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>Calling session_regenerate_id() at login is the start of PHP session security, not the whole of it. This guide shows the fixation attack strict mode does not stop, a tested helper for timestamp-based rotation, idle and absolute timeouts, and the grace-period shortcut that quietly undoes the fix.</description><enclosure url="https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/cover_hu2986149460493088631.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/cover_hu2986149460493088631.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/cover_hu2986149460493088631.jpg" alt="Two antique brass skeleton keys on a white background, one pointing up and one down, each tied with a thin pale pink ribbon" width="1200" height="630"></p><p>To stop session fixation in PHP, call <code>session_regenerate_id()</code>, or the timestamp-based rotation shown below, whenever a user&rsquo;s privilege changes. Do it before you store the authenticated user in <code>$_SESSION</code>. Don&rsquo;t destroy the old session on the spot. Mark it with a timestamp and refuse it after a short grace period, and enforce your own idle and absolute timeouts, because PHP&rsquo;s garbage collector is not a timeout. That is what PHP&rsquo;s own manual asks for. Below is a helper that follows it, tested against a fixation attack, plus the one shortcut in the manual&rsquo;s example that brings the attack back if you use it at login.</p>
<p>Every behaviour here was run on PHP 8.3.6 CLI with the built-in web server (<code>php -S</code>) and curl, using the default <code>files</code> save handler. The cookie flags and <code>php.ini</code> settings that go with this code are covered in our <a href="/posts/php-ini-security-hardening-production-settings/">php.ini security hardening guide</a>. This article is about the session lifecycle in your application code.</p>
<h2 id="the-attack-strict-mode-doesnt-stop">The attack strict mode doesn&rsquo;t stop</h2>
<p><code>session.use_strict_mode=1</code> makes PHP reject a session ID it never issued. That stops an attacker inventing an ID such as <code>PHPSESSID=123456</code>. But the <a href="https://www.php.net/manual/en/features.session.security.management.php">PHP manual</a> is explicit about the gap: &ldquo;an attacker can force users to use an initialized session ID which has been created by an attacker.&rdquo; The attacker visits your site, receives a real ID, and plants it in the victim&rsquo;s browser. The manual describes two routes: setting a cookie that the browser&rsquo;s precedence rules let override yours (for example from another subdomain), and JavaScript injection. If your login code keeps that ID, the attacker now holds an authenticated session.</p>
<p>We reproduced it with a minimal app whose login does only <code>$_SESSION['uid'] = 42;</code>, run with strict mode on:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ PHPFLAGS<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;-d session.use_strict_mode=1&#34;</span> ./test.sh naive <span style="color:#ae81ff">8090</span>
</span></span><span style="display:flex;"><span>attacker ID: pmq468mni64u18nhukmh7vqe1v
</span></span><span style="display:flex;"><span>logged in
</span></span><span style="display:flex;"><span>ID after login: unchanged
</span></span><span style="display:flex;"><span>attacker replays old ID: account page for uid 42
</span></span></code></pre></div><p>The test script fetches <code>/</code> to get a real ID, sends <code>/login</code> with that ID as the victim, then replays the ID as the attacker.</p>
<h2 id="what-phps-manual-requires">What PHP&rsquo;s manual requires</h2>
<p>The session security chapter is unusually prescriptive. The rules that matter for application code:</p>
<ul>
<li><strong>Rotate on privilege change, before writing auth state.</strong> &ldquo;Session IDs must be regenerated when user privileges are elevated, such as after authenticating. session_regenerate_id must be called prior to setting the authentication information to $_SESSION.&rdquo; Rotate again on any later privilege change, such as entering an admin area or completing step-up authentication. The <a href="https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html">OWASP Session Management Cheat Sheet</a> makes the same point: renew the ID &ldquo;after any privilege level change&rdquo;.</li>
<li><strong>Don&rsquo;t delete active sessions immediately.</strong> &ldquo;session_regenerate_id(true); and session_destroy must never be called together for an active session.&rdquo; Deleting right away loses sessions when requests overlap or the new cookie never arrives. It also removes your chance to notice that someone used an old ID.</li>
<li><strong>Use timestamps instead.</strong> Set &ldquo;a short-term expiration time (timestamp) in $_SESSION&rdquo; on the old session and refuse it later: &ldquo;a few seconds later for stable networks&hellip; and a few minutes later for unstable networks such as cell phones or Wi-Fi.&rdquo;</li>
<li><strong>Don&rsquo;t rely on <code>session.gc_maxlifetime</code> for expiry.</strong> &ldquo;Attackers may access a victim&rsquo;s session ID periodically to prevent its expiration and keep exploiting it.&rdquo; The setting only controls when data &ldquo;will be seen as &lsquo;garbage&rsquo; and potentially cleaned up&rdquo; (default 1440 seconds).</li>
<li><strong>Treat obsolete-ID access as a signal.</strong> When an expired ID is used, the manual recommends removing &ldquo;authenticated status from all of the user&rsquo;s sessions&rdquo; and forcing re-authentication.</li>
</ul>
<p>The <a href="https://www.php.net/manual/en/function.session-regenerate-id.php"><code>session_regenerate_id()</code> page</a> has two example implementations. Both are labelled &ldquo;not fully working code, but an example!&rdquo;</p>
<h2 id="a-tested-rotation-helper">A tested rotation helper</h2>
<p><code>session_guard.php</code> follows the manual&rsquo;s pattern, with two corrections we found in testing (explained after the code). It targets PHP 8.1 or later (<code>session_create_id()</code> exists from 7.1, and the cookie-options array for <code>setcookie()</code> from 7.3).</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#f92672">&lt;?</span><span style="color:#a6e22e">php</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// session_guard.php: timestamp-based session ID rotation, adapted from the
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// pattern in the PHP manual&#39;s session_regenerate_id() page.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">declare</span>(<span style="color:#a6e22e">strict_types</span><span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">defined</span>(<span style="color:#e6db74">&#39;SESSION_GRACE&#39;</span>)    <span style="color:#f92672">||</span> <span style="color:#a6e22e">define</span>(<span style="color:#e6db74">&#39;SESSION_GRACE&#39;</span>, <span style="color:#ae81ff">60</span>);     <span style="color:#75715e">// old ID usable after rotation (s)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">defined</span>(<span style="color:#e6db74">&#39;SESSION_IDLE&#39;</span>)     <span style="color:#f92672">||</span> <span style="color:#a6e22e">define</span>(<span style="color:#e6db74">&#39;SESSION_IDLE&#39;</span>, <span style="color:#ae81ff">1800</span>);    <span style="color:#75715e">// idle timeout (s)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">defined</span>(<span style="color:#e6db74">&#39;SESSION_ABSOLUTE&#39;</span>) <span style="color:#f92672">||</span> <span style="color:#a6e22e">define</span>(<span style="color:#e6db74">&#39;SESSION_ABSOLUTE&#39;</span>, <span style="color:#ae81ff">28800</span>); <span style="color:#75715e">// absolute timeout (s)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">defined</span>(<span style="color:#e6db74">&#39;SESSION_ROTATE&#39;</span>)   <span style="color:#f92672">||</span> <span style="color:#a6e22e">define</span>(<span style="color:#e6db74">&#39;SESSION_ROTATE&#39;</span>, <span style="color:#ae81ff">900</span>);   <span style="color:#75715e">// periodic rotation (s)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ObsoleteSessionAccess</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">RuntimeException</span> {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">secure_session_start</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ini_set</span>(<span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>, <span style="color:#e6db74">&#39;1&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span>    $now <span style="color:#f92672">=</span> <span style="color:#a6e22e">time</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 1. Old ID after rotation: allow a short grace period, then refuse.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isset</span>($_SESSION[<span style="color:#e6db74">&#39;destroyed&#39;</span>])) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($_SESSION[<span style="color:#e6db74">&#39;destroyed&#39;</span>] <span style="color:#f92672">&lt;</span> $now <span style="color:#f92672">-</span> <span style="color:#a6e22e">SESSION_GRACE</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">ObsoleteSessionAccess</span>(<span style="color:#e6db74">&#39;obsolete session ID used&#39;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isset</span>($_SESSION[<span style="color:#e6db74">&#39;new_session_id&#39;</span>])) {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Cookie for the new ID was probably lost: move the client over.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>            <span style="color:#a6e22e">session_commit</span>();
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">session_id</span>($_SESSION[<span style="color:#e6db74">&#39;new_session_id&#39;</span>]);
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 2. Idle and absolute timeouts, enforced in application code.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isset</span>($_SESSION[<span style="color:#e6db74">&#39;uid&#39;</span>])) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($now <span style="color:#f92672">-</span> ($_SESSION[<span style="color:#e6db74">&#39;last_seen&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">SESSION_IDLE</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">||</span> $now <span style="color:#f92672">-</span> ($_SESSION[<span style="color:#e6db74">&#39;created&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">SESSION_ABSOLUTE</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">secure_logout</span>();
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        $_SESSION[<span style="color:#e6db74">&#39;last_seen&#39;</span>] <span style="color:#f92672">=</span> $now;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($now <span style="color:#f92672">-</span> ($_SESSION[<span style="color:#e6db74">&#39;rotated&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">SESSION_ROTATE</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">secure_regenerate</span>(<span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// $forward: let a client still holding the old ID be moved to the new one
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// during the grace period (lost Set-Cookie on a flaky network). Never forward
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// on a privilege change: whoever holds the old ID would be handed the new one.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">secure_regenerate</span>(<span style="color:#a6e22e">bool</span> $forward)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    $data <span style="color:#f92672">=</span> $_SESSION;                      <span style="color:#75715e">// session_start() below loads an empty session
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    $new <span style="color:#f92672">=</span> <span style="color:#a6e22e">session_create_id</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ($forward) {
</span></span><span style="display:flex;"><span>        $_SESSION[<span style="color:#e6db74">&#39;new_session_id&#39;</span>] <span style="color:#f92672">=</span> $new;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    $_SESSION[<span style="color:#e6db74">&#39;destroyed&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#a6e22e">time</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">session_commit</span>();                       <span style="color:#75715e">// old session keeps the markers
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">session_id</span>($new);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ini_set</span>(<span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>, <span style="color:#e6db74">&#39;0&#39;</span>); <span style="color:#75715e">// let session_start() accept $new
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ini_set</span>(<span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>, <span style="color:#e6db74">&#39;1&#39;</span>);
</span></span><span style="display:flex;"><span>    $_SESSION <span style="color:#f92672">=</span> $data;
</span></span><span style="display:flex;"><span>    $_SESSION[<span style="color:#e6db74">&#39;rotated&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#a6e22e">time</span>();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">secure_login</span>(<span style="color:#a6e22e">int</span> $uid)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">secure_regenerate</span>(<span style="color:#66d9ef">false</span>);               <span style="color:#75715e">// rotate BEFORE marking authenticated
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    $_SESSION[<span style="color:#e6db74">&#39;uid&#39;</span>] <span style="color:#f92672">=</span> $uid;
</span></span><span style="display:flex;"><span>    $_SESSION[<span style="color:#e6db74">&#39;created&#39;</span>] <span style="color:#f92672">=</span> $_SESSION[<span style="color:#e6db74">&#39;last_seen&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#a6e22e">time</span>();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">secure_logout</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    $_SESSION <span style="color:#f92672">=</span> [];
</span></span><span style="display:flex;"><span>    $p <span style="color:#f92672">=</span> <span style="color:#a6e22e">session_get_cookie_params</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">setcookie</span>(<span style="color:#a6e22e">session_name</span>(), <span style="color:#e6db74">&#39;&#39;</span>, [
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;expires&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">time</span>() <span style="color:#f92672">-</span> <span style="color:#ae81ff">3600</span>, <span style="color:#e6db74">&#39;path&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;path&#39;</span>], <span style="color:#e6db74">&#39;domain&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;domain&#39;</span>],
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;secure&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;secure&#39;</span>], <span style="color:#e6db74">&#39;httponly&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;httponly&#39;</span>], <span style="color:#e6db74">&#39;samesite&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;samesite&#39;</span>] <span style="color:#f92672">?:</span> <span style="color:#e6db74">&#39;Lax&#39;</span>,
</span></span><span style="display:flex;"><span>    ]);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">session_destroy</span>();
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Wire it in by calling <code>secure_session_start()</code> instead of <code>session_start()</code>, <code>secure_login($userId)</code> after you have verified the password and any second factor, and <code>secure_logout()</code> from your logout handler. On <code>ObsoleteSessionAccess</code>, return 401 and log the event.</p>
<h3 id="what-we-tested">What we tested</h3>
<p>Against the same attack, with the helper in place:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ ./test.sh guard <span style="color:#ae81ff">8086</span>
</span></span><span style="display:flex;"><span>attacker ID: jt6upaht8b1i3hcuuk743hivm5
</span></span><span style="display:flex;"><span>logged in
</span></span><span style="display:flex;"><span>ID after login: 24qohqtgd4trsgfuov1tjn20nm
</span></span><span style="display:flex;"><span>attacker replays old ID: anonymous
</span></span></code></pre></div><p>Periodic rotation, with rotation forced on every request and a 2-second grace period:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ ./test2.sh
</span></span><span style="display:flex;"><span>account page for uid 42
</span></span><span style="display:flex;"><span>login ID 10d0f50p49pto2ocoffotrbsv3 rotated to r4428iae8369vin9533ubpb292
</span></span><span style="display:flex;"><span>old ID inside grace: account page for uid 42
</span></span><span style="display:flex;"><span>old ID after grace:  401 obsolete session
</span></span><span style="display:flex;"><span>logout with current ID: logged out
</span></span><span style="display:flex;"><span>current ID after logout: anonymous
</span></span></code></pre></div><p>With a 1-second idle timeout, a request made 3 seconds after login came back anonymous with a new session cookie, and the logged-in session&rsquo;s file was gone from <code>session.save_path</code>. The pre-login session stayed on disk, marked as destroyed, until garbage collection, as the manual intends.</p>
<h3 id="correction-1-never-forward-an-old-id-after-login">Correction 1: never forward an old ID after login</h3>
<p>The manual&rsquo;s second example deals with lost cookies: if the old ID comes back during the grace period, it switches the client to the new ID it stored in the old session. That is reasonable for periodic rotation, where the person holding the old ID is almost always the legitimate user. At login it is a hole. The attacker holds the old, pre-login ID, so forwarding hands them the new, authenticated one.</p>
<p>Our first draft forwarded in both cases, and the fixation test failed: the attacker&rsquo;s replay returned <code>account page for uid 42</code>. The manual hints at this in a comment (&ldquo;Do not try to set session ID again if you would like to remove authentication flag&rdquo;). The helper makes it explicit: <code>secure_regenerate(false)</code> at login, <code>secure_regenerate(true)</code> only for timed rotation. The trade-off is that a user whose login response is lost has to log in again.</p>
<h3 id="correction-2-carry-the-session-data-across">Correction 2: carry the session data across</h3>
<p>In the manual&rsquo;s example, the new session is started with <code>session_id($new); session_start();</code>. In our test on PHP 8.3.6, that started an empty <code>$_SESSION</code>: the authenticated user vanished on the first periodic rotation. The helper copies the data before committing the old session and restores it after starting the new one. At login this also keeps pre-login state, such as a cart.</p>
<h2 id="other-session-rules-worth-enforcing">Other session rules worth enforcing</h2>
<ul>
<li><strong>Timeouts.</strong> OWASP gives common idle timeouts of &ldquo;2-5 minutes for high-value applications and 15-30 minutes for low risk applications&rdquo;. Every session should also have an absolute timeout &ldquo;regardless of session activity&rdquo;. The helper&rsquo;s defaults (30 minutes idle, 8 hours absolute) are examples; set your own.</li>
<li><strong>Locking.</strong> PHP locks session data per request, which attackers can abuse to tie up workers. The manual suggests <code>session_start(['read_and_close' =&gt; true])</code> for requests that only read the session, and <code>session_commit()</code> as soon as you have finished writing.</li>
<li><strong>Remember-me is not a long session.</strong> The manual says not to &ldquo;use long life session IDs for auto-login&rdquo;. Use a separate, single-use random token in its own cookie, and replace it each time it is used.</li>
<li><strong>Track active sessions</strong> if you need &ldquo;log out everywhere&rdquo; or the manual&rsquo;s response to obsolete-ID access. PHP doesn&rsquo;t do this for you. The manual suggests a user-ID-prefixed ID from <code>session_create_id()</code>, which &ldquo;is vital&rdquo; to pair with strict mode. Don&rsquo;t use a confidential value as the prefix.</li>
<li><strong>Sessions don&rsquo;t stop CSRF.</strong> &ldquo;Sessions and authentication do not protect against CSRF attacks.&rdquo; See our <a href="/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/">CSRF defences comparison</a>.</li>
<li><strong>Session files are an attack surface.</strong> October&rsquo;s PHP2Shell attempts tried to plant code through session files. See the <a href="/posts/php2shell-cve-2026-109618-cve-2026-109619-patch-and-hunt/">PHP2Shell patch-and-hunt guide</a>.</li>
</ul>
<h2 id="common-mistakes">Common mistakes</h2>
<table>
  <thead>
      <tr>
          <th>Mistake</th>
          <th>Why it fails</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Setting <code>$_SESSION['uid']</code> before rotating</td>
          <td>The pre-login ID, which an attacker may hold, briefly carries the authenticated state. The manual says to rotate first</td>
      </tr>
      <tr>
          <td><code>session_regenerate_id(true)</code> on every request</td>
          <td>Concurrent requests lose their session, and you can no longer detect old-ID reuse</td>
      </tr>
      <tr>
          <td>Relying on <code>gc_maxlifetime</code> for logout</td>
          <td>Garbage collection is probabilistic, and activity on a stolen ID keeps it alive</td>
      </tr>
      <tr>
          <td>Forwarding old IDs after login</td>
          <td>Gives the attacker the new authenticated ID (our test)</td>
      </tr>
      <tr>
          <td>Rotating only at login</td>
          <td>A stolen post-login ID stays valid until logout or timeout. Add periodic rotation for sensitive apps</td>
      </tr>
  </tbody>
</table>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>The helper is a tested example, not a drop-in library. It assumes PHP&rsquo;s default <code>files</code> handler. A custom or Redis save handler must implement session ID validation for strict mode to work, as the manual notes for user-defined handlers. It does not implement the cross-session &ldquo;remove authentication from all of the user&rsquo;s sessions&rdquo; response, which needs an active-session registry. If you use a framework, prefer its session layer to this helper, and check that it rotates the ID at login and enforces timeouts. Our tests ran on a single PHP process. We did not load-test concurrent requests during rotation.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Strict mode does not stop initialized attacker IDs; rotate before setting auth; timestamps instead of immediate deletion; gc_maxlifetime not to be relied on; locking; auto-login; CSRF</td>
          <td><a href="https://www.php.net/manual/en/features.session.security.management.php">PHP manual: Session Management Basics</a> (doc-en <code>reference/session/security.xml</code>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Example rotation code, &ldquo;not fully working code&rdquo;, lost-cookie forwarding</td>
          <td><a href="https://www.php.net/manual/en/function.session-regenerate-id.php">PHP manual: session_regenerate_id</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>gc_maxlifetime definition and 1440 default</td>
          <td><a href="https://www.php.net/manual/en/session.configuration.php">PHP manual: session runtime configuration</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Renew on privilege change; idle and absolute timeout ranges</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html">OWASP Session Management Cheat Sheet</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Fixation succeeds without rotation (strict mode on); helper blocks it; forwarding at login reopens it; new session starts empty</td>
          <td>Our tests, PHP 8.3.6 CLI, <code>php -S</code></td>
          <td>Verified (our test)</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Webhook Security: Signatures, Replay Protection, Idempotency</title><link>https://firewallsync.com/posts/webhook-security-hmac-signatures-timestamps-and-replay-protection/</link><guid>https://firewallsync.com/posts/webhook-security-hmac-signatures-timestamps-and-replay-protection/</guid><pubDate>Mon, 19 Oct 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>A webhook receiver is a public URL that triggers real actions. Securing it takes three separate controls: HMAC signature verification against the raw body, a freshness check on a signed timestamp, and idempotent processing keyed on the event ID. Here is how Stripe, GitHub and Shopify differ, with tested Python verification code.</description><enclosure url="https://firewallsync.com/posts/webhook-security-hmac-signatures-timestamps-and-replay-protection/cover_hu11431759582368161818.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/webhook-security-hmac-signatures-timestamps-and-replay-protection/cover_hu11431759582368161818.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/webhook-security-hmac-signatures-timestamps-and-replay-protection/cover_hu11431759582368161818.jpg" alt="Close-up of colourful syntax-highlighted JavaScript on a dark screen, including a SQL query string" width="1200" height="630"></p><p>A webhook endpoint is a public URL that fulfils an order, grants access or updates a record whenever it receives a correctly shaped POST request. Unlike most of your API, it is designed to accept unsolicited requests from outside, because the sending service decides when to call it. That makes three separate problems worth handling deliberately: <strong>forged</strong> requests from someone who isn&rsquo;t the provider, <strong>replayed</strong> copies of real requests, and <strong>duplicate</strong> deliveries of the same real event by the provider itself.</p>
<p><strong>Scope.</strong> Provider details below come from Stripe&rsquo;s webhook documentation, GitHub&rsquo;s &ldquo;Validating webhook deliveries&rdquo; and &ldquo;Best practices for using webhooks&rdquo; pages, and Shopify&rsquo;s &ldquo;Verify webhook deliveries&rdquo; page, read on October 22, 2026. The Python verification code was run with Python 3.13 against GitHub&rsquo;s published test vector and against signatures we generated; it was not run against live provider traffic. Providers change their schemes, so check their current documentation before you ship.</p>
<h2 id="forgery-verify-an-hmac-signature-on-the-raw-body">Forgery: verify an HMAC signature, on the raw body</h2>
<p>Stripe, GitHub and Shopify all sign deliveries with HMAC-SHA256 (hash-based message authentication code using SHA-256). The provider computes an HMAC of the payload with a secret shared with you and sends it in a header; you recompute it and compare. Stripe&rsquo;s documentation puts the risk plainly: without verification, an attacker could send fake events to trigger actions like fulfilling orders, granting account access or modifying records.</p>
<p>Two implementation details decide whether this works.</p>
<p><strong>Hash the exact bytes you received.</strong> Every provider signs the raw body it sent. Stripe lists the ways frameworks break this: adding or removing whitespace, reordering keys, converting the string to JSON, or changing the encoding. In Express, for example, Stripe notes that <code>express.json()</code> applied before the webhook route parses the body first and makes verification fail. Capture the raw body for the webhook route, verify, then parse. When verification fails on real traffic, fix the body capture; don&rsquo;t weaken the check.</p>
<p><strong>Compare in constant time.</strong> GitHub&rsquo;s documentation says never to use a plain <code>==</code> and to use a constant-time comparison such as <code>crypto.timingSafeEqual</code> (Node.js) or <code>secure_compare</code> (Ruby) to mitigate timing attacks; Stripe says the same. In Python, use <code>hmac.compare_digest</code>.</p>
<h2 id="how-the-three-providers-differ">How the three providers differ</h2>
<table>
  <thead>
      <tr>
          <th></th>
          <th>Stripe</th>
          <th>GitHub</th>
          <th>Shopify</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Header</td>
          <td><code>Stripe-Signature</code></td>
          <td><code>X-Hub-Signature-256</code></td>
          <td><code>X-Shopify-Hmac-SHA256</code></td>
      </tr>
      <tr>
          <td>Format</td>
          <td><code>t=&lt;unix time&gt;,v1=&lt;hex&gt;</code> (can contain several <code>v1</code> values; test events also carry a fake <code>v0</code>)</td>
          <td><code>sha256=&lt;hex&gt;</code></td>
          <td>Base64 digest</td>
      </tr>
      <tr>
          <td>What is signed</td>
          <td><code>&lt;timestamp&gt;.&lt;raw body&gt;</code></td>
          <td>Raw body</td>
          <td>Raw body</td>
      </tr>
      <tr>
          <td>Secret</td>
          <td>Per-endpoint signing secret (<code>whsec_...</code>)</td>
          <td>Webhook secret you set</td>
          <td>App&rsquo;s client secret</td>
      </tr>
      <tr>
          <td>Freshness</td>
          <td>Signed timestamp; libraries default to 5 minutes&rsquo; tolerance</td>
          <td>None in the signature; use <code>X-GitHub-Delivery</code></td>
          <td>None in the signature; use <code>X-Shopify-Webhook-Id</code> for duplicates</td>
      </tr>
  </tbody>
</table>
<p>Stripe adds two details worth copying into any verifier: ignore every scheme except <code>v1</code>, which it says prevents downgrade attacks, and accept any one of several <code>v1</code> signatures, because during a secret rollover (up to 24 hours) Stripe signs with each active secret.</p>
<h3 id="tested-verification-code">Tested verification code</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> base64<span style="color:#f92672">,</span> hashlib<span style="color:#f92672">,</span> hmac<span style="color:#f92672">,</span> time
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">_hmac</span>(secret: bytes, msg: bytes) <span style="color:#f92672">-&gt;</span> bytes:
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> hmac<span style="color:#f92672">.</span>new(secret, msg, hashlib<span style="color:#f92672">.</span>sha256)<span style="color:#f92672">.</span>digest()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">verify_github</span>(raw_body: bytes, header: str, secret: bytes) <span style="color:#f92672">-&gt;</span> bool:
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># X-Hub-Signature-256: &#34;sha256=&#34; + hex digest of the raw body</span>
</span></span><span style="display:flex;"><span>    expected <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;sha256=&#34;</span> <span style="color:#f92672">+</span> _hmac(secret, raw_body)<span style="color:#f92672">.</span>hex()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> hmac<span style="color:#f92672">.</span>compare_digest(expected, header <span style="color:#f92672">or</span> <span style="color:#e6db74">&#34;&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">verify_shopify</span>(raw_body: bytes, header: str, secret: bytes) <span style="color:#f92672">-&gt;</span> bool:
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># X-Shopify-Hmac-SHA256: base64 of the HMAC of the raw body</span>
</span></span><span style="display:flex;"><span>    expected <span style="color:#f92672">=</span> base64<span style="color:#f92672">.</span>b64encode(_hmac(secret, raw_body))<span style="color:#f92672">.</span>decode()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> hmac<span style="color:#f92672">.</span>compare_digest(expected, header <span style="color:#f92672">or</span> <span style="color:#e6db74">&#34;&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">verify_stripe</span>(raw_body: bytes, header: str, secret: bytes,
</span></span><span style="display:flex;"><span>                  tolerance: int <span style="color:#f92672">=</span> <span style="color:#ae81ff">300</span>, now: int <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">None</span>) <span style="color:#f92672">-&gt;</span> bool:
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Stripe-Signature: &#34;t=&lt;unix time&gt;,v1=&lt;hex&gt;[,v1=...]&#34; over &#34;&lt;t&gt;.&lt;raw body&gt;&#34;</span>
</span></span><span style="display:flex;"><span>    parts <span style="color:#f92672">=</span> [p<span style="color:#f92672">.</span>split(<span style="color:#e6db74">&#34;=&#34;</span>, <span style="color:#ae81ff">1</span>) <span style="color:#66d9ef">for</span> p <span style="color:#f92672">in</span> (header <span style="color:#f92672">or</span> <span style="color:#e6db74">&#34;&#34;</span>)<span style="color:#f92672">.</span>split(<span style="color:#e6db74">&#34;,&#34;</span>) <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;=&#34;</span> <span style="color:#f92672">in</span> p]
</span></span><span style="display:flex;"><span>    ts <span style="color:#f92672">=</span> next((v <span style="color:#66d9ef">for</span> k, v <span style="color:#f92672">in</span> parts <span style="color:#66d9ef">if</span> k <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;t&#34;</span>), <span style="color:#66d9ef">None</span>)
</span></span><span style="display:flex;"><span>    sigs <span style="color:#f92672">=</span> [v <span style="color:#66d9ef">for</span> k, v <span style="color:#f92672">in</span> parts <span style="color:#66d9ef">if</span> k <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;v1&#34;</span>]          <span style="color:#75715e"># ignore v0 and others</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ts <span style="color:#f92672">is</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">or</span> <span style="color:#f92672">not</span> ts<span style="color:#f92672">.</span>isdigit() <span style="color:#f92672">or</span> <span style="color:#f92672">not</span> sigs:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>    expected <span style="color:#f92672">=</span> _hmac(secret, ts<span style="color:#f92672">.</span>encode() <span style="color:#f92672">+</span> <span style="color:#e6db74">b</span><span style="color:#e6db74">&#34;.&#34;</span> <span style="color:#f92672">+</span> raw_body)<span style="color:#f92672">.</span>hex()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> any(hmac<span style="color:#f92672">.</span>compare_digest(expected, s) <span style="color:#66d9ef">for</span> s <span style="color:#f92672">in</span> sigs):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>    now <span style="color:#f92672">=</span> int(time<span style="color:#f92672">.</span>time()) <span style="color:#66d9ef">if</span> now <span style="color:#f92672">is</span> <span style="color:#66d9ef">None</span> <span style="color:#66d9ef">else</span> now
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> abs(now <span style="color:#f92672">-</span> int(ts)) <span style="color:#f92672">&lt;=</span> tolerance
</span></span></code></pre></div><p>What we checked on October 22, 2026:</p>
<table>
  <thead>
      <tr>
          <th>Test</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GitHub&rsquo;s published test vector (secret <code>It's a Secret to Everybody</code>, payload <code>Hello, World!</code>)</td>
          <td>Accepted</td>
      </tr>
      <tr>
          <td>GitHub-style signature checked against a re-serialized body (spaces added after colons)</td>
          <td>Rejected</td>
      </tr>
      <tr>
          <td>Shopify Base64 signature</td>
          <td>Accepted; the same digest sent as hex was rejected</td>
      </tr>
      <tr>
          <td>Stripe signature checked 60 seconds after its timestamp</td>
          <td>Accepted</td>
      </tr>
      <tr>
          <td>Same Stripe signature checked 10 minutes later (default 5-minute tolerance)</td>
          <td>Rejected</td>
      </tr>
      <tr>
          <td>Stripe header with the timestamp changed but the original <code>v1</code> signature</td>
          <td>Rejected</td>
      </tr>
      <tr>
          <td>Stripe header with only a <code>v0</code> signature</td>
          <td>Rejected</td>
      </tr>
  </tbody>
</table>
<p>For production, prefer the provider&rsquo;s official library where one exists (Stripe recommends its libraries over manual verification). Code like the above is for providers without one, or for understanding what the library does.</p>
<h2 id="replay-a-valid-signature-isnt-a-fresh-request">Replay: a valid signature isn&rsquo;t a fresh request</h2>
<p>A signature proves who signed the body, not when the request was sent. An attacker who captures a real delivery can resend it, and the signature still verifies. If the webhook triggers a one-time action, such as a refund or an access grant, that replay does real harm.</p>
<p><strong>The timestamp must be inside what&rsquo;s signed.</strong> Stripe&rsquo;s documentation explains why it signs <code>timestamp.body</code>: an attacker can&rsquo;t change the timestamp without invalidating the signature, so a stale request can be rejected. A timestamp sent alongside the signature but not covered by it gives no protection, because an attacker can replace it. Our test above shows the difference: swapping the timestamp broke the signature. If you design your own outgoing webhooks, copy this pattern.</p>
<p>Stripe&rsquo;s libraries default to a 5-minute tolerance. Stripe warns that a tolerance of <code>0</code> disables the recency check entirely, and recommends keeping your clock synchronized with NTP (Network Time Protocol). It also notes that each retry gets a new timestamp and signature, so legitimate retries aren&rsquo;t rejected as stale.</p>
<p><strong>A time window is not the whole answer.</strong> A replay inside the tolerance window still passes. GitHub and Shopify don&rsquo;t sign a timestamp at all. That is why the next control matters.</p>
<h2 id="duplicates-process-each-event-once">Duplicates: process each event once</h2>
<p>Providers retry. Stripe says it retries live-mode deliveries for up to three days and that endpoints &ldquo;might occasionally receive the same event more than once&rdquo;. Shopify says it retries 8 times over 4 hours after a failure. None of the three promises exactly-once delivery.</p>
<p>The fix is to record the ID of every event you&rsquo;ve processed and skip ones you&rsquo;ve seen:</p>
<ul>
<li><strong>Stripe:</strong> log processed event IDs. Stripe also notes that two separate Event objects can occasionally be generated for the same change; it suggests using the <code>data.object</code> ID together with the event type to spot those. Don&rsquo;t use the <code>created</code> timestamp to detect duplicates or ordering.</li>
<li><strong>GitHub:</strong> use <code>X-GitHub-Delivery</code>, which GitHub describes as unique per event and recommends specifically against replay attacks. A redelivery you request keeps the same value.</li>
<li><strong>Shopify:</strong> make processing idempotent, or deduplicate on <code>X-Shopify-Webhook-Id</code>. <code>X-Shopify-Event-Id</code> groups deliveries caused by the same merchant action, which matters if you have several subscriptions to one topic.</li>
</ul>
<p>Recording the ID and performing the action should succeed or fail together, for example in one database transaction with a unique constraint on the event ID. Otherwise a crash between the two can lose or double-process an event.</p>
<p>This one control handles three cases at once: provider retries, redeliveries, and replays that slip inside the timestamp window.</p>
<h2 id="ip-allowlisting-an-extra-layer-not-a-primary-control">IP allowlisting: an extra layer, not a primary control</h2>
<p>Stripe publishes the IP addresses it sends webhooks from and recommends allowlisting them alongside signature verification. GitHub publishes its webhook source addresses through its <code>GET /meta</code> API and notes that they change occasionally, so the allow list needs regular updates. An allow list doesn&rsquo;t depend on your application code being right, which is its value. It doesn&rsquo;t help if the address list is stale, if a provider doesn&rsquo;t publish one, or if the attacker is sending through the provider&rsquo;s own infrastructure, so keep signature verification as the primary check.</p>
<h2 id="operational-details-that-matter">Operational details that matter</h2>
<ul>
<li><strong>Respond fast, process later.</strong> GitHub treats a delivery as failed if you don&rsquo;t respond with a 2xx within 10 seconds (on GitHub.com), and Stripe says to return 2xx before complex logic. Verify, record the event, queue the work, then respond.</li>
<li><strong>Reject on any failed check.</strong> Do no partial processing. Log which check failed, the source IP and the delivery ID, but avoid logging full payloads that may contain personal or payment data. Repeated failures from one source are worth an alert.</li>
<li><strong>Exempt only the webhook route from CSRF checks.</strong> Stripe notes that frameworks such as Rails and Django may require a CSRF token on every POST and block webhooks; scope the exemption to the webhook route, which is protected by its signature instead. See our <a href="/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/">CSRF protection guide</a> for why the rest of the app should keep it.</li>
<li><strong>Rotate secrets.</strong> Stripe recommends rolling signing secrets periodically or when you suspect exposure, and supports an overlap of up to 24 hours. Store webhook secrets like any other credential; our guide on <a href="/posts/how-to-detect-and-rotate-leaked-api-keys-before-attackers-use-them/">detecting and rotating leaked API keys</a> covers the process.</li>
<li><strong>Only subscribe to events you handle.</strong> Stripe recommends it, and it shrinks the set of actions a forged or replayed event could trigger.</li>
</ul>
<h2 id="checklist">Checklist</h2>
<ul>
<li>Capture the raw body for the webhook route and verify before parsing.</li>
<li>Use the provider&rsquo;s exact scheme: header, encoding, signed string, accepted versions.</li>
<li>Compare signatures in constant time.</li>
<li>Where a timestamp is signed, enforce a tolerance of a few minutes, never zero, and keep clocks in sync.</li>
<li>Record processed event or delivery IDs atomically with the action, and skip duplicates.</li>
<li>Treat IP allowlists as defense in depth.</li>
<li>Respond quickly; process asynchronously.</li>
<li>Reject and log failures; alert on repeats.</li>
</ul>
<p>Webhook receivers are one entry in a wider API surface; <a href="/posts/how-to-secure-a-public-rest-api-a-production-checklist/">our public REST API checklist</a> covers the rest.</p>
<h2 id="about-this-article">About this article</h2>
<p>This guide combines two earlier FirewallSync articles on webhook security, published October 18 and 19, 2026, into one updated reference. The October 18 URL now redirects here. We added provider documentation references, a provider comparison and tested code.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 22, 2026.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Stripe header format, <code>v1</code> only, <code>v0</code> test signature, signed string <code>timestamp.body</code>, constant-time comparison, default 5-minute tolerance, never 0, NTP, new timestamp per retry, multiple signatures during secret roll (up to 24 hours)</td>
          <td><a href="https://docs.stripe.com/webhooks">Stripe: Receive Stripe events in your webhook endpoint</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Raw body required; frameworks that reorder keys or change whitespace break verification; Express middleware order</td>
          <td><a href="https://docs.stripe.com/webhooks/signature">Stripe: Resolve webhook signature verification errors</a> and the Stripe webhooks page</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Stripe retries up to three days in live mode; duplicate events; log event IDs; don&rsquo;t use <code>created</code>; IP list; CSRF exemption; subscribe only to needed events</td>
          <td><a href="https://docs.stripe.com/webhooks">Stripe: webhooks best practices</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>GitHub <code>X-Hub-Signature-256</code>, hex digest starting <code>sha256=</code>, never <code>==</code>, test vector</td>
          <td><a href="https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries">GitHub Docs: Validating webhook deliveries</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>GitHub IP list via <code>GET /meta</code>; respond within 10 seconds; <code>X-GitHub-Delivery</code> against replay; same value on redelivery</td>
          <td><a href="https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks">GitHub Docs: Best practices for using webhooks</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Shopify <code>X-Shopify-Hmac-SHA256</code>, Base64, raw body, client secret; idempotency; <code>X-Shopify-Webhook-Id</code> and <code>X-Shopify-Event-Id</code>; 8 retries over 4 hours</td>
          <td><a href="https://shopify.dev/docs/apps/build/webhooks/verify-deliveries">Shopify: Verify webhook deliveries</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Verification code results</td>
          <td>Run by us on October 22, 2026 with Python 3.13</td>
          <td>Verified; not tested against live provider traffic</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>IDOR Vulnerabilities: How to Find and Fix Insecure Direct Object References</title><link>https://firewallsync.com/posts/idor-vulnerabilities-how-to-find-and-fix-insecure-direct-object-references/</link><guid>https://firewallsync.com/posts/idor-vulnerabilities-how-to-find-and-fix-insecure-direct-object-references/</guid><pubDate>Fri, 16 Oct 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>IDOR is one of the simplest vulnerability classes to understand and one of the easiest to miss in testing, because it doesn't fail — it just returns the wrong person's data.</description><enclosure url="https://firewallsync.com/posts/idor-vulnerabilities-how-to-find-and-fix-insecure-direct-object-references/cover_hu5924451424189516459.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/idor-vulnerabilities-how-to-find-and-fix-insecure-direct-object-references/cover_hu5924451424189516459.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/idor-vulnerabilities-how-to-find-and-fix-insecure-direct-object-references/cover_hu5924451424189516459.jpg" alt="Computer screen displaying web code" width="1200" height="630"></p><p>An insecure direct object reference happens when an application uses a value the user can see or guess — an order number, a document ID, a user ID in a URL — to look up a resource, without separately checking whether the requesting user is actually authorized to access that specific resource. It&rsquo;s conceptually simple, which is exactly why it&rsquo;s easy to miss: the request doesn&rsquo;t fail, doesn&rsquo;t throw an error, and doesn&rsquo;t look different from a legitimate one. It just returns data belonging to someone else.</p>
<h2 id="why-standard-testing-misses-it">Why standard testing misses it</h2>
<p>Functional testing confirms a feature works for the user it was built for. Security testing that stops at authentication confirms a user can log in and reach the feature at all. Neither one, by default, asks &ldquo;what happens if this same authenticated user changes the ID in the request to something that belongs to someone else&rdquo; — which is the entire test that actually catches IDOR. It has to be added deliberately as its own test category, per endpoint that accepts an identifier, rather than assumed to be covered by general functional or auth testing.</p>
<h2 id="how-to-actually-test-for-it">How to actually test for it</h2>
<p>The direct method: authenticate as two different test accounts, and for every endpoint that accepts a resource identifier, attempt to access account A&rsquo;s resources while authenticated as account B. If the request succeeds and returns account A&rsquo;s data, that endpoint is vulnerable. This needs to be done systematically across every endpoint that takes an ID — read endpoints, update endpoints, and delete endpoints separately, since an application can correctly restrict reads while still allowing an unauthorized update or delete through the same missing check.</p>
<p>Pay particular attention to endpoints that were added later, or that support secondary features (exports, attachments, comments, activity logs tied to a parent resource) — these are common blind spots because they weren&rsquo;t part of the original access-control design review, if one happened at all.</p>
<h2 id="sequential-ids-make-exploitation-trivial-but-arent-the-root-cause">Sequential IDs make exploitation trivial, but aren&rsquo;t the root cause</h2>
<p>Switching from sequential numeric IDs to UUIDs is a common recommendation, and it does raise the bar for casual exploitation — an attacker can&rsquo;t simply increment a number to enumerate other users&rsquo; resources. But this is obfuscation, not a fix. If the authorization check is still missing, a UUID-based system is still vulnerable to anyone who obtains a valid UUID through another channel (a shared link, a referrer header, a leaked log entry, or simple correlation from other exposed data). Treat ID format as a hardening measure that raises exploitation cost, not as the actual control that closes the vulnerability.</p>
<h2 id="the-actual-fix-authorize-the-object-not-just-the-request">The actual fix: authorize the object, not just the request</h2>
<p>Every code path that fetches a resource by ID needs to verify, as an explicit step, that the currently authenticated user has a legitimate relationship to that specific resource — they own it, it was shared with them, or their role grants access to it — before returning or modifying it. This check has to happen server-side, on every request, independent of anything the client sends about what it thinks it&rsquo;s allowed to do.</p>
<p>A useful pattern is to make this check structurally hard to skip: instead of writing &ldquo;fetch the resource, then check authorization&rdquo; as two separate steps a developer could forget to pair, write data-access functions that require the authenticated user&rsquo;s identity as an input and only ever return resources that user is authorized for — moving the check into the data layer itself rather than leaving it to be remembered in every route handler.</p>
<h2 id="a-practical-checklist">A practical checklist</h2>
<ul>
<li>Test every ID-accepting endpoint with a second account&rsquo;s credentials against the first account&rsquo;s resources, across read, update, and delete separately</li>
<li>Don&rsquo;t treat UUIDs as a fix — they raise exploitation difficulty, not close the gap</li>
<li>Add explicit object-level authorization at the data-access layer, not scattered per-route</li>
<li>Specifically review secondary and recently-added endpoints, since these are the most common places the check gets missed</li>
<li>Include authorization testing as its own category in your test suite, not folded into general functional testing</li>
</ul>
<p>IDOR is one of the few vulnerability classes where the fix isn&rsquo;t more complex code — it&rsquo;s one consistent check, applied everywhere it needs to be, instead of assumed to already be there.</p>
]]></content:encoded></item><item><title>Broken Access Control: Real-World Examples and How to Prevent It</title><link>https://firewallsync.com/posts/broken-access-control-real-world-examples-and-how-to-prevent-it/</link><guid>https://firewallsync.com/posts/broken-access-control-real-world-examples-and-how-to-prevent-it/</guid><pubDate>Thu, 15 Oct 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>Broken access control has topped the OWASP Top 10 since 2021 for a reason: it's not one vulnerability type, it's an entire category of ways authorization checks get skipped.</description><enclosure url="https://firewallsync.com/posts/broken-access-control-real-world-examples-and-how-to-prevent-it/cover_hu8235627336239014093.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/broken-access-control-real-world-examples-and-how-to-prevent-it/cover_hu8235627336239014093.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/broken-access-control-real-world-examples-and-how-to-prevent-it/cover_hu8235627336239014093.jpg" alt="Old key hanging from a ring" width="1200" height="630"></p><p>Broken access control has held the top position in the OWASP Top 10 since the 2021 edition, and it&rsquo;s the category with the most reported occurrences in OWASP&rsquo;s own contributed testing data — over 318,000 recorded instances across the applications analyzed. That volume isn&rsquo;t because it&rsquo;s one specific bug pattern; it&rsquo;s because &ldquo;broken access control&rdquo; covers several distinct ways authorization checks fail, each common enough on its own to matter.</p>
<h2 id="insecure-direct-object-references-idor">Insecure direct object references (IDOR)</h2>
<p>The most familiar pattern: an application exposes a reference to an internal object — an order ID, a document ID, a user ID — directly in a URL or request parameter, and fails to verify that the requesting user is actually authorized to access that specific object. Changing <code>/invoices/1024</code> to <code>/invoices/1025</code> and getting another customer&rsquo;s invoice back is the canonical example. This happens because authentication (confirming who&rsquo;s logged in) gets implemented and tested thoroughly, while object-level authorization (confirming that specific logged-in user is allowed to see that specific object) gets assumed rather than explicitly checked on every request.</p>
<h2 id="missing-function-level-access-control">Missing function-level access control</h2>
<p>Distinct from IDOR: this is when an application correctly restricts what&rsquo;s shown in the UI for a given role, but doesn&rsquo;t enforce the same restriction on the backend endpoint itself. A standard user who can&rsquo;t see an admin panel in the interface can often still call the admin API endpoint directly if the endpoint itself doesn&rsquo;t independently verify the caller&rsquo;s role — the UI hid the button, but nothing stopped the request. Any access control decision made only in frontend code is not actually an access control decision.</p>
<h2 id="privilege-escalation-vertical-and-horizontal">Privilege escalation, vertical and horizontal</h2>
<p>Vertical escalation is a standard user gaining admin-level capability — often through the function-level gap above, or through a parameter that controls role or permission level and isn&rsquo;t validated server-side (a request body that includes <code>&quot;role&quot;: &quot;admin&quot;</code> and gets trusted rather than ignored). Horizontal escalation is accessing another user&rsquo;s data at the same privilege level — which is effectively IDOR viewed through a different lens, since both involve one user reaching resources that belong to another.</p>
<h2 id="cors-misconfiguration-as-an-access-control-failure">CORS misconfiguration as an access control failure</h2>
<p>A permissive CORS policy — particularly one that reflects an arbitrary request origin back as trusted, combined with credentialed requests — effectively extends your access control boundary to any site that can get a victim to load it, since the browser will now let that third-party origin make authenticated requests on the victim&rsquo;s behalf. This is access control failing at the browser trust boundary rather than the server logic, but it belongs in the same category because the end result is the same: someone accessing something they shouldn&rsquo;t.</p>
<h2 id="why-this-keeps-happening">Why this keeps happening</h2>
<p>Authentication has mature, well-tested standard patterns that most frameworks handle reasonably well by default. Object and function-level authorization is inherently application-specific — the framework can&rsquo;t know on its own whether the currently authenticated user should be allowed to see order #1025, because that depends entirely on your data model and business logic. This means authorization checks have to be deliberately written for each resource type and each endpoint, and any one that&rsquo;s missed is a gap, not a framework misconfiguration you can patch centrally.</p>
<h2 id="how-to-actually-prevent-it">How to actually prevent it</h2>
<p><strong>Deny by default, allow explicitly.</strong> Every endpoint and every resource access should require an explicit authorization check to succeed, rather than being open unless something blocks it. This flips the failure mode from &ldquo;silently overexposed&rdquo; to &ldquo;loudly broken,&rdquo; which is far easier to catch in testing.</p>
<p><strong>Enforce authorization server-side, every time, regardless of what the client already checked.</strong> Never trust a client-supplied value (a role field, a permission flag) without independently verifying it against the actual authenticated identity&rsquo;s real permissions on the server.</p>
<p><strong>Test authorization explicitly, not just authentication.</strong> A test suite that confirms &ldquo;unauthenticated users are rejected&rdquo; but never confirms &ldquo;user A cannot access user B&rsquo;s resources&rdquo; is testing less than half of what actually matters here. Include this as its own category of test, per resource type, not an afterthought bundled into general functional testing.</p>
<p><strong>Centralize authorization logic where possible.</strong> Scattering ad hoc permission checks across individual route handlers makes it easy for one to be forgotten. A centralized authorization layer or middleware that every request passes through, with each route declaring what it requires, reduces how many places the check has to be independently remembered and correctly implemented.</p>
<p>Broken access control isn&rsquo;t a single vulnerability to patch. It&rsquo;s a category that requires deliberate, per-resource enforcement — which is exactly why it remains the most common category in real-world findings even after years at the top of the list.</p>
]]></content:encoded></item><item><title>How API Rate Limiting Actually Works—and Where It Fails</title><link>https://firewallsync.com/posts/how-api-rate-limiting-actually-works-and-where-it-fails/</link><guid>https://firewallsync.com/posts/how-api-rate-limiting-actually-works-and-where-it-fails/</guid><pubDate>Wed, 14 Oct 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>Rate limiting is treated as a solved problem once it's implemented. In practice, the algorithm and the scope you choose determine whether it actually stops abuse or just adds latency to legitimate traffic.</description><enclosure url="https://firewallsync.com/posts/how-api-rate-limiting-actually-works-and-where-it-fails/cover_hu11562816909962696335.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/how-api-rate-limiting-actually-works-and-where-it-fails/cover_hu11562816909962696335.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/how-api-rate-limiting-actually-works-and-where-it-fails/cover_hu11562816909962696335.jpg" alt="Fiber optic cables connected to a network switch" width="1200" height="630"></p><p>Rate limiting is often treated as a solved problem the moment it&rsquo;s implemented — a number is picked, a library is wired in, and the box gets checked. In practice, the algorithm behind that limit and the scope it&rsquo;s applied at determine whether it actually stops abuse or just adds latency to legitimate traffic while doing little against a determined attacker.</p>
<h2 id="fixed-window-simple-and-exploitable-at-the-boundary">Fixed window: simple, and exploitable at the boundary</h2>
<p>The simplest approach counts requests in a fixed time window — for example, 100 requests per minute, reset every minute on the clock. It&rsquo;s cheap to implement and reason about, but it has a well-known edge-case flaw: a client can send 100 requests in the last second of one window and another 100 in the first second of the next, achieving 200 requests in roughly two seconds while technically staying within the stated limit for each window. For any rate limit meant to actually cap burst behavior, this boundary effect matters.</p>
<h2 id="sliding-window-closes-the-boundary-gap-costs-more-to-compute">Sliding window: closes the boundary gap, costs more to compute</h2>
<p>A sliding window log or sliding window counter evaluates the limit over a continuously moving time range rather than discrete fixed buckets, which removes the boundary exploit. The tradeoff is implementation cost — a true sliding log requires tracking individual request timestamps (or an approximation of them), which is more expensive to store and compute at scale than a simple fixed counter, especially across a distributed system where the limit needs to be enforced consistently across multiple servers.</p>
<h2 id="token-bucket-the-common-choice-for-allowing-controlled-bursts">Token bucket: the common choice for allowing controlled bursts</h2>
<p>A token bucket algorithm fills a bucket with tokens at a steady rate, and each request consumes one token; if the bucket is empty, the request is rejected or delayed. This naturally allows short bursts (as long as tokens have accumulated) while still enforcing an average rate over time, which better matches how real client traffic behaves — mostly steady, with occasional legitimate spikes — compared to a hard fixed-window cutoff. It&rsquo;s the algorithm most production API gateways default to for this reason.</p>
<h2 id="the-scope-you-apply-it-at-matters-as-much-as-the-algorithm">The scope you apply it at matters as much as the algorithm</h2>
<p>A global rate limit — capping total requests to an endpoint regardless of caller — protects your infrastructure from being overwhelmed, but does nothing to stop one abusive client from degrading service for every other legitimate caller sharing that same limit. Per-identity (authenticated user or API key) or per-IP limiting contains the impact of abuse to the specific source causing it, which is almost always the intent when rate limiting is framed as a security control rather than purely a capacity control.</p>
<p>The two aren&rsquo;t mutually exclusive — a global ceiling protects infrastructure, and a per-identity limit protects fairness and contains abuse, and most production systems need both layered together rather than picking one.</p>
<h2 id="where-rate-limiting-fails-even-when-correctly-implemented">Where rate limiting fails even when correctly implemented</h2>
<p><strong>Distributed attacks split under the limit.</strong> An attacker using many IP addresses or many low-volume accounts can stay under a per-identity or per-IP limit while still generating meaningful aggregate load or completing a credential-stuffing campaign slowly enough to avoid triggering any single bucket&rsquo;s threshold. Rate limiting alone doesn&rsquo;t solve this — it needs to be paired with anomaly detection that looks at aggregate patterns across identities, not just individual limits.</p>
<p><strong>Expensive endpoints need lower limits than uniform ones provide.</strong> A rate limit set uniformly across an entire API treats a cheap read endpoint the same as a computationally expensive report-generation endpoint, meaning the &ldquo;safe&rdquo; uniform limit for the expensive endpoint is often still high enough to cause real resource exhaustion. Limits should reflect the actual cost of the operation, not just be copied across endpoints for consistency.</p>
<p><strong>Legitimate retries can look like abuse, and vice versa.</strong> A client retrying after a transient failure, without backoff, can trip the same limit as an actual attacker, while a well-behaved distributed attack can stay just under it. Rate limiting is a blunt instrument for distinguishing intent — it should be one layer in a broader strategy that includes authentication, anomaly detection, and endpoint-specific cost awareness, not the sole control standing between an API and abuse.</p>
<h2 id="a-practical-approach">A practical approach</h2>
<ul>
<li>Use token bucket (or a sliding window if implementation cost allows) rather than fixed window, if burst behavior and boundary exploitation matter for your traffic</li>
<li>Apply limits both globally (infrastructure protection) and per-identity (abuse containment) rather than choosing one</li>
<li>Set limits per-endpoint based on actual computational cost, not a single number applied uniformly</li>
<li>Treat rate limiting as one layer, not the whole strategy — pair it with monitoring for distributed patterns that individually stay under any single limit</li>
</ul>
]]></content:encoded></item><item><title>The Most Common API Security Misconfigurations and How to Fix Them</title><link>https://firewallsync.com/posts/the-most-common-api-security-misconfigurations-and-how-to-fix-them/</link><guid>https://firewallsync.com/posts/the-most-common-api-security-misconfigurations-and-how-to-fix-them/</guid><pubDate>Tue, 13 Oct 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>API misconfigurations don't usually come from a missing feature. They come from a default that was never revisited once the API left the prototype stage.</description><enclosure url="https://firewallsync.com/posts/the-most-common-api-security-misconfigurations-and-how-to-fix-them/cover_hu3736700412211668445.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/the-most-common-api-security-misconfigurations-and-how-to-fix-them/cover_hu3736700412211668445.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/the-most-common-api-security-misconfigurations-and-how-to-fix-them/cover_hu3736700412211668445.jpg" alt="Monitor displaying program code" width="1200" height="630"></p><p>API misconfigurations rarely come from a missing security feature. They come from a default that was fine during prototyping and never got revisited once the API left that stage and started handling real traffic. These are the ones that show up most often in production audits.</p>
<h2 id="verbose-error-messages-leaking-implementation-detail">Verbose error messages leaking implementation detail</h2>
<p>A stack trace or a raw database error returned in an API response is convenient during development and a reconnaissance gift in production — it can reveal database structure, internal file paths, library versions, and sometimes even query fragments that hint at how authorization is implemented. Fix: catch exceptions at the API boundary and return generic error responses to the client, while logging the full detail server-side where it&rsquo;s actually useful.</p>
<h2 id="cors-configured-with-a-wildcard-that-shouldnt-be-there">CORS configured with a wildcard that shouldn&rsquo;t be there</h2>
<p><code>Access-Control-Allow-Origin: *</code> combined with credentialed requests is a common and dangerous misconfiguration — though browsers block this specific combination by spec, teams often work around it in ways that reintroduce the risk, such as dynamically reflecting whatever <code>Origin</code> header the request sends back as an allowed origin. That pattern effectively disables the same-origin protection CORS exists to provide. Fix: maintain an explicit allowlist of trusted origins rather than reflecting the request&rsquo;s own origin back as valid.</p>
<h2 id="debug-or-admin-endpoints-left-reachable-in-production">Debug or admin endpoints left reachable in production</h2>
<p>Endpoints added for local debugging — a <code>/debug</code>, <code>/admin</code>, <code>/internal</code>, or framework-default status page — frequently ship to production because they were never explicitly gated behind an environment check. These often expose configuration values, internal routing information, or unauthenticated administrative actions. Fix: gate anything not meant for public use behind environment-specific configuration, and treat any endpoint reachable without authentication as something that needs an explicit justification, not a default.</p>
<h2 id="missing-or-misconfigured-security-headers">Missing or misconfigured security headers</h2>
<p>Headers like <code>Strict-Transport-Security</code>, <code>X-Content-Type-Options</code>, and a sensible <code>Content-Security-Policy</code> are cheap to set and commonly skipped on API responses specifically, because teams associate them with browser-rendered pages rather than JSON endpoints — even though many APIs are consumed by browser-based clients that benefit from them. Fix: apply a consistent security header baseline at the gateway or middleware level so it isn&rsquo;t something each new endpoint has to remember individually.</p>
<h2 id="default-credentials-or-default-configuration-left-unchanged">Default credentials or default configuration left unchanged</h2>
<p>Databases, caches, and internal services that back an API frequently ship with default credentials or open-by-default network configuration, and these get exposed when the API sits in front of infrastructure that was configured quickly and never hardened afterward. Fix: treat default credentials as a deployment blocker, not a post-launch cleanup item, and audit backing infrastructure specifically for anything still running default configuration.</p>
<h2 id="overly-permissive-http-method-support">Overly permissive HTTP method support</h2>
<p>An endpoint built to handle <code>GET</code> requests that also silently accepts <code>PUT</code>, <code>DELETE</code>, or <code>PATCH</code> because the framework&rsquo;s routing defaults allow it — without anyone deliberately deciding those methods should be supported — creates unintended write or delete paths that were never designed or reviewed. Fix: explicitly declare which HTTP methods each endpoint supports and reject the rest, rather than relying on the framework&rsquo;s default permissiveness.</p>
<h2 id="inconsistent-authentication-enforcement-across-the-api-surface">Inconsistent authentication enforcement across the API surface</h2>
<p>A common pattern in APIs that grow organically: authentication is enforced on primary endpoints but missed on newer, secondary, or auto-generated ones — an admin-added endpoint for a reporting feature, a webhook receiver, an internal utility route. Fix: enforce authentication centrally, as a default every route must explicitly opt out of, rather than something each route has to remember to opt into.</p>
<h2 id="why-these-accumulate">Why these accumulate</h2>
<p>Every one of these starts as a reasonable shortcut at some point in development — a permissive CORS setting to unblock local frontend testing, a debug endpoint to speed up troubleshooting, verbose errors to make development faster. None of them are wrong choices during prototyping. They become misconfigurations specifically because nobody owns the step of reverting them before the API is exposed publicly, which is why a pre-launch (and periodic post-launch) configuration review, treated as its own explicit step, catches most of this list before it becomes an incident.</p>
]]></content:encoded></item><item><title>Cosign Container Image Signing: Keyless Signing in CI and Why Verification Must Pin an Identity</title><link>https://firewallsync.com/posts/cosign-container-image-signing-keyless-signing-in-ci-and-why-verification-must-pin-an-identity/</link><guid>https://firewallsync.com/posts/cosign-container-image-signing-keyless-signing-in-ci-and-why-verification-must-pin-an-identity/</guid><pubDate>Tue, 13 Oct 2026 06:06:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>Signing a container image with Sigstore's cosign takes one command; the security comes from what the verifier insists on. Here is how keyless signing works in a GitHub Actions job, what to pin when verifying, and what an August 2026 cosign advisory shows about trusting the verifier.</description><enclosure url="https://firewallsync.com/posts/cosign-container-image-signing-keyless-signing-in-ci-and-why-verification-must-pin-an-identity/cover_hu8055220339597418561.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/cosign-container-image-signing-keyless-signing-in-ci-and-why-verification-must-pin-an-identity/cover_hu8055220339597418561.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/cosign-container-image-signing-keyless-signing-in-ci-and-why-verification-must-pin-an-identity/cover_hu8055220339597418561.jpg" alt="Close-up of the double doors of a weathered blue shipping container, with a small padlock hanging from the latch on the right door" width="1200" height="630"></p><p>Signing a container image with <strong>cosign</strong>, the command-line client of the open-source Sigstore project, is a one-line command. Whether the signature protects anyone depends on the other end: the verifier has to say <em>who</em> must have signed the image and <em>which identity provider</em> must have vouched for them. This article walks through keyless signing in a GitHub Actions job, what to pin when verifying, and why an August 2026 advisory about cosign&rsquo;s own verification is a good reminder that the verifier is part of your trust chain.</p>
<p>We read the Sigstore documentation, the cosign command reference, the <code>sigstore/cosign-installer</code> README, GitHub&rsquo;s documentation on its OIDC tokens, the cosign release notes and the cosign security advisory on October 13, 2026. We did not run any of the commands for this article, so it is research-based explanation, not hands-on testing. Placeholders in angle brackets are for you to fill in.</p>
<h2 id="the-short-answer">The short answer</h2>
<ul>
<li><strong>Sign:</strong> <code>cosign sign &lt;image&gt;@&lt;digest&gt;</code> in your release job. In keyless mode no key pair is stored: cosign gets a short-lived certificate for the job&rsquo;s OIDC identity.</li>
<li><strong>Verify:</strong> run <code>cosign verify</code> with <code>--certificate-identity</code> and <code>--certificate-oidc-issuer</code> set to the identity and issuer you expect, in the place where images are deployed or pulled.</li>
<li><strong>Keep cosign patched</strong> on both signing and verifying machines. The advisory below shows verification code can itself have bugs.</li>
</ul>
<h2 id="what-keyless-signing-does">What keyless signing does</h2>
<p>Per the <a href="https://docs.sigstore.dev/cosign/signing/overview/">Sigstore cosign documentation</a>, keyless signing &ldquo;associates identities, rather than keys, with an artifact signature.&rdquo; Cosign uses an ephemeral key; the page notes the private key is destroyed shortly after signing. <strong>Fulcio</strong>, Sigstore&rsquo;s certificate authority, &ldquo;issues short-lived certificates binding an ephemeral key to an OpenID Connect identity.&rdquo; The signing event is logged in <strong>Rekor</strong>, a signature transparency log, &ldquo;providing an auditable record.&rdquo; Our article on <a href="/posts/slsa-build-levels-and-provenance-attestations-what-they-prove-and-what-they-dont/">SLSA build levels and provenance</a> describes the same flow in the context of attestations.</p>
<p>In an automated environment cosign can take an OIDC token directly via <code>--identity-token</code>; the documentation says the token&rsquo;s audiences claim must contain <code>sigstore</code>. In GitHub Actions, the cosign installer&rsquo;s example relies on the job&rsquo;s GitHub OIDC token instead.</p>
<h2 id="signing-in-github-actions">Signing in GitHub Actions</h2>
<p>The <a href="https://github.com/sigstore/cosign-installer"><code>sigstore/cosign-installer</code> README</a> provides an example workflow. The elements that matter for security are:</p>
<ul>
<li><strong>Permissions.</strong> The job needs <code>id-token: write</code> (annotated in the example as &ldquo;needed for signing the images with GitHub OIDC Token&rdquo;). GitHub&rsquo;s <a href="https://docs.github.com/en/actions/reference/security/oidc">OIDC documentation</a> states that without <code>id-token: write</code> the OIDC JWT ID token cannot be requested. The example also grants <code>packages: write</code> for pushing to <code>ghcr.io</code> and <code>contents: read</code>.</li>
<li><strong>Sign by digest.</strong> The example builds and pushes the image, takes the digest from the build step&rsquo;s output and signs <code>tag@digest</code> references. We read this as the reason the digest matters: a tag can later be re-pointed to a different image, while a digest identifies specific content. (The cosign signing page we read does not itself state a digest recommendation; this is our reading of the example.)</li>
<li><strong>No secret for keyless.</strong> The README&rsquo;s keyless step runs <code>cosign sign --yes ${images}</code> with no key. Its other example signs with a key held in repository secrets, which brings back the problem of protecting a long-lived key.</li>
<li><strong>Untrusted values in shell.</strong> The example passes values through environment variables rather than inline expressions, following GitHub&rsquo;s guidance on script injection.</li>
<li><strong>Pin the installer.</strong> The README says the <code>cosign-release</code> input pins a specific cosign version and that you then maintain that version yourself. Pinning also applies to the installer action: the example references <code>sigstore/cosign-installer@v4.1.0</code>; for stronger protection against a moved tag you can pin actions to a full commit SHA (our suggestion, not from the README).</li>
</ul>
<p>The signature is stored with the image in the registry. The signing documentation says signatures use the OCI 1.1 referrer specification, and <code>cosign tree &lt;image&gt;</code> shows what is attached. Your registry must support what cosign pushes; we did not test registry compatibility.</p>
<h2 id="verifying-pin-the-identity-and-the-issuer">Verifying: pin the identity and the issuer</h2>
<p>This is the step that turns a signature into a control. For keyless signatures, the <a href="https://github.com/sigstore/cosign/blob/main/doc/cosign_verify.md">cosign command reference</a> says the identity flags are mandatory:</p>
<pre tabindex="0"><code>cosign verify &lt;registry&gt;/&lt;repo&gt;@&lt;digest&gt; \
  --certificate-identity=&lt;expected signer identity&gt; \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com
</code></pre><p>What each flag means, per the reference:</p>
<table>
  <thead>
      <tr>
          <th>Flag</th>
          <th>Meaning in the reference</th>
          <th>What to put there</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>--certificate-identity</code></td>
          <td>The identity expected in a valid Fulcio certificate; valid values include email addresses, DNS names, IP addresses and URIs. One of this or <code>--certificate-identity-regexp</code> must be set for keyless flows</td>
          <td>The identity of your release workflow (see below)</td>
      </tr>
      <tr>
          <td><code>--certificate-oidc-issuer</code></td>
          <td>The OIDC issuer expected in the certificate; the reference gives <code>https://token.actions.githubusercontent.com</code> as an example. One of this or <code>--certificate-oidc-issuer-regexp</code> must be set</td>
          <td>For GitHub Actions: <code>https://token.actions.githubusercontent.com</code></td>
      </tr>
  </tbody>
</table>
<p><strong>Exact vs regexp.</strong> The regexp variants accept Go regular expressions. A loose pattern (for example one that matches any repository in an organization, or any branch) widens who can satisfy your policy. Prefer exact values where you can; if you must use a pattern, anchor it. This is our recommendation.</p>
<p><strong>What identity string to expect.</strong> We did not verify, from the sources above, the exact format of the identity that appears in a certificate issued to a GitHub Actions job, so we do not state one here. GitHub&rsquo;s OIDC documentation defines claims in the <em>token</em> (for example the <code>sub</code> claim, formatted like <code>repo:ORG/REPO:ref:refs/heads/BRANCH</code> in its examples, and <code>job_workflow_ref</code> for reusable workflows), but how those claims map into the Fulcio certificate identity is not something we confirmed. A reasonable procedure (our suggestion): sign a test image from your real release workflow, inspect the certificate on that signature, record the identity and issuer you see there, and then pin those exact values.</p>
<p><strong>What a passing check tells you.</strong> That the image content matches the signed digest and that a signer with the pinned identity signed it, with the signing recorded in the transparency log. It does not tell you the image is free of vulnerabilities or malicious code, or that the workflow that signed it was itself uncompromised. For vulnerability status, see our articles on <a href="/posts/vex-explained-what-a-not-affected-statement-means-and-how-to-read-one/">VEX statements</a> and <a href="/posts/container-image-scanning-what-ci-pipelines-still-miss/">what container image scanning still misses</a>.</p>
<p>Where to run it: at the point of consumption, such as a deploy job or an admission step in your cluster, failing the deployment on mismatch. The cosign reference documents the command-line flags; we did not review Kubernetes admission tooling, so that part is outside this article.</p>
<h2 id="a-reminder-that-the-verifier-needs-patching-too">A reminder that the verifier needs patching too</h2>
<p>On August 6, 2026, the cosign project published advisory <a href="https://github.com/sigstore/cosign/security/advisories/GHSA-fx35-mq7g-6g98">GHSA-fx35-mq7g-6g98</a>, &ldquo;Verification bypass via public key in legacy bundle.&rdquo; The facts as stated in the advisory:</p>
<table>
  <thead>
      <tr>
          <th>Item</th>
          <th>Detail</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Severity</td>
          <td>High, CVSS 7.4 (vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:H/A:N)</td>
      </tr>
      <tr>
          <td>CVE</td>
          <td>None assigned (&ldquo;No known CVE&rdquo;)</td>
      </tr>
      <tr>
          <td>Affected commands</td>
          <td><code>cosign verify-blob</code> and <code>cosign verify-blob-attestation</code>, when given a legacy JSON <code>--bundle</code></td>
      </tr>
      <tr>
          <td>Affected versions</td>
          <td>cosign v2.6.4 and earlier; cosign v3.1.2 and earlier</td>
      </tr>
      <tr>
          <td>Patched versions</td>
          <td>v2.6.5 and v3.1.3</td>
      </tr>
      <tr>
          <td>Mechanism</td>
          <td>If the bundle&rsquo;s <code>cert</code> field is not a valid X.509 certificate, cosign treated it as a raw public key; certificate chain and identity checks were skipped, so <code>--certificate-identity</code> and <code>--certificate-oidc-issuer</code> were ignored</td>
      </tr>
      <tr>
          <td>Not affected</td>
          <td>The protobuf bundle format and OCI image verification, per the advisory</td>
      </tr>
      <tr>
          <td>Workaround</td>
          <td>Upgrade; or migrate to the standardized protobuf bundle format (<code>--new-bundle-format</code>); <code>cosign bundle create</code> can convert existing bundles</td>
      </tr>
  </tbody>
</table>
<p><strong>What this means for container image signing.</strong> The advisory says OCI image verification is not affected, so a pipeline that only runs <code>cosign verify</code> on images in a registry is outside the described bug. It matters if you also sign and verify <strong>blobs</strong>, such as release binaries, tarballs or SBOM files, using separate bundle files. The general lesson is practical: identity pinning only helps if the verifier actually enforces it, so track cosign releases on every machine that verifies. The cosign <a href="https://github.com/sigstore/cosign/releases">releases page</a> lists v3.1.3 and v2.6.5 as the August 6, 2026 releases that carry the fix. The same page records a bundle-format change in the 2.6 line: its notes say the default for signing is now the bundle format, and that <code>--new-bundle-format=false</code> signs in the old format; verification in 3.1.x accepts both formats. Check the release notes for your version before changing flags.</p>
<h2 id="a-rollout-checklist-our-suggestion">A rollout checklist (our suggestion)</h2>
<ol>
<li>Sign only release images from a protected workflow, by digest.</li>
<li>Give the signing job only the permissions it needs: <code>id-token: write</code>, plus whatever the registry push requires.</li>
<li>Sign a test image, inspect the certificate, and record the exact identity and issuer to pin.</li>
<li>Verify wherever the image is deployed, with exact identity and issuer flags, and fail closed.</li>
<li>Pin and update cosign deliberately on signing and verifying hosts; subscribe to the project&rsquo;s security advisories.</li>
<li>If you also use blob signatures, confirm none depend on legacy JSON bundles on affected versions.</li>
<li>Remember what signing does not cover: see our <a href="/posts/sbom-minimum-elements-2026-what-cisa-and-partners-changed-from-the-2021-ntia-baseline/">SBOM minimum-elements</a> article for the inventory side.</li>
</ol>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We did not run cosign, sign or verify anything, or test any registry. The cosign command reference we cite is the <code>main</code> branch of the project&rsquo;s repository, which may differ from the version you run; the Sigstore signing page we read did not discuss differences between cosign v2 and v3. We did not confirm the exact certificate-identity format for GitHub Actions jobs, did not review the Sigstore Public Good Instance&rsquo;s operational policies or Kubernetes admission tooling, and did not independently re-score the advisory&rsquo;s CVSS vector.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 13, 2026.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Keyless signing associates identities rather than keys; ephemeral key destroyed; Fulcio issues short-lived certificates; Rekor logs signing; <code>--identity-token</code> audience must contain <code>sigstore</code></td>
          <td><a href="https://docs.sigstore.dev/cosign/signing/overview/">Sigstore, cosign signing overview</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>cosign sign &lt;image&gt;</code> (keyless) and <code>--key</code> signing; signatures use the OCI 1.1 referrer specification; <code>cosign tree</code></td>
          <td><a href="https://docs.sigstore.dev/cosign/signing/signing_with_containers/">Sigstore, signing containers</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Workflow needs <code>id-token: write</code>; example signs <code>tag@digest</code> with <code>cosign sign --yes</code>; <code>cosign-release</code> pinning; installer example version v4.1.0</td>
          <td><a href="https://github.com/sigstore/cosign-installer">sigstore/cosign-installer README</a></td>
          <td>Verified: example versions may change</td>
      </tr>
      <tr>
          <td>Without <code>id-token: write</code> the OIDC token cannot be requested; GitHub issuer URL</td>
          <td><a href="https://docs.github.com/en/actions/reference/security/oidc">GitHub Docs, OIDC reference</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Keyless verification requires <code>--certificate-identity</code> or its regexp form and <code>--certificate-oidc-issuer</code> or its regexp form</td>
          <td><a href="https://github.com/sigstore/cosign/blob/main/doc/cosign_verify.md">cosign verify reference</a></td>
          <td>Qualified: <code>main</code> branch documentation</td>
      </tr>
      <tr>
          <td>Advisory GHSA-fx35-mq7g-6g98: published August 6, 2026; High, CVSS 7.4; no CVE; affected and patched versions; affected commands; OCI image verification not affected</td>
          <td><a href="https://github.com/sigstore/cosign/security/advisories/GHSA-fx35-mq7g-6g98">GitHub advisory</a></td>
          <td>Verified: score as published by the project</td>
      </tr>
      <tr>
          <td>v3.1.3 and v2.6.5 released August 6, 2026 with the fix; bundle-format default note</td>
          <td><a href="https://github.com/sigstore/cosign/releases">cosign releases</a></td>
          <td>Qualified: page shows month and day only; year inferred from asset timestamps</td>
      </tr>
      <tr>
          <td>Digest rationale, exact-vs-regexp advice, certificate-inspection procedure, rollout checklist</td>
          <td>Our analysis</td>
          <td>Qualified: editorial judgment, labelled as such</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>How to Secure a Public REST API: A Production Checklist</title><link>https://firewallsync.com/posts/how-to-secure-a-public-rest-api-a-production-checklist/</link><guid>https://firewallsync.com/posts/how-to-secure-a-public-rest-api-a-production-checklist/</guid><pubDate>Mon, 12 Oct 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>Most public API breaches don't come from a novel attack technique. They come from a well-known control that was never actually implemented before launch.</description><enclosure url="https://firewallsync.com/posts/how-to-secure-a-public-rest-api-a-production-checklist/cover_hu1974222179737339066.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/how-to-secure-a-public-rest-api-a-production-checklist/cover_hu1974222179737339066.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/how-to-secure-a-public-rest-api-a-production-checklist/cover_hu1974222179737339066.jpg" alt="Computer screen with code" width="1200" height="630"></p><p>Most public API incidents don&rsquo;t trace back to a sophisticated or novel technique. They trace back to a well-known control — rate limiting, object-level authorization, input validation — that got deprioritized before launch and never circled back to. This is a checklist of the controls that actually matter, ordered roughly by how often skipping them causes real incidents.</p>
<h2 id="authentication-and-object-level-authorization">Authentication and object-level authorization</h2>
<p>Authenticating a request confirms who&rsquo;s calling. It says nothing about whether that caller should be allowed to access the specific resource they&rsquo;re requesting. Broken object-level authorization — where an authenticated user can access another user&rsquo;s data by changing an ID in the request — remains one of the most common and most exploited API vulnerabilities, precisely because authentication is easy to test and access control on individual resources is easy to forget on any endpoint that wasn&rsquo;t the main focus during development.</p>
<p>Every endpoint that accepts a resource identifier needs an explicit check that the authenticated caller is actually authorized for that specific resource, not just authenticated in general. This has to be enforced server-side, on every request — never assume that because a UI doesn&rsquo;t expose a way to request another user&rsquo;s data, an API consumer can&rsquo;t construct that request directly.</p>
<h2 id="rate-limiting-applied-per-identity-and-per-endpoint">Rate limiting, applied per-identity and per-endpoint</h2>
<p>An API without rate limiting is vulnerable to brute-force credential attacks, scraping, and simple resource exhaustion, regardless of how solid the rest of the security posture is. Rate limits need to be scoped per authenticated identity (or per IP for unauthenticated endpoints), not globally — a global limit lets one abusive client degrade service for everyone else, while a per-identity limit contains the damage to that one caller.</p>
<p>Endpoints that are inherently more sensitive — login, password reset, any action that&rsquo;s expensive to compute or has real-world side effects — deserve tighter limits than general read endpoints, rather than one uniform rate limit applied everywhere.</p>
<h2 id="input-validation-on-every-parameter-not-just-the-obvious-ones">Input validation on every parameter, not just the obvious ones</h2>
<p>Validate types, formats, and ranges server-side for every input, including ones that feel like internal implementation details — pagination parameters, sort fields, filter values. These are frequently the fields that get skipped because they don&rsquo;t look security-sensitive, and they&rsquo;re also where injection and denial-of-service issues (an attacker requesting an enormous page size, or a sort field that maps directly to an unvalidated database column) commonly show up.</p>
<p>Reject unexpected fields rather than silently ignoring them — an API that quietly accepts and processes fields outside its documented schema makes it harder to reason about what the actual attack surface is.</p>
<h2 id="dont-expose-more-data-than-the-response-needs">Don&rsquo;t expose more data than the response needs</h2>
<p>A common pattern that causes quiet data exposure: returning an entire internal object or database row because it was convenient, rather than an explicitly defined response schema. This routinely leaks internal fields — hashed passwords, internal flags, other users&rsquo; identifiers embedded in nested objects — that were never meant to be public, simply because nobody defined what should and shouldn&rsquo;t be in the response.</p>
<h2 id="tls-and-actually-enforcing-it">TLS, and actually enforcing it</h2>
<p>HTTPS-only sounds obvious, but &ldquo;supports HTTPS&rdquo; and &ldquo;rejects plaintext HTTP entirely&rdquo; are different configurations, and it&rsquo;s common for an API to accept both silently, quietly downgrading security for any client (or misconfigured integration) that happens to connect over HTTP. Enforce it at the load balancer or gateway level, not as an assumption about how clients will behave.</p>
<h2 id="logging-and-monitoring-built-for-incident-response-not-just-uptime">Logging and monitoring built for incident response, not just uptime</h2>
<p>Standard uptime and error-rate monitoring won&rsquo;t catch a slow, low-and-slow scraping attack or a credential-stuffing campaign hiding inside otherwise-normal traffic patterns. Log enough detail — caller identity, endpoint, response code, timestamp — to reconstruct what happened during an investigation, and specifically monitor for patterns like unusual volumes of authorization failures or an unusual spread of resource IDs being requested by a single identity.</p>
<h2 id="the-checklist">The checklist</h2>
<ul>
<li>Object-level authorization enforced server-side on every resource-accessing endpoint, not just authentication</li>
<li>Rate limits scoped per identity and tightened further on sensitive endpoints</li>
<li>Server-side validation on every input parameter, including pagination, sorting, and filtering fields</li>
<li>Explicit response schemas, never raw internal objects</li>
<li>HTTPS enforced, not just supported</li>
<li>Logging detailed enough to reconstruct an incident after the fact</li>
</ul>
<p>None of these are exotic. They&rsquo;re the controls that get cut when a launch deadline is tight, on the assumption they can be added later — and &ldquo;later&rdquo; is usually after the first incident that specifically exploited whichever one got skipped.</p>
]]></content:encoded></item><item><title>SLSA Build Levels and Provenance Attestations: What They Prove and What They Don't</title><link>https://firewallsync.com/posts/slsa-build-levels-and-provenance-attestations-what-they-prove-and-what-they-dont/</link><guid>https://firewallsync.com/posts/slsa-build-levels-and-provenance-attestations-what-they-prove-and-what-they-dont/</guid><pubDate>Mon, 12 Oct 2026 06:10:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>SLSA Build Level 1, 2 and 3 describe how trustworthy a build's provenance is, not whether the code is safe. Here is what each level promises, how Sigstore and GitHub artifact attestations fit in, and how to verify an attestation with the GitHub CLI.</description><enclosure url="https://firewallsync.com/posts/slsa-build-levels-and-provenance-attestations-what-they-prove-and-what-they-dont/cover_hu17136422527361664766.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/slsa-build-levels-and-provenance-attestations-what-they-prove-and-what-they-dont/cover_hu17136422527361664766.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/slsa-build-levels-and-provenance-attestations-what-they-prove-and-what-they-dont/cover_hu17136422527361664766.jpg" alt="Three cream envelopes, each closed with a bronze wax seal stamped with a tree design, on a white surface" width="1200" height="630"></p><p>SLSA, pronounced &ldquo;salsa&rdquo; and short for Supply-chain Levels for Software Artifacts, is a framework for describing how much you can trust the way a software artifact was built. A SLSA <strong>Build level</strong> (L1, L2 or L3) is a statement about the build platform and the <strong>provenance</strong> it produces: a signed record of what was built, from what inputs, by which builder. It is not a statement that the software is free of bugs or backdoors. This article explains what each level means, how Sigstore signing and GitHub artifact attestations produce provenance, and what a consumer has to check for any of it to matter.</p>
<p>We read the SLSA specification pages (v1.2 and the retired v1.1), the Sigstore overview, GitHub&rsquo;s attestation documentation and the GitHub CLI manual on October 12, 2026. We did not build or sign any artifacts for this article; it is research-based explanation, not hands-on testing.</p>
<h2 id="the-short-answer">The short answer</h2>
<ul>
<li><strong>Build L1:</strong> provenance exists. It records how the package was built, but the specification says it &ldquo;can be used to prevent mistakes but is trivial to bypass or forge.&rdquo;</li>
<li><strong>Build L2:</strong> the build runs on a hosted platform that generates and signs the provenance. This addresses tampering after the build. Forging the provenance requires an explicit attack, which the specification notes may still be easy.</li>
<li><strong>Build L3:</strong> the platform is hardened so that runs cannot influence one another and the signing secret is unreachable from user-defined build steps. This addresses tampering during the build.</li>
</ul>
<p>Source: <a href="https://slsa.dev/spec/v1.2/build-track-basics">SLSA v1.2, Build track basics</a>.</p>
<h2 id="what-provenance-contains">What &ldquo;provenance&rdquo; contains</h2>
<p>Provenance is a document (in SLSA&rsquo;s case an <em>attestation</em>, a signed statement about an artifact) linking an artifact&rsquo;s cryptographic digest to a description of how it was built. The SLSA provenance format uses the predicate type <code>https://slsa.dev/provenance/v1</code>. For Build L1, the specification requires the following fields:</p>
<table>
  <thead>
      <tr>
          <th>Field</th>
          <th>What it holds</th>
          <th>Notes from the specification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>buildDefinition.buildType</code></td>
          <td>A URI naming the build template</td>
          <td>Should resolve to a human-readable specification</td>
      </tr>
      <tr>
          <td><code>buildDefinition.externalParameters</code></td>
          <td>Inputs the user controls</td>
          <td>Must be complete at L3; consumers should reject unexpected fields</td>
      </tr>
      <tr>
          <td><code>runDetails.builder.id</code></td>
          <td>A URI identifying the trusted build platform</td>
          <td>Intended to determine the SLSA Build level the consumer assigns</td>
      </tr>
  </tbody>
</table>
<p>Optional fields include <code>resolvedDependencies</code> (for example, the exact git commit a repository reference resolved to), <code>internalParameters</code> set by the platform, and run metadata such as start and finish times. These field requirements are from the v1.1 provenance page; that page is marked retired in favour of v1.2, and the v1.2 pages we read did not mention changes to the provenance format.</p>
<h2 id="why-the-levels-differ">Why the levels differ</h2>
<p>The levels are not about better paperwork. Each answers a different attacker.</p>
<table>
  <thead>
      <tr>
          <th>Level</th>
          <th>Who produces the provenance</th>
          <th>Main threat addressed</th>
          <th>What it does not stop</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>L1</td>
          <td>Build platform, possibly unsigned and incomplete</td>
          <td>Mistakes; missing documentation</td>
          <td>Anyone who simply edits or forges the record</td>
      </tr>
      <tr>
          <td>L2</td>
          <td>Hosted build platform, which signs it</td>
          <td>Tampering after the build</td>
          <td>A build run that is itself manipulated; a tenant who can reach the signing key</td>
      </tr>
      <tr>
          <td>L3</td>
          <td>Hardened hosted platform</td>
          <td>Tampering during the build, including by other tenants or insiders</td>
          <td>Malicious or vulnerable source; compromised dependencies</td>
      </tr>
  </tbody>
</table>
<p>Level descriptions are from the SLSA <a href="https://slsa.dev/spec/v1.2/build-track-basics">v1.2 build track basics</a> and <a href="https://slsa.dev/spec/v1.2/build-requirements">build requirements</a>. The &ldquo;does not stop&rdquo; column is our reading of what each level&rsquo;s requirements leave out; the specification itself defines the levels by what they require. The L3 requirements include that it &ldquo;MUST NOT be possible for one build to persist or influence the build environment of a subsequent build,&rdquo; and that provenance be &ldquo;strongly resistant to forgery by tenants.&rdquo;</p>
<h2 id="what-changed-in-slsa-v12">What changed in SLSA v1.2</h2>
<p>SLSA v1.0 removed source-code requirements to focus on the Build track. According to the v1.2 &ldquo;What&rsquo;s new&rdquo; page, v1.2 adds a <strong>Source track</strong> that helps organizations secure their source development process, updates the threat model, and reorganizes the specification to support multiple tracks. The page gives no release date. This article covers the Build track only; we did not review the Source track requirements. The v1.1 pages are marked &ldquo;Retired&rdquo; and point to v1.2 as current.</p>
<h2 id="how-sigstore-produces-the-signature">How Sigstore produces the signature</h2>
<p>Sigstore is an open project for signing and verifying software. In its keyless flow, according to the <a href="https://docs.sigstore.dev/about/overview/">Sigstore overview</a>:</p>
<ol>
<li><strong>Cosign</strong>, the client, generates a new key pair.</li>
<li>It sends the public key and an OpenID Connect (OIDC) identity token to <strong>Fulcio</strong>, the certificate authority.</li>
<li>Fulcio checks the token and issues a short-lived certificate binding the identity to the public key. (The page does not give a lifetime.)</li>
<li>Cosign signs the artifact, then discards the private key.</li>
<li>The artifact digest, signature and certificate are recorded in <strong>Rekor</strong>, an append-only transparency log.</li>
<li>A verifier checks the signature, confirms the certificate&rsquo;s identity is the one it expects, validates the certificate against Sigstore&rsquo;s root of trust, and verifies Rekor&rsquo;s proof of inclusion.</li>
</ol>
<p>For CI signing, the identity in the certificate can describe the workflow rather than a person. The practical point: no long-lived signing key exists to steal, but the signature is only meaningful if the verifier decides <em>which identity</em> is acceptable.</p>
<h2 id="github-artifact-attestations-and-the-slsa-level">GitHub artifact attestations and the SLSA level</h2>
<p>GitHub&rsquo;s <a href="https://docs.github.com/en/actions/concepts/security/artifact-attestations">documentation</a> says artifact attestations are signed claims about where and how software was built, including links to the workflow, repository, organization, environment, commit SHA and triggering event, and that they can include an SBOM. It states:</p>
<ul>
<li>Attestations alone provide <strong>SLSA v1.0 Build Level 2</strong>. Build Level 3 requires builds to run in a <strong>reusable workflow</strong> shared across repositories, which isolates the build from the calling workflow.</li>
<li>Public repositories use the Sigstore Public Good Instance, with the bundle written to a publicly readable transparency log. Private repositories use GitHub&rsquo;s own Sigstore instance, with no transparency log, federating only with GitHub Actions.</li>
<li>Attestations are not a guarantee that an artifact is secure; you still need to define and evaluate a policy.</li>
<li>Sign released software people will run or download, not frequent test builds or individual source files.</li>
</ul>
<p>Note the version: GitHub&rsquo;s page refers to SLSA v1.0 levels. We did not check whether GitHub has published a mapping to v1.2.</p>
<h2 id="verifying-the-step-most-teams-skip">Verifying: the step most teams skip</h2>
<p>Provenance nobody checks protects nobody. The GitHub CLI&rsquo;s <a href="https://cli.github.com/manual/gh_attestation_verify"><code>gh attestation verify</code></a> takes an artifact and requires at least one of <code>--owner</code> or <code>--repo</code>; the manual says that &ldquo;the more precisely you specify the actor&rsquo;s identity, the stronger the verification guarantees.&rdquo; It checks the actor identity against the certificate and the predicate type, which defaults to <code>https://slsa.dev/provenance/v1</code>.</p>
<pre tabindex="0"><code>gh attestation verify example.bin --repo github/example
gh attestation verify example.bin --owner github --signer-repo actions/example
</code></pre><p>(Both are examples from the manual; <code>github/example</code> is a placeholder.) Useful flags documented in the manual:</p>
<ul>
<li><code>--signer-workflow</code> or <code>--signer-repo</code>: needed when a reusable workflow produced the attestation.</li>
<li><code>--deny-self-hosted-runners</code>: fails verification for attestations generated on self-hosted runners.</li>
<li><code>--bundle</code>: verify offline from stored attestations.</li>
<li><code>--format json</code>: machine-readable output. The manual warns that only <code>signature.certificate</code> and <code>verifiedTimestamps</code> are tamper-resistant, and that <code>statement.predicate</code> can be falsified if an attacker controls the workflow, so policies should not rely on it alone.</li>
</ul>
<p>That last warning is worth sitting with. If an attacker controls the build workflow, the provenance they get signed is truthful about <em>which workflow ran</em>, not about the workflow being benign. This is why pinning the expected signer workflow matters more than reading the contents.</p>
<h2 id="what-provenance-does-not-prove">What provenance does not prove</h2>
<p>These are our conclusions from the framework&rsquo;s definitions, not claims the specification makes in these words:</p>
<ul>
<li><strong>That the source is trustworthy.</strong> Provenance links an artifact to a commit; it says nothing about whether the commit is malicious. Source controls are what the Source track is for.</li>
<li><strong>That dependencies are safe.</strong> Provenance records inputs; it does not vet them. See our guides to <a href="/posts/dependency-audits-without-slowing-down-releases/">dependency audits without slowing releases</a> and to what <a href="/posts/container-image-scanning-what-ci-pipelines-still-miss/">container image scanning still misses</a>.</li>
<li><strong>That the software has no known vulnerabilities.</strong> For that you need an inventory (<a href="/posts/sbom-minimum-elements-2026-what-cisa-and-partners-changed-from-the-2021-ntia-baseline/">SBOM</a>) and prioritization (<a href="/posts/cvss-vs-epss-vs-kev-which-signal-should-decide-what-you-patch-first/">CVSS, EPSS and KEV</a>).</li>
<li><strong>That the build platform itself is uncompromised.</strong> The level is only as good as the platform&rsquo;s isolation.</li>
</ul>
<h2 id="a-practical-adoption-order-our-suggestion">A practical adoption order (our suggestion)</h2>
<ol>
<li>Produce attestations for released artifacts only, on a hosted builder (this reaches L2 on GitHub per its documentation).</li>
<li>Write down the expected identity for each artifact: repository, and signer workflow if you use a reusable workflow.</li>
<li>Verify in the place where artifacts are consumed (deployment pipeline or admission step), failing closed on mismatch.</li>
<li>Move high-value artifacts to a reusable workflow to target L3, and test that your verification command pins that workflow.</li>
<li>Treat provenance as one control alongside SBOMs, dependency review and source protections.</li>
</ol>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We did not run signing or verification ourselves. We did not read the SLSA Source track requirements, GitHub plan requirements, the Sigstore certificate lifetime, or any mapping of GitHub&rsquo;s attestations to SLSA v1.2. The SLSA v1.1 pages we used for the provenance fields are retired.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 12, 2026.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Build L0 to L3 definitions, focus and quoted phrases</td>
          <td><a href="https://slsa.dev/spec/v1.2/build-track-basics">SLSA v1.2 Build track basics</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>L3 isolation and unforgeability requirements</td>
          <td><a href="https://slsa.dev/spec/v1.2/build-requirements">SLSA v1.2 Build requirements</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>v1.2 adds a Source track, updates the threat model; no date given</td>
          <td><a href="https://slsa.dev/spec/v1.2/whats-new">SLSA v1.2 What&rsquo;s new</a></td>
          <td>Qualified: page gives no release date</td>
      </tr>
      <tr>
          <td>Provenance predicate type and L1-required fields</td>
          <td><a href="https://slsa.dev/spec/v1.1/provenance">SLSA v1.1 Provenance</a></td>
          <td>Qualified: v1.1 page, marked retired; no format change noted in v1.2 pages</td>
      </tr>
      <tr>
          <td>Sigstore keyless flow and component roles</td>
          <td><a href="https://docs.sigstore.dev/about/overview/">Sigstore overview</a></td>
          <td>Verified; certificate lifetime not stated</td>
      </tr>
      <tr>
          <td>GitHub attestations: SLSA v1.0 Build L2 alone, L3 with reusable workflows; public vs private Sigstore instances</td>
          <td><a href="https://docs.github.com/en/actions/concepts/security/artifact-attestations">GitHub Docs, artifact attestations</a></td>
          <td>Qualified: refers to SLSA v1.0</td>
      </tr>
      <tr>
          <td><code>gh attestation verify</code> options and JSON caveat</td>
          <td><a href="https://cli.github.com/manual/gh_attestation_verify">GitHub CLI manual</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>What provenance does not prove; adoption order</td>
          <td>Our analysis</td>
          <td>Qualified: editorial judgment</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>SBOM Minimum Elements 2026: 17 Fields and What Changed</title><link>https://firewallsync.com/posts/sbom-minimum-elements-2026-what-cisa-and-partners-changed-from-the-2021-ntia-baseline/</link><guid>https://firewallsync.com/posts/sbom-minimum-elements-2026-what-cisa-and-partners-changed-from-the-2021-ntia-baseline/</guid><pubDate>Sun, 11 Oct 2026 06:05:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>On July 29, 2026, CISA and international partners published updated minimum elements for a software bill of materials, replacing the 2021 NTIA document. Here is what changed in the data fields and practices, what did not, and what to ask your suppliers and your tooling for.</description><enclosure url="https://firewallsync.com/posts/sbom-minimum-elements-2026-what-cisa-and-partners-changed-from-the-2021-ntia-baseline/cover_hu17806621991921323946.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/sbom-minimum-elements-2026-what-cisa-and-partners-changed-from-the-2021-ntia-baseline/cover_hu17806621991921323946.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/sbom-minimum-elements-2026-what-cisa-and-partners-changed-from-the-2021-ntia-baseline/cover_hu17806621991921323946.jpg" alt="Tall stacks of wooden crates in a warehouse, with open crates of oranges in the foreground" width="1200" height="630"></p><p>A software bill of materials (SBOM) is, in the words of the guidance itself, &ldquo;a nested inventory, a list of ingredients that make up software applications and systems.&rdquo; On <strong>July 29, 2026</strong>, the U.S. Cybersecurity and Infrastructure Security Agency (CISA), the National Security Agency (NSA), the Federal Bureau of Investigation (FBI) and agencies from other countries published the <em>2026 Minimum Elements for a Software Bill of Materials</em>. It &ldquo;updates and replaces&rdquo; the <em>Minimum Elements For a Software Bill of Materials</em> that the U.S. National Telecommunications and Information Administration (NTIA) published on <strong>July 12, 2021</strong>. If you buy, build or operate software and you ask anyone for an SBOM, this is now the reference list of what to ask for.</p>
<p>This article is research-based: we read the full 2026 document, the 2021 NTIA document, and CISA&rsquo;s announcement. We did not generate SBOMs with any tool or check how specific tools map to these fields.</p>
<h2 id="what-this-document-is-and-what-it-is-not">What this document is, and what it is not</h2>
<ul>
<li><strong>Who published it:</strong> CISA with the NSA, the FBI and, according to the document&rsquo;s header, 15 further organizations from other countries (among them Australia, Canada, France, Germany, India, Italy, Japan, the Netherlands, New Zealand, Poland, South Korea, Slovakia and the Czech Republic). Its acknowledgements note that the European Commission&rsquo;s DG CONNECT contributed, but that the document does not interpret EU law and does not bind the Commission.</li>
<li><strong>Version history:</strong> version 1.0 was the NTIA document of July 12, 2021; version 2.0 was a public-comment draft of August 22, 2025; version 2.1, dated July 29, 2026, incorporates the comments received. CISA&rsquo;s announcement says more than 90 comments were received.</li>
<li><strong>Not a mandate:</strong> the document states the minimum elements &ldquo;do not create new requirements; they refine how organizations should generate and request SBOMs,&rdquo; and its disclaimer says it is not advice for compliance, regulatory or legal purposes. Do not read it as a law. Individual contracts or regulations may point to it, but that is separate.</li>
<li><strong>Scope:</strong> it applies to SBOMs for all software, including open source, AI software and software as a service (SaaS). It says AI systems and SaaS may need additional elements, which are out of scope. For AI it points to separate G7 joint guidance published in May 2026.</li>
</ul>
<h2 id="what-changed-in-the-data-fields">What changed in the data fields</h2>
<p>The 2021 document listed seven baseline data fields: Supplier Name, Component Name, Version of the Component, Other Unique Identifiers, Dependency Relationship, Author of SBOM Data and Timestamp. The 2026 document&rsquo;s Appendix A table lists <strong>17</strong> data fields, in two groups: SBOM Metadata (about the SBOM document) and Component Data (about each component).</p>
<p>CISA&rsquo;s announcement names four new elements: Component Hash Algorithm, Component License, SBOM Tool Name and SBOM Generation Context. The document itself is more complete. Its Notable Updates section lists <strong>ten</strong> new elements: SBOM Author Signature, SBOM Data Format Name, SBOM Data Format Version, SBOM Generation Context, SBOM Tool Name, SBOM Tool Version, SBOM Version, Component Hash Value, Component Hash Algorithm and Component License. We count ten from that list; the announcement&rsquo;s four are a subset, not a conflict.</p>
<table>
  <thead>
      <tr>
          <th>2026 data field</th>
          <th>Group</th>
          <th>Status vs 2021</th>
          <th>What it is (paraphrased from the document)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SBOM Author</td>
          <td>Metadata</td>
          <td>Major update (was Author of SBOM Data)</td>
          <td>Entity that created the SBOM data; the operator of the tool, not the tool</td>
      </tr>
      <tr>
          <td>SBOM Author Signature</td>
          <td>Metadata</td>
          <td>New</td>
          <td>Digital signature attributable to the author, to show the data was not modified</td>
      </tr>
      <tr>
          <td>SBOM Data Format Name / Version</td>
          <td>Metadata</td>
          <td>New</td>
          <td>Which format (and format version) the SBOM uses</td>
      </tr>
      <tr>
          <td>SBOM Generation Context</td>
          <td>Metadata</td>
          <td>New</td>
          <td>Lifecycle phase when generated, such as before build, build, or after build</td>
      </tr>
      <tr>
          <td>SBOM Timestamp</td>
          <td>Metadata</td>
          <td>Minor update</td>
          <td>Date and time of the most recent update; should follow RFC 9557</td>
      </tr>
      <tr>
          <td>SBOM Tool Name / Version</td>
          <td>Metadata</td>
          <td>New</td>
          <td>The tool that generated or amended the SBOM, and its version</td>
      </tr>
      <tr>
          <td>SBOM Version</td>
          <td>Metadata</td>
          <td>New</td>
          <td>Identifies a change from a previous version of the SBOM</td>
      </tr>
      <tr>
          <td>Component Producer</td>
          <td>Component</td>
          <td>Major update (was Supplier Name)</td>
          <td>Entity that created or developed the component; one organization per component</td>
      </tr>
      <tr>
          <td>Component Name</td>
          <td>Component</td>
          <td>Minor update</td>
          <td>Name assigned by the producer; multiple entries allowed for alternate names</td>
      </tr>
      <tr>
          <td>Component Version</td>
          <td>Component</td>
          <td>Major update</td>
          <td>Producer&rsquo;s identifier for the version; mark unknown if not provided</td>
      </tr>
      <tr>
          <td>Component Identifiers</td>
          <td>Component</td>
          <td>Major update (was Other Unique Identifiers)</td>
          <td>At least one common identifier, such as CPE or Package-URL (PURL)</td>
      </tr>
      <tr>
          <td>Component Dependency Relationship</td>
          <td>Component</td>
          <td>Minor update</td>
          <td>One component is necessary for the operation of another</td>
      </tr>
      <tr>
          <td>Component Hash Value / Algorithm</td>
          <td>Component</td>
          <td>New</td>
          <td>Hash of the executable component artifact and the algorithm used</td>
      </tr>
      <tr>
          <td>Component License</td>
          <td>Component</td>
          <td>New</td>
          <td>License identifiers, such as SPDX license identifiers</td>
      </tr>
  </tbody>
</table>
<p>A few details worth knowing:</p>
<ul>
<li><strong>Why &ldquo;Supplier&rdquo; became &ldquo;Component Producer&rdquo;:</strong> the document says Supplier Name &ldquo;has proven ambiguous in practice, particularly around distributors of software,&rdquo; and that some ambiguity will remain even with the new name.</li>
<li><strong>Hashes:</strong> the Component Hash Value is computed over the executable component artifact. If the SBOM author does not have access to it, the document says to indicate the value is unknown. The algorithm should be identified using IANA Hash Function Textual Names and be approved by a relevant authority such as NIST.</li>
<li><strong>Generation context matters:</strong> the document&rsquo;s example is that an SBOM generated from source code is &ldquo;before build,&rdquo; while one produced by binary analysis tools is &ldquo;after build.&rdquo; The same software can have different component data depending on when the SBOM is generated, so this field tells a reader how much to trust the data.</li>
<li><strong>Identifiers:</strong> a field may carry multiple identifiers; if there are several, the author should include all of them.</li>
</ul>
<h2 id="what-changed-in-practices-and-processes">What changed in practices and processes</h2>
<table>
  <thead>
      <tr>
          <th>Element</th>
          <th>2026 expectation (paraphrased)</th>
          <th>Change from 2021</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Coverage</td>
          <td>Include all components, including transitive dependencies. &ldquo;There is no minimum depth.&rdquo; Duplicate instances with different metadata are listed separately.</td>
          <td>Replaces Depth, which was defined as top-level dependencies only</td>
      </tr>
      <tr>
          <td>Explicitly Identifying Unknown Information</td>
          <td>If a field is not provided, state whether it is unknown to the author or withheld. An SBOM may be considered incomplete if essential data is withheld.</td>
          <td>Replaces Known Unknowns</td>
      </tr>
      <tr>
          <td>Frequency</td>
          <td>Each software version or update should have an associated SBOM; issue a revised one when errors or new details are found</td>
          <td>Rewritten; intent unchanged per the document</td>
      </tr>
      <tr>
          <td>Distribution and Delivery</td>
          <td>Available promptly to those who need them; access controls must not block authorized parties or trusted security tools</td>
          <td>Simplified; absorbs the removed Access Control element</td>
      </tr>
      <tr>
          <td>Accommodation of Updates to SBOM Data</td>
          <td>Accommodate corrections; correct errors promptly; errors may feed risk decisions</td>
          <td>Replaces Accommodation of Mistakes</td>
      </tr>
      <tr>
          <td>Machine-Processable Data</td>
          <td>Use widely used, open, machine-processable formats; avoid accepting new-software SBOMs in deprecated format versions</td>
          <td>Replaces Automation Support; SWID tags dropped</td>
      </tr>
  </tbody>
</table>
<p>Coverage carries the biggest practical change. The document&rsquo;s own illustration: from a vulnerability management view, the recipient of an SBOM &ldquo;should be able to conclude that a newly reported vulnerability does not affect them if the SBOM does not list the component associated with the vulnerability.&rdquo; That only works if the SBOM is comprehensive, including transitive dependencies (dependencies of your dependencies). It also allows linking to separate SBOMs for subcomponents, provided the recipient has access to all of them.</p>
<h2 id="spdx-cyclonedx-and-the-format-question">SPDX, CycloneDX and the format question</h2>
<p>The document names two data formats as widely used: SPDX (System Package Data eXchange), cited with ISO/IEC 5962:2021, and CycloneDX, cited with ECMA-424, which it dates December 2025. It does not tell you to pick one. Producers choose &ldquo;based on factors that may be specific to organizations, industries, or sectors,&rdquo; while consumers &ldquo;should accept any widely used, interoperable, and machine-processable SBOM format.&rdquo;</p>
<p>For current version numbers, the CycloneDX specification overview showed version 1.7, released October 21, 2025, when we checked. The SPDX specifications page identified SPDX Document Version 3.0 as the current version; it shows no release dates for versions. We did not verify how each tool&rsquo;s output maps onto the 17 fields. When evaluating a generator, check whether its output actually includes, for example, hash values, license identifiers and generation context, rather than assuming it does.</p>
<h2 id="what-this-means-in-practice">What this means in practice</h2>
<p>These are our suggestions based on the guidance, not requirements in it:</p>
<ol>
<li><strong>Update the SBOM request language</strong> in vendor questionnaires. Instead of &ldquo;provide an SBOM,&rdquo; ask for one that satisfies the 2026 Minimum Elements, in a named format and version, with Coverage including transitive dependencies. See our <a href="/posts/vetting-third-party-saas-tools-before-they-touch-your-data/">guide to vetting third-party SaaS tools</a> for the wider vendor review.</li>
<li><strong>Check your generation tooling</strong> for the new metadata: tool name and version, generation context, timestamp, and a signature if you share SBOMs externally.</li>
<li><strong>Generate at more than one point</strong> if you can. Because components differ by lifecycle phase, a source-based SBOM and a post-build SBOM are not interchangeable.</li>
<li><strong>Pair SBOMs with vulnerability prioritization.</strong> An SBOM tells you whether you have a component; it does not tell you whether the flaw is exploitable in your product. The guidance points to VEX and CSAF for that; see <a href="/posts/vex-explained-what-a-not-affected-statement-means-and-how-to-read-one/">how to read a VEX not-affected statement</a>. Our comparison of <a href="/posts/cvss-vs-epss-vs-kev-which-signal-should-decide-what-you-patch-first/">CVSS, EPSS and KEV</a> covers prioritization, and <a href="/posts/dependency-audits-without-slowing-down-releases/">dependency audits without slowing releases</a> and <a href="/posts/container-image-scanning-what-ci-pipelines-still-miss/">container image scanning</a> cover where the component data comes from in CI.</li>
<li><strong>Treat unknowns as data.</strong> Mark fields you cannot fill as unknown rather than omitting them, as the guidance asks, and treat &ldquo;unknown&rdquo; in a vendor SBOM as a follow-up question, not a pass.</li>
</ol>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We did not verify the claims of third-party summaries that count the changes differently; we counted from the document. We did not check whether the U.S. federal procurement rules, the EU Cyber Resilience Act or any other regime now references the 2026 document. The document itself only notes that the EU Cyber Resilience Act (Regulation (EU) 2024/2847) requires manufacturers of products with digital elements to provide an SBOM as part of technical documentation, and states that it does not interpret EU law. Check your own contractual and regulatory obligations separately.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 11, 2026.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>2026 Minimum Elements published July 29, 2026; version history 1.0 (July 12, 2021), 2.0 (August 22, 2025), 2.1 (July 29, 2026); updates and replaces NTIA 2021</td>
          <td><a href="https://www.cisa.gov/sites/default/files/2026-07/2026_cisa_sbom_minimum_elements_508c.pdf">CISA, 2026 Minimum Elements for a SBOM (PDF)</a> and <a href="https://www.cisa.gov/resources-tools/resources/2026-minimum-elements-software-bill-materials-sbom">CISA resource page</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>17 data fields in Appendix A; ten elements listed as new; definitions; Access Control removed; SWID removed; Depth replaced by Coverage; unknown-information rules; no new requirements; not compliance advice</td>
          <td>Same PDF</td>
          <td>Verified: counts are our own from the document&rsquo;s tables and lists</td>
      </tr>
      <tr>
          <td>CISA announcement names four new elements and says 90+ comments were received</td>
          <td><a href="https://www.cisa.gov/news-events/news/cisa-and-partners-unveil-updated-software-bill-materials-resource-improves-transparency-security-and">CISA announcement, July 29, 2026</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>2021 baseline had seven data fields; published July 12, 2021</td>
          <td><a href="https://www.ntia.gov/files/ntia/publications/sbom_minimum_elements_report.pdf">NTIA, The Minimum Elements For a Software Bill of Materials (PDF)</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>CycloneDX 1.7 released October 21, 2025; developed with Ecma International as ECMA-424</td>
          <td><a href="https://cyclonedx.org/specification/overview/">CycloneDX specification overview</a></td>
          <td>Qualified: current as of the date we read it</td>
      </tr>
      <tr>
          <td>SPDX specifications page identifies 3.0 as the current document version and links ISO/IEC 5962:2021</td>
          <td><a href="https://spdx.dev/use/specifications/">SPDX specifications</a></td>
          <td>Qualified: page shows no release dates for versions</td>
      </tr>
      <tr>
          <td>Practical recommendations in &ldquo;What this means in practice&rdquo;</td>
          <td>Our analysis of the above</td>
          <td>Qualified: editorial judgment, not requirements of the guidance</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>HTTP Security Headers Beyond CSP: HSTS, nosniff, Referrer-Policy and Permissions-Policy Explained</title><link>https://firewallsync.com/posts/http-security-headers-hsts-nosniff-referrer-policy-and-permissions-policy-explained/</link><guid>https://firewallsync.com/posts/http-security-headers-hsts-nosniff-referrer-policy-and-permissions-policy-explained/</guid><pubDate>Sun, 11 Oct 2026 06:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>Four response headers do most of the work after Content Security Policy: Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy and Permissions-Policy. Here is what each one does, the values MDN and OWASP document, and the HSTS preload trap that is hard to undo.</description><enclosure url="https://firewallsync.com/posts/http-security-headers-hsts-nosniff-referrer-policy-and-permissions-policy-explained/cover_hu15791279917520557934.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/http-security-headers-hsts-nosniff-referrer-policy-and-permissions-policy-explained/cover_hu15791279917520557934.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/http-security-headers-hsts-nosniff-referrer-policy-and-permissions-policy-explained/cover_hu15791279917520557934.jpg" alt="Brass padlock hanging from a metal slide bolt across the seam of two dark wooden door panels" width="1200" height="630"></p><p>Content Security Policy gets most of the attention among HTTP response headers, but four smaller headers cover different problems: forcing HTTPS, stopping content-type guessing, limiting what a link leaks, and switching off browser features you never use. The values below come from OWASP&rsquo;s HTTP Headers Cheat Sheet and MDN&rsquo;s reference pages, and every claim is attributed to one of them. This is research-based; we did not run these headers against a live server or test browser behavior. For the script-control header, see our separate guide to <a href="/posts/content-security-policy-a-strict-nonce-based-policy-and-how-to-roll-it-out-safely/">a strict nonce-based Content Security Policy</a>.</p>
<h2 id="the-short-version">The short version</h2>
<p>OWASP&rsquo;s recommended values for the four headers:</p>
<table>
  <thead>
      <tr>
          <th>Header</th>
          <th>OWASP-recommended value</th>
          <th>What it controls</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>Strict-Transport-Security</code></td>
          <td><code>max-age=63072000; includeSubDomains; preload</code></td>
          <td>Browser uses HTTPS only for the host</td>
      </tr>
      <tr>
          <td><code>X-Content-Type-Options</code></td>
          <td><code>nosniff</code></td>
          <td>No content-type guessing</td>
      </tr>
      <tr>
          <td><code>Referrer-Policy</code></td>
          <td><code>strict-origin-when-cross-origin</code></td>
          <td>How much of the page URL is sent in the <code>Referer</code> header</td>
      </tr>
      <tr>
          <td><code>Permissions-Policy</code></td>
          <td><code>geolocation=(), camera=(), microphone=()</code></td>
          <td>Which browser features the page and its iframes may use</td>
      </tr>
  </tbody>
</table>
<p>OWASP gives these as recommendations; <code>63072000</code> is two years in seconds. Two of them need care before you copy them: the HSTS value includes <code>preload</code>, which has consequences described below, and the Permissions-Policy value disables only three features, so you should disable what <em>your</em> site does not need.</p>
<p>Headers are sent by the web server, CDN or application, and the configuration syntax differs between them. The examples here are the raw header lines as they appear in an HTTP response.</p>
<h2 id="strict-transport-security-hsts">Strict-Transport-Security (HSTS)</h2>
<p>HTTP Strict Transport Security (HSTS) tells a browser to access a host only over HTTPS. MDN defines <code>max-age</code> as &ldquo;the time, in seconds, that the browser should remember that a host is only to be accessed using HTTPS.&rdquo; Before loading an <code>http</code> URL for that host, the browser rewrites it to <code>https</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">Strict-Transport-Security: max-age=31536000; includeSubDomains
</span></span></span></code></pre></div><p>How it behaves, per MDN:</p>
<ul>
<li><strong>HTTPS only.</strong> The header must be sent over HTTPS. Browsers ignore it over HTTP, which prevents a man-in-the-middle from setting or expiring it. Your HTTP-to-HTTPS redirect (MDN suggests a permanent redirect such as status <code>301</code>) must not carry the header.</li>
<li><strong>First-visit gap.</strong> HSTS &ldquo;does not take effect until the browser has made at least one secure connection to the host and received the <code>Strict-Transport-Security</code> header.&rdquo; An insecure first request is vulnerable to network attacks. Preloading mitigates this.</li>
<li><strong>Refreshing.</strong> Each time the browser receives the header, it extends the expiry by <code>max-age</code> from the current time. MDN warns that a fixed <code>max-age</code> can keep HSTS from ever expiring while the header keeps being sent.</li>
<li><strong>Subdomains.</strong> <code>includeSubDomains</code> applies the policy to all subdomains of the host that sent it. MDN notes it does not apply to the parent domain or to sibling hosts.</li>
<li><strong>No click-through.</strong> If a TLS error such as an invalid certificate occurs on an HSTS host, the browser does not let the user proceed. OWASP makes the same point from the operator side: a misconfigured header or a certificate problem can block legitimate users, and long durations are a particular risk if the certificate expires or is revoked.</li>
<li><strong>Turning it off.</strong> <code>max-age=0</code> disables it, but only once a browser makes a secure request and receives that response.</li>
</ul>
<h3 id="the-preload-decision">The preload decision</h3>
<p>HSTS preloading ships your domain inside browsers so even the first visit is HTTPS. MDN says the list is maintained by Google at hstspreload.org, is used by all major browsers, and &ldquo;is not part of the HSTS specification and should not be treated as official.&rdquo;</p>
<p>hstspreload.org lists these submission requirements:</p>
<ol>
<li>Serve a valid certificate.</li>
<li>Redirect HTTP to HTTPS on the same host if you listen on port 80.</li>
<li>Serve all subdomains over HTTPS, including <code>www</code> if a DNS record for it exists.</li>
<li>Send an HSTS header on the base domain with <code>max-age</code> of at least <code>31536000</code> seconds (one year), <code>includeSubDomains</code> and <code>preload</code>.</li>
</ol>
<p>The site&rsquo;s warning is blunt: &ldquo;inclusion in the preload list cannot easily be undone.&rdquo; Removal takes months to reach users through a Chrome update, other browsers are not guaranteed to honor it, and removal &ldquo;tends to be slow and painful&rdquo; for sites preloaded before they realized some subdomains lacked HTTPS.</p>
<p>Our editorial suggestion, not a quoted rule: because the header persists in browsers until it expires and <code>max-age=0</code> works only after a secure request, start with a short <code>max-age</code>, confirm every subdomain (including internal ones) serves valid HTTPS, raise the value in steps, and add <code>preload</code> only as a deliberate, final decision. Before then, an internal hostname with an expired certificate becomes unreachable for users who cached the policy.</p>
<h2 id="x-content-type-options-nosniff">X-Content-Type-Options: nosniff</h2>
<p>Browsers can try to work out what a file is from its content, called MIME sniffing, rather than trusting the <code>Content-Type</code> header. <code>nosniff</code> is the only valid value, and MDN describes two effects:</p>
<ul>
<li>For requests with a destination of script or style, the browser blocks the response if the MIME type does not match: a JavaScript MIME type for scripts, <code>text/css</code> for stylesheets.</li>
<li>For other responses, including navigations to a new HTML document, the browser uses the supplied <code>Content-Type</code> as-is. MDN&rsquo;s example: a <code>text/plain</code> response with <code>nosniff</code> will not be interpreted as HTML even if it contains HTML markup, which helps against attacks where user-uploaded content is executed as an HTML document.</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">X-Content-Type-Options: nosniff
</span></span></span></code></pre></div><p>The limitation is the part people miss. Blocking applies to script and style requests only, and everything depends on the declared type being right. OWASP&rsquo;s pairing advice is to set <code>Content-Type</code> correctly throughout the site. If your server labels JavaScript files <code>text/plain</code>, <code>nosniff</code> will cause scripts to be blocked, which is a good way to find those mistakes in staging.</p>
<h2 id="referrer-policy">Referrer-Policy</h2>
<p>When a user clicks a link or a page loads a resource, the browser may send a <code>Referer</code> header containing the address of the page that triggered the request. URLs can contain sensitive material such as password-reset tokens or internal paths, so <code>Referrer-Policy</code> controls how much is sent. MDN&rsquo;s list of values:</p>
<table>
  <thead>
      <tr>
          <th>Value</th>
          <th>What the <code>Referer</code> contains</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>no-referrer</code></td>
          <td>Nothing</td>
      </tr>
      <tr>
          <td><code>same-origin</code></td>
          <td>Full URL for same-origin requests; nothing for cross-origin requests</td>
      </tr>
      <tr>
          <td><code>strict-origin</code></td>
          <td>Origin only (for example <code>https://example.com/</code>) for HTTPS-to-HTTPS; nothing to less secure destinations</td>
      </tr>
      <tr>
          <td><code>strict-origin-when-cross-origin</code></td>
          <td>Full URL for same-origin requests; origin only for cross-origin HTTPS-to-HTTPS; nothing to less secure destinations</td>
      </tr>
      <tr>
          <td><code>no-referrer-when-downgrade</code></td>
          <td>Full URL unless the request goes from HTTPS to HTTP, then nothing</td>
      </tr>
      <tr>
          <td><code>origin</code></td>
          <td>Origin only, for all requests</td>
      </tr>
      <tr>
          <td><code>origin-when-cross-origin</code></td>
          <td>Full URL for same-origin requests; origin only for cross-origin requests and HTTPS-to-HTTP</td>
      </tr>
      <tr>
          <td><code>unsafe-url</code></td>
          <td>Full URL for all requests; MDN warns it &ldquo;will leak potentially-private information from HTTPS resource URLs to insecure origins&rdquo;</td>
      </tr>
  </tbody>
</table>
<p>MDN states that <code>strict-origin-when-cross-origin</code> is the default when no policy is specified, per a specification revision of November 2020, and that the previous default was <code>no-referrer-when-downgrade</code>. OWASP recommends the header with <code>strict-origin-when-cross-origin</code> and says to &ldquo;Set the header explicitly rather than relying on browser defaults.&rdquo;</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">Referrer-Policy: strict-origin-when-cross-origin
</span></span></span></code></pre></div><p>If your URLs carry secrets, the stricter <code>no-referrer</code> or <code>same-origin</code> send less; that comparison is our reading of MDN&rsquo;s table, not a recommendation either source makes. The better fix is not to put secrets in URLs. MDN also documents a comma-separated fallback list with the preferred policy last, for example <code>no-referrer, strict-origin-when-cross-origin</code>.</p>
<h2 id="permissions-policy">Permissions-Policy</h2>
<p><code>Permissions-Policy</code> is a response header that, per MDN, &ldquo;provides a mechanism to allow and deny the use of browser features in a document or within any <code>&lt;iframe&gt;</code> elements in the document.&rdquo; Specifying <code>()</code> for a feature disables it for all browsing contexts, including iframes from any origin.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">Permissions-Policy: geolocation=(), camera=(), microphone=()
</span></span></span></code></pre></div><p>MDN documents what happens when each is disabled: calls to <code>getUserMedia()</code> for camera or microphone reject with a <code>NotAllowedError</code>, and geolocation calls receive a <code>PERMISSION_DENIED</code> error. OWASP&rsquo;s rationale is that the policy &ldquo;prevents that an injection, for example an XSS, enables the camera.&rdquo; Its advice is to disable every feature the site does not need, or allow it only for authorized domains.</p>
<p>Two qualifications. MDN labels the header &ldquo;Limited availability&rdquo; and &ldquo;Experimental&rdquo; and says it is not Baseline because it does not work in some of the most widely used browsers, so treat it as defense in depth rather than a guarantee. And you can scope a feature to specific origins, for example <code>(&quot;https://a.example.com&quot;)</code>, rather than disabling it outright, if a legitimate embedded widget needs it.</p>
<h2 id="headers-to-stop-sending">Headers to stop sending</h2>
<p>OWASP&rsquo;s cheat sheet also names headers that should no longer be used:</p>
<ul>
<li><strong><code>X-XSS-Protection</code>:</strong> &ldquo;Do not set this header or explicitly turn it off.&rdquo; The example given is <code>X-XSS-Protection: 0</code>. OWASP says it can create XSS vulnerabilities in otherwise safe sites and points to a CSP that disables inline JavaScript instead.</li>
<li><strong><code>Expect-CT</code> and <code>Public-Key-Pins</code>:</strong> do not use; remove from existing code where possible.</li>
<li><strong><code>Server</code>, <code>X-Powered-By</code>, <code>X-AspNet-Version</code>, <code>X-AspNetMvc-Version</code>:</strong> remove, or set non-informative values.</li>
</ul>
<p>OWASP also recommends <code>X-Frame-Options: DENY</code> against clickjacking but notes the CSP <code>frame-ancestors</code> directive obsoletes it for supporting browsers, and that it provides nothing for redirects or JSON API responses. For cross-origin access headers see <a href="/posts/cors-misconfiguration-what-access-control-allow-origin-actually-permits/">CORS misconfiguration</a>, and for another cookie- and header-based defense see <a href="/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/">CSRF protection with SameSite cookies, tokens and Fetch Metadata</a>.</p>
<h2 id="a-rollout-order-that-limits-breakage">A rollout order that limits breakage</h2>
<p>This ordering is our editorial judgment built on the behaviors above:</p>
<ol>
<li><strong>Audit what you send.</strong> Inspect responses for all hostnames, not just the main site, including error pages and redirects.</li>
<li><strong>Add <code>nosniff</code> and fix any wrong <code>Content-Type</code> values</strong> it exposes.</li>
<li><strong>Set <code>Referrer-Policy</code> explicitly.</strong> It is the lowest-risk change here.</li>
<li><strong>Add <code>Permissions-Policy</code></strong> disabling features you do not use.</li>
<li><strong>Add HSTS with a short <code>max-age</code>,</strong> then raise it after checking every subdomain. Consider preload last.</li>
<li><strong>Remove deprecated headers</strong> listed above.</li>
</ol>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 11, 2026. We read each page below; we did not test headers on a live server or in browsers.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>OWASP&rsquo;s recommended values for HSTS, nosniff, Referrer-Policy and Permissions-Policy; recommendation to set Referrer-Policy explicitly; X-Frame-Options and COOP notes; do not set X-XSS-Protection; do not use Expect-CT or Public-Key-Pins; remove Server and X-Powered-By</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html">OWASP HTTP Headers Cheat Sheet</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>HSTS behavior: max-age, includeSubDomains, ignored over HTTP, first-visit gap, expiry refresh, max-age=0, no click-through</td>
          <td><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security">MDN: Strict-Transport-Security</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Preload requirements and &ldquo;cannot easily be undone&rdquo; warning</td>
          <td><a href="https://hstspreload.org/">hstspreload.org</a></td>
          <td>Verified: page has no visible date; read October 11, 2026</td>
      </tr>
      <tr>
          <td>nosniff only valid value; script and style blocking; no sniffing elsewhere</td>
          <td><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Content-Type-Options">MDN: X-Content-Type-Options</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Referrer-Policy values and default of strict-origin-when-cross-origin</td>
          <td><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy">MDN: Referrer-Policy</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Permissions-Policy syntax, effects of disabling features, &ldquo;Limited availability&rdquo; and &ldquo;Experimental&rdquo; labels</td>
          <td><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy">MDN: Permissions-Policy</a></td>
          <td>Qualified: MDN labels reflect the page when we read it and may change</td>
      </tr>
      <tr>
          <td>The suggested rollout order and the comparison that no-referrer sends least</td>
          <td>Our analysis of the pages above</td>
          <td>Qualified: editorial judgment, not a quoted recommendation</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Content Security Policy: A Strict Nonce-Based Policy and How to Roll It Out Safely</title><link>https://firewallsync.com/posts/content-security-policy-a-strict-nonce-based-policy-and-how-to-roll-it-out-safely/</link><guid>https://firewallsync.com/posts/content-security-policy-a-strict-nonce-based-policy-and-how-to-roll-it-out-safely/</guid><pubDate>Sat, 10 Oct 2026 06:05:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>A Content Security Policy limits what scripts a browser will run, which can turn a cross-site scripting bug from a full compromise into a blocked request. Here is the strict nonce-based policy MDN and OWASP recommend, what each directive does, and a report-only rollout that does not break your site.</description><enclosure url="https://firewallsync.com/posts/content-security-policy-a-strict-nonce-based-policy-and-how-to-roll-it-out-safely/cover_hu16871119953033856430.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/content-security-policy-a-strict-nonce-based-policy-and-how-to-roll-it-out-safely/cover_hu16871119953033856430.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/content-security-policy-a-strict-nonce-based-policy-and-how-to-roll-it-out-safely/cover_hu16871119953033856430.jpg" alt="Black-and-white photo of an open padlock hanging from the latch of a metal grille gate" width="1200" height="630"></p><p>A Content Security Policy (CSP) is an HTTP response header that tells the browser which sources of script and other content it may use on your page. The version worth deploying for cross-site scripting (XSS) defense is a <em>strict</em> policy based on a random per-response value called a nonce. MDN&rsquo;s guidance reads: &ldquo;To control script loading as a mitigation against XSS, recommended practice is to use nonce- or hash-based fetch directives.&rdquo; OWASP&rsquo;s cheat sheet likewise calls a strict CSP the &ldquo;current leading practice.&rdquo; This article covers the minimal strict policy, what each part does, and how to roll it out without a day of broken pages.</p>
<p>It is research-based. We tested only one thing ourselves: a small Node.js script confirming that the example below produces a different nonce on every response and that the header and page agree. We did not test browser behavior.</p>
<h2 id="the-minimal-strict-policy">The minimal strict policy</h2>
<p>MDN&rsquo;s nonce-based example is:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">Content-Security-Policy:
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">  script-src &#39;nonce-{RANDOM}&#39;;
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">  object-src &#39;none&#39;;
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">  base-uri &#39;none&#39;;
</span></span></span></code></pre></div><p>In plain terms:</p>
<ul>
<li><strong><code>script-src 'nonce-{RANDOM}'</code></strong> allows a script only if its tag carries a <code>nonce</code> attribute matching the value in the header. Without nonce or hash expressions, MDN says, a <code>script-src</code> or <code>default-src</code> directive blocks inline JavaScript. That is the protection: an attacker who injects <code>&lt;script&gt;alert(1)&lt;/script&gt;</code> does not know the nonce, so the browser refuses to run it.</li>
<li><strong><code>object-src 'none'</code></strong> disallows plugins and embedded objects. OWASP&rsquo;s strict samples set it because, with <code>plugin-types</code> removed from the specification, <code>object-src 'none'</code> is the control when you do not need embedded objects.</li>
<li><strong><code>base-uri 'none'</code></strong> stops injected <code>&lt;base&gt;</code> elements from changing how relative URLs resolve. OWASP&rsquo;s samples also set <code>base-uri 'none'</code>.</li>
</ul>
<p>Add <strong><code>frame-ancestors 'none'</code></strong> if your pages should never be embedded in an iframe on another site. MDN says: &ldquo;Unless you need your site to be embeddable, you should set <code>frame-ancestors</code> to <code>'none'</code>,&rdquo; and describes it as a more flexible replacement for <code>X-Frame-Options</code>.</p>
<h2 id="generating-the-nonce">Generating the nonce</h2>
<p>The rule from MDN: the nonce &ldquo;must be different for every HTTP response, and must not be predictable.&rdquo; Here is a minimal Node.js example (no framework) that does that:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">http</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">require</span>(<span style="color:#e6db74">&#34;node:http&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">crypto</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">require</span>(<span style="color:#e6db74">&#34;node:crypto&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">createServer</span>((<span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">nonce</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">crypto</span>.<span style="color:#a6e22e">randomBytes</span>(<span style="color:#ae81ff">16</span>).<span style="color:#a6e22e">toString</span>(<span style="color:#e6db74">&#34;base64&#34;</span>); <span style="color:#75715e">// new value per response
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">policy</span> <span style="color:#f92672">=</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">`script-src &#39;nonce-</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">nonce</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#39; &#39;strict-dynamic&#39;`</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;object-src &#39;none&#39;&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;base-uri &#39;none&#39;&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;frame-ancestors &#39;none&#39;&#34;</span>,
</span></span><span style="display:flex;"><span>  ].<span style="color:#a6e22e">join</span>(<span style="color:#e6db74">&#34;; &#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">setHeader</span>(<span style="color:#e6db74">&#34;Content-Security-Policy-Report-Only&#34;</span>, <span style="color:#a6e22e">policy</span>); <span style="color:#75715e">// switch to Content-Security-Policy to enforce
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">setHeader</span>(<span style="color:#e6db74">&#34;Content-Type&#34;</span>, <span style="color:#e6db74">&#34;text/html; charset=utf-8&#34;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">end</span>(<span style="color:#e6db74">`&lt;!doctype html&gt;&lt;script nonce=&#34;</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">nonce</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;&gt;/* app code */&lt;/script&gt;`</span>);
</span></span><span style="display:flex;"><span>}).<span style="color:#a6e22e">listen</span>(<span style="color:#ae81ff">8080</span>);
</span></span></code></pre></div><p>We ran an equivalent script twice against itself: the nonce differed between the two responses and the nonce in the body matched the header. That confirms the mechanics, not that your application is protected.</p>
<p>OWASP warns against one shortcut: do not use middleware that automatically adds the nonce to every script tag, because scripts an attacker injects would then receive the nonce too. The nonce belongs only on scripts your templates deliberately emit, which means your templating layer has to apply it.</p>
<h2 id="nonce-hash-or-strict-dynamic">Nonce, hash or <code>'strict-dynamic'</code></h2>
<table>
  <thead>
      <tr>
          <th>Option</th>
          <th>Good for</th>
          <th>Limitation</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Nonce</td>
          <td>Pages rendered by a server on each request</td>
          <td>Needs a templating step; a static, cached page would reuse the nonce, which is unsafe</td>
      </tr>
      <tr>
          <td>Hash (<code>'sha256-...'</code>)</td>
          <td>Static pages with fixed inline scripts</td>
          <td>OWASP: any change to the script, even whitespace, changes the hash and breaks it; external scripts also need an <code>integrity</code> attribute (MDN)</td>
      </tr>
      <tr>
          <td><code>'strict-dynamic'</code></td>
          <td>Scripts that load other scripts, such as tag managers</td>
          <td>MDN: it &ldquo;does make your CSP less secure&rdquo; because trusted scripts that create <code>&lt;script&gt;</code> elements from XSS-prone input are not protected</td>
      </tr>
  </tbody>
</table>
<p>Per MDN, <code>'strict-dynamic'</code> means that if a script has a nonce or hash, it may load further scripts that do not have one. It eases third-party scripts but moves some trust to them.</p>
<h3 id="why-not-just-list-allowed-hostnames">Why not just list allowed hostnames?</h3>
<p>Allowlist policies such as <code>script-src https://cdn.example.com</code> were the original mechanism. OWASP says a strict policy is &ldquo;much easier to deploy&rdquo; and warns that a non-strict policy &ldquo;that is too granular or permissive is likely to lead to bypasses.&rdquo; We did not survey bypass techniques here and do not quantify how often they occur.</p>
<h2 id="roll-it-out-in-report-only-mode-first">Roll it out in report-only mode first</h2>
<p>The <code>Content-Security-Policy-Report-Only</code> header applies the policy in reporting mode. MDN: &ldquo;The policy is not enforced, but any violations are sent to the reporting endpoint specified in the policy.&rdquo; OWASP calls it non-blocking (&ldquo;fail open&rdquo;) and often a precursor to blocking mode. Practical points:</p>
<ol>
<li><strong>Report-only cannot be set in a <code>&lt;meta&gt;</code> element</strong>, per MDN and the W3C draft. The <code>&lt;meta http-equiv&gt;</code> form also &ldquo;does not support all CSP features.&rdquo; Use response headers, on all responses, not only the main document (MDN).</li>
<li><strong>Both headers can run together.</strong> MDN says that if both are present, both policies are honored: the enforced one blocks and the report-only one reports. OWASP suggests running a strict report-only policy alongside a looser enforced one to collect many violation reports while you migrate.</li>
<li><strong>Collect reports.</strong> MDN&rsquo;s recommended method uses the Reporting API:</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-http" data-lang="http"><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">Reporting-Endpoints: csp-endpoint=&#34;https://example.com/csp-reports&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">Content-Security-Policy: default-src &#39;self&#39;; report-to csp-endpoint
</span></span></span></code></pre></div><p>Reports arrive as <code>POST</code> requests with content type <code>application/reports+json</code> and <code>&quot;type&quot;: &quot;csp-violation&quot;</code>. The older <code>report-uri</code> directive is described by MDN as deprecated, but it says to declare both until <code>report-to</code> is supported in all browsers.
4. <strong>Fix the causes</strong>, which are usually inline <code>&lt;script&gt;</code> blocks, inline event handlers such as <code>onclick</code>, and third-party snippets. Move inline code to external files or give emitted scripts a nonce.
5. <strong>Switch the header name</strong> from <code>Content-Security-Policy-Report-Only</code> to <code>Content-Security-Policy</code> once reports are quiet for your traffic, and keep reporting on.</p>
<p>Avoid the temptation to add <code>'unsafe-inline'</code> to make reports go away. MDN: &ldquo;Developers should avoid <code>'unsafe-inline'</code>, because it defeats much of the purpose of having a CSP.&rdquo; It adds that when a directive has nonce or hash expressions, browsers ignore <code>'unsafe-inline'</code> in it, so adding it beside a nonce does not weaken the policy in browsers that support nonces, but it also gains nothing there.</p>
<h2 id="what-csp-does-not-do">What CSP does not do</h2>
<p>OWASP states that CSP &ldquo;should not be relied upon as the only defensive mechanism against XSS.&rdquo; MDN says the same thing differently: &ldquo;Setting a CSP is not an alternative to sanitizing input. Websites should sanitize input <em>and</em> set a CSP.&rdquo; Output encoding and safe templating remain the primary fix. CSP also does not address cross-site request forgery, misconfigured cross-origin access, or broken authorization; see our articles on <a href="/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/">CSRF protection</a> and <a href="/posts/cors-misconfiguration-what-access-control-allow-origin-actually-permits/">CORS misconfiguration</a>.</p>
<p>A note on standards status: as of our check, CSP Level 3 is a W3C Working Draft (the page shows September 16, 2026 as its date), not a finished Recommendation. Browser support is what determines real behavior, so test the browsers your users have.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 10, 2026. We did not test a policy in any browser.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Strict CSP is recommended practice for XSS mitigation; nonce must be unique per response and unpredictable; hashes for static pages; example policy; <code>'strict-dynamic'</code> behavior and its security cost; <code>'unsafe-inline'</code> warning and nonce/hash behavior</td>
          <td><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP">MDN, Content Security Policy (CSP)</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Report-Only header behavior, both headers honored, no meta support, Reporting-Endpoints and <code>report-to</code>, deprecated <code>report-uri</code></td>
          <td>Same MDN page</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>frame-ancestors 'none'</code> recommendation and relation to X-Frame-Options</td>
          <td>Same MDN page</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Strict CSP is leading practice; nonce guidance and middleware warning; hash fragility; <code>object-src</code> and <code>base-uri</code>; Report-Only is fail-open; CSP not the only XSS defense; allowlist bypass warning</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html">OWASP, Content Security Policy Cheat Sheet</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Report-only cannot be in meta; CSP Level 3 is a W3C Working Draft dated September 16, 2026</td>
          <td><a href="https://www.w3.org/TR/CSP3/">W3C, Content Security Policy Level 3</a></td>
          <td>Qualified: draft status and date as shown when we checked; only the first part of the document was read</td>
      </tr>
      <tr>
          <td>Example script produces a different nonce per response and matches the page</td>
          <td>Our own test, Node.js 22</td>
          <td>Qualified: tests header and nonce mechanics only, not browser enforcement</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>CORS Misconfiguration: What Access-Control-Allow-Origin Actually Permits</title><link>https://firewallsync.com/posts/cors-misconfiguration-what-access-control-allow-origin-actually-permits/</link><guid>https://firewallsync.com/posts/cors-misconfiguration-what-access-control-allow-origin-actually-permits/</guid><pubDate>Thu, 08 Oct 2026 08:15:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>A CORS header decides which other websites may read your responses with your users' cookies attached. Reflecting the Origin header, matching it with endsWith or an unescaped regex, or trusting null each hands that read access to an attacker.</description><enclosure url="https://firewallsync.com/posts/cors-misconfiguration-what-access-control-allow-origin-actually-permits/cover_hu8443874725337962926.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/cors-misconfiguration-what-access-control-allow-origin-actually-permits/cover_hu8443874725337962926.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/cors-misconfiguration-what-access-control-allow-origin-actually-permits/cover_hu8443874725337962926.jpg" alt="Glowing blue fiber optic strands against a dark background" width="1200" height="630"></p><p>Cross-Origin Resource Sharing (CORS) headers answer one question: may script running on another website read this response? If your API answers &ldquo;yes&rdquo; to the wrong origin and also allows credentials, that website can read your users&rsquo; data using their own logged-in sessions. Most real CORS vulnerabilities come from a short list of origin-matching mistakes, and all of them are fixed the same way: compare the <code>Origin</code> header against an exact list.</p>
<h2 id="what-cors-actually-controls">What CORS actually controls</h2>
<p>Browsers apply the same-origin policy: script loaded from one origin cannot read responses from another. An origin is the scheme, host and port together, so <code>https://app.example.com</code> and <code>https://api.example.com</code> are different origins. MDN describes CORS as &ldquo;an HTTP-header based mechanism that allows a server to indicate any origins (domain, scheme, or port) other than its own from which a browser should permit loading resources.&rdquo;</p>
<p>Three facts shape everything below:</p>
<ul>
<li><strong>CORS relaxes a restriction; it does not add one.</strong> Without CORS headers, other origins cannot read your responses. Every CORS header you add widens access.</li>
<li><strong>CORS governs reading, not sending.</strong> For a simple request, which MDN defines as a GET, HEAD or POST using only safelisted headers and one of three content types (<code>application/x-www-form-urlencoded</code>, <code>multipart/form-data</code> or <code>text/plain</code>), the browser sends the request without asking first. CORS decides only whether the calling script gets the response. Other requests trigger a preflight <code>OPTIONS</code> request before the real one is sent.</li>
<li><strong>Browsers enforce it; servers do not.</strong> A command-line client ignores CORS entirely. PortSwigger puts it plainly: &ldquo;CORS defines browser behaviors and is never a replacement for server-side protection of sensitive data.&rdquo;</li>
</ul>
<h2 id="credentials-are-what-make-it-dangerous">Credentials are what make it dangerous</h2>
<p>By default, a cross-origin <code>fetch()</code> does not send cookies. A script must ask for them with <code>credentials: &quot;include&quot;</code>, and the browser then makes the response available only if the server replies with <code>Access-Control-Allow-Credentials: true</code>.</p>
<p>The browser adds a second safeguard. MDN says that when responding to a credentialed request the server &ldquo;must not specify the <code>*</code> wildcard&rdquo; for <code>Access-Control-Allow-Origin</code> and must instead name an explicit origin. The same rule applies to the allowed headers, allowed methods and exposed headers.</p>
<table>
  <thead>
      <tr>
          <th>Response headers</th>
          <th>Request without cookies</th>
          <th>Request with cookies</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>No CORS headers</td>
          <td>Response hidden from the calling script</td>
          <td>Response hidden</td>
      </tr>
      <tr>
          <td><code>Access-Control-Allow-Origin: *</code></td>
          <td>Any website can read the response</td>
          <td>Browser blocks access to the response</td>
      </tr>
      <tr>
          <td><code>Access-Control-Allow-Origin: https://app.example.com</code></td>
          <td>That origin can read it</td>
          <td>Hidden: credentials header missing</td>
      </tr>
      <tr>
          <td>Same, plus <code>Access-Control-Allow-Credentials: true</code></td>
          <td>That origin can read it</td>
          <td>That origin can read your user&rsquo;s authenticated data</td>
      </tr>
  </tbody>
</table>
<p>The last row is the one to get right. It is also where the wildcard rule pushes developers into the worst shortcut.</p>
<h2 id="the-five-misconfigurations-that-matter">The five misconfigurations that matter</h2>
<p>PortSwigger&rsquo;s Web Security Academy groups CORS vulnerabilities into a handful of classes. These are the ones that reach authenticated data.</p>
<h3 id="1-reflecting-any-origin">1. Reflecting any Origin</h3>
<p>The wildcard is refused for credentialed requests, so some servers copy the request&rsquo;s <code>Origin</code> header straight into <code>Access-Control-Allow-Origin</code> and add <code>Access-Control-Allow-Credentials: true</code>. PortSwigger describes this as the application that &ldquo;reflects arbitrary origins&rdquo;. The effect is identical to a wildcard that browsers would allow with cookies: any website a logged-in user visits can read their data from your API.</p>
<h3 id="2-matching-the-origin-with-a-prefix-suffix-or-loose-regex">2. Matching the Origin with a prefix, suffix or loose regex</h3>
<p>Allowlists implemented as string tests are easy to get wrong. We tested three common shortcuts in Python against a set of origins:</p>
<table>
  <thead>
      <tr>
          <th>Origin header</th>
          <th>Exact match against a list</th>
          <th><code>endswith(&quot;example.com&quot;)</code></th>
          <th><code>startswith(&quot;https://app.example.com&quot;)</code></th>
          <th>Regex <code>https://.*.example.com</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>https://app.example.com</code></td>
          <td>Allowed</td>
          <td>Allowed</td>
          <td>Allowed</td>
          <td>Allowed</td>
      </tr>
      <tr>
          <td><code>https://evilexample.com</code></td>
          <td>Rejected</td>
          <td><strong>Allowed</strong></td>
          <td>Rejected</td>
          <td><strong>Allowed</strong></td>
      </tr>
      <tr>
          <td><code>https://attacker-example.com</code></td>
          <td>Rejected</td>
          <td><strong>Allowed</strong></td>
          <td>Rejected</td>
          <td><strong>Allowed</strong></td>
      </tr>
      <tr>
          <td><code>https://app.example.com.evil.net</code></td>
          <td>Rejected</td>
          <td>Rejected</td>
          <td><strong>Allowed</strong></td>
          <td><strong>Allowed</strong></td>
      </tr>
      <tr>
          <td><code>http://app.example.com</code></td>
          <td>Rejected</td>
          <td><strong>Allowed</strong></td>
          <td>Rejected</td>
          <td>Rejected</td>
      </tr>
      <tr>
          <td><code>null</code></td>
          <td>Rejected</td>
          <td>Rejected</td>
          <td>Rejected</td>
          <td>Rejected</td>
      </tr>
  </tbody>
</table>
<p>Every bold cell is an origin an attacker can obtain by registering a domain, or in the last case by serving content over plain HTTP. The regex fails two ways at once: the dot before <code>example.com</code> is unescaped, so it matches any character, and Python&rsquo;s <code>re.match</code> anchors only the start of the string, so trailing text is accepted.</p>
<h3 id="3-allowing-the-null-origin">3. Allowing the <code>null</code> origin</h3>
<p>Some applications allow <code>Origin: null</code> to make local development work. PortSwigger notes that an attacker can produce a request carrying <code>Origin: null</code> from &ldquo;a sandboxed <code>iframe</code>&rdquo;. If <code>null</code> is on your list, so is every attacker who uses that trick. Its prevention advice is direct: &ldquo;Avoid using the header <code>Access-Control-Allow-Origin: null</code>.&rdquo;</p>
<h3 id="4-trusting-an-origin-that-can-be-compromised">4. Trusting an origin that can be compromised</h3>
<p>An allowlist is only as strong as the weakest origin on it. PortSwigger describes two cases. If a trusted origin has a cross-site scripting (XSS) flaw, an attacker can run script there and read your API through the trust you granted. If you trust an origin served over plain HTTP, such as <code>http://legacy.example.com</code>, an attacker who can intercept that traffic can inject script into it, even though your own site uses HTTPS everywhere.</p>
<h3 id="5-wildcards-on-internal-services">5. Wildcards on internal services</h3>
<p>Without credentials, the risk shifts to networks. PortSwigger explains that most CORS attacks rely on credentials, but internal sites, which are &ldquo;often held to a lower security standard&rdquo;, can be read through a visitor&rsquo;s browser when they send <code>Access-Control-Allow-Origin: *</code>. A public website can use an employee&rsquo;s browser as a proxy to reach an intranet page that trusts its own network. PortSwigger&rsquo;s advice is to &ldquo;avoid using wildcards in internal networks&rdquo;.</p>
<h2 id="the-fix-an-exact-allowlist">The fix: an exact allowlist</h2>
<p>This is a Flask hook that returns CORS headers only for exact matches. We ran it with Flask 3.1 on Python 3.13; it is a minimal illustration.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> flask <span style="color:#f92672">import</span> Flask, request
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> Flask(__name__)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ALLOWED_ORIGINS <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;https://app.example.com&#34;</span>, <span style="color:#e6db74">&#34;https://admin.example.com&#34;</span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.after_request</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">add_cors_headers</span>(response):
</span></span><span style="display:flex;"><span>    origin <span style="color:#f92672">=</span> request<span style="color:#f92672">.</span>headers<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;Origin&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> origin <span style="color:#f92672">in</span> ALLOWED_ORIGINS:
</span></span><span style="display:flex;"><span>        response<span style="color:#f92672">.</span>headers[<span style="color:#e6db74">&#34;Access-Control-Allow-Origin&#34;</span>] <span style="color:#f92672">=</span> origin
</span></span><span style="display:flex;"><span>        response<span style="color:#f92672">.</span>headers[<span style="color:#e6db74">&#34;Access-Control-Allow-Credentials&#34;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;true&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># The response differs by Origin either way, so caches must key on it.</span>
</span></span><span style="display:flex;"><span>    response<span style="color:#f92672">.</span>vary<span style="color:#f92672">.</span>add(<span style="color:#e6db74">&#34;Origin&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> response
</span></span></code></pre></div><p>In our tests with Flask&rsquo;s test client, a request from <code>https://app.example.com</code> received <code>Access-Control-Allow-Origin: https://app.example.com</code> and <code>Access-Control-Allow-Credentials: true</code>. A request from <code>https://evil.example</code> and a request with no <code>Origin</code> header received neither header. All three responses carried <code>Vary: Origin</code>.</p>
<p>Why each line is there:</p>
<ul>
<li><strong>Set membership, not string tests.</strong> The comparison is the whole origin string, scheme and port included. Add each origin you need, including each environment&rsquo;s front end, and nothing else.</li>
<li><strong><code>Vary: Origin</code> on every response.</strong> MDN advises it whenever the allowed origin changes based on the request. Without it, a shared cache can store a response that carries one origin&rsquo;s headers and serve it to another.</li>
<li><strong>Never add <code>null</code>.</strong> If local development needs CORS, add <code>http://localhost:3000</code> (or your port) in development configuration only.</li>
<li><strong>Only send <code>Access-Control-Allow-Credentials</code> where needed.</strong> A public endpoint that serves the same data to everyone can use <code>*</code> without credentials.</li>
<li><strong>Preflight responses need the same check.</strong> Frameworks and CORS middleware usually answer <code>OPTIONS</code> for you. Confirm that they use the same allowlist and list only the methods and headers you actually accept.</li>
</ul>
<p>If you use a CORS library, check how it interprets its configuration. Some accept a regex or a function, which reintroduces the matching problems in the table above.</p>
<h2 id="how-to-test-your-own-api">How to test your own API</h2>
<p>Send a request with a foreign <code>Origin</code> and read the response headers. These are read-only GET requests; run them only against systems you are authorized to test.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -s -o /dev/null -D - -H <span style="color:#e6db74">&#34;Origin: https://evil.example&#34;</span> https://api.example.com/me
</span></span><span style="display:flex;"><span>curl -s -o /dev/null -D - -H <span style="color:#e6db74">&#34;Origin: null&#34;</span> https://api.example.com/me
</span></span><span style="display:flex;"><span>curl -s -o /dev/null -D - -H <span style="color:#e6db74">&#34;Origin: https://evilexample.com&#34;</span> https://api.example.com/me
</span></span></code></pre></div><p>Any response that echoes the test origin in <code>Access-Control-Allow-Origin</code> while also sending <code>Access-Control-Allow-Credentials: true</code> is a finding. Test the endpoints that return personal data, not only the home page, because CORS is often configured per route.</p>
<h2 id="what-cors-does-not-do">What CORS does not do</h2>
<ul>
<li><strong>It does not stop CSRF.</strong> A cross-site form submission or simple request is still sent, with cookies, subject to SameSite rules. CORS only hides the response. Our guide to <a href="/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/">CSRF protection with SameSite cookies, tokens and Fetch Metadata</a> covers that side.</li>
<li><strong>It does not authenticate anyone.</strong> It is a browser rule. Server-side authorization still decides who can see what.</li>
<li><strong>It is not related to SSRF.</strong> Server-side request forgery is a server making requests on an attacker&rsquo;s behalf, covered in <a href="/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/">SSRF explained</a>.</li>
</ul>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 8, 2026. We tested the matching functions and the Flask example with Flask&rsquo;s test client on Python 3.13, not in real browsers.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CORS definition; simple request conditions; preflight; credentials not sent by default; wildcard not allowed for credentialed requests; browser blocks the response; <code>Vary: Origin</code> advice</td>
          <td><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS">MDN, Cross-Origin Resource Sharing (CORS)</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>CORS-safelisted methods are GET, HEAD and POST; safelisted <code>Content-Type</code> values</td>
          <td><a href="https://fetch.spec.whatwg.org/">Fetch Standard</a>, section 2.2</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Vulnerability classes: arbitrary origin reflection, origin parsing errors, <code>null</code> origin via sandboxed iframe, XSS on trusted origins, trusted HTTP origins, internal networks without credentials; prevention advice</td>
          <td><a href="https://portswigger.net/web-security/cors">PortSwigger Web Security Academy, CORS</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Results of the four matching approaches in the table</td>
          <td>Run in our build environment, Python 3.13</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Flask example response headers</td>
          <td>Run with Flask 3.1 test client</td>
          <td>Verified: not tested in browsers</td>
      </tr>
      <tr>
          <td>Some CORS libraries accept regexes or functions</td>
          <td>General observation</td>
          <td>Qualified: check your library&rsquo;s documentation</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>CSRF Protection: SameSite Cookies, Tokens and Fetch Metadata Compared</title><link>https://firewallsync.com/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/</link><guid>https://firewallsync.com/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/</guid><pubDate>Wed, 07 Oct 2026 08:35:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>SameSite cookies reduce cross-site request forgery but do not end it. Here is what each defense stops, where each one fails, and a header check you can add in a few lines.</description><enclosure url="https://firewallsync.com/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/cover_hu11107870620667495162.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/cover_hu11107870620667495162.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/cover_hu11107870620667495162.jpg" alt="Laptop on a desk showing code in an editor" width="1200" height="630"></p><p>Cross-site request forgery (CSRF) is still possible in 2026, but the defaults have moved in your favor. If your session cookie is explicitly set to <code>SameSite=Lax</code> or <code>Strict</code>, every state-changing endpoint uses POST or another non-GET method, and you reject requests the browser labels as cross-origin, the classic attack is closed. Each of those three conditions is one that real applications miss, so this article goes through what each defense stops and where it fails.</p>
<h2 id="what-the-attack-needs">What the attack needs</h2>
<p>A CSRF attack has three ingredients: a victim logged in to your site, a browser that attaches the victim&rsquo;s cookies to requests automatically, and a request that an attacker&rsquo;s page can make the browser send. The attacker&rsquo;s page never sees the response. It only needs the side effect: an email address changed, a transfer submitted, an API key created.</p>
<p>Every defense below works by breaking one of the last two ingredients. Either the cookie is not attached, or the server can tell the request did not come from its own pages.</p>
<h2 id="the-four-defenses-compared">The four defenses compared</h2>
<table>
  <thead>
      <tr>
          <th></th>
          <th>SameSite cookies</th>
          <th>CSRF token</th>
          <th>Fetch Metadata check</th>
          <th>Custom header requirement</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Where it runs</td>
          <td>Browser decides whether to send the cookie</td>
          <td>Server compares a secret value</td>
          <td>Server reads <code>Sec-Fetch-Site</code></td>
          <td>Server requires a header a plain form cannot send</td>
      </tr>
      <tr>
          <td>What you change</td>
          <td>One cookie attribute</td>
          <td>Every form and state-changing request</td>
          <td>One middleware</td>
          <td>Client code and one server check</td>
      </tr>
      <tr>
          <td>Granularity</td>
          <td>Site (registrable domain plus scheme)</td>
          <td>Session</td>
          <td>Origin or site, your choice</td>
          <td>Origin, enforced by CORS</td>
      </tr>
      <tr>
          <td>Protects GET endpoints that change state</td>
          <td>No, with Lax</td>
          <td>Only if you check tokens on GET</td>
          <td>Only if you check GET</td>
          <td>Only if required on GET</td>
      </tr>
      <tr>
          <td>Stops a sibling subdomain</td>
          <td>No</td>
          <td>Yes, if tied to the session</td>
          <td>Yes, if you reject <code>same-site</code></td>
          <td>Yes, unless CORS allows that origin</td>
      </tr>
      <tr>
          <td>Works without browser support</td>
          <td>No</td>
          <td>Yes</td>
          <td>No, needs a fallback</td>
          <td>Yes</td>
      </tr>
      <tr>
          <td>Server state needed</td>
          <td>None</td>
          <td>Session, or a signing key</td>
          <td>None</td>
          <td>None</td>
      </tr>
  </tbody>
</table>
<p>&ldquo;Site&rdquo; and &ldquo;origin&rdquo; are different things, and the gap between them is where several bypasses live. An origin is the scheme, host and port together: <code>https://app.example.com</code>. A site is the scheme plus the registrable domain: <code>https://example.com</code>. Two subdomains of one company are different origins on the same site.</p>
<h2 id="samesite-what-it-does-and-the-three-gaps">SameSite: what it does and the three gaps</h2>
<p>MDN describes the three values of the cookie&rsquo;s <code>SameSite</code> attribute:</p>
<ul>
<li><strong>Strict</strong> sends the cookie only for requests originating from the same site.</li>
<li><strong>Lax</strong> also sends it on cross-site requests that are top-level navigations using a safe method. In practice that means following a link, not submitting a cross-site POST form.</li>
<li><strong>None</strong> sends it everywhere, and requires the <code>Secure</code> attribute.</li>
</ul>
<p>For a session cookie, <code>Lax</code> means an attacker&rsquo;s page can no longer submit a hidden POST form with your user&rsquo;s session attached. That removes the textbook attack. Three gaps remain.</p>
<p><strong>Gap 1: GET requests that change state.</strong> <code>Lax</code> deliberately allows cookies on cross-site top-level GET navigations, so that links to your site work for logged-in users. If <code>/account/delete?confirm=1</code> works as a GET, a link is enough. OWASP&rsquo;s cheat sheet is blunt about the fix: never use GET for state changes.</p>
<p><strong>Gap 2: the default is weaker than the explicit setting.</strong> Some browsers apply <code>Lax</code> when a cookie has no <code>SameSite</code> attribute at all. MDN notes that &ldquo;when <code>Lax</code> is applied as a default, a more permissive version is used&rdquo;, in which cookies are also sent on cross-site POST requests &ldquo;as long as they were set no more than two minutes before the request was made.&rdquo; That means a cookie issued moments ago, for example right after login, is exposed. Browsers also differ on whether they apply the default at all. The fix costs one attribute: set <code>SameSite=Lax</code> or <code>Strict</code> explicitly.</p>
<p><strong>Gap 3: same-site is not same-origin.</strong> <code>SameSite</code> compares sites. A request from <code>https://blog.example.com</code> to <code>https://app.example.com</code> is same-site, so the cookie is sent whatever the setting. If any subdomain can run attacker-controlled script, through an abandoned DNS record, a user-content host or a cross-site scripting flaw in a marketing page, <code>SameSite</code> gives no protection against it. OWASP lists this registrable-domain behavior among the reasons it treats <code>SameSite</code> as defense in depth and not a standalone defense.</p>
<h2 id="csrf-tokens-still-the-reference-defense">CSRF tokens: still the reference defense</h2>
<p>A CSRF token is a secret value the server gives its own pages and expects back on state-changing requests. An attacker&rsquo;s page cannot read it, so it cannot include it.</p>
<p>OWASP&rsquo;s cheat sheet describes two patterns:</p>
<ul>
<li><strong>Synchronizer token.</strong> The server stores a random token in the user&rsquo;s session and compares it on each protected request. It should be sent in a hidden form field or a custom header, not in a cookie or URL.</li>
<li><strong>Signed double-submit cookie.</strong> For applications without server-side session storage. The token is an HMAC (a keyed hash) computed over the session identifier plus a random value, sent both as a cookie and in the request. OWASP recommends this signed form and warns that the naive version, a bare random value in a cookie compared with a request field, can be defeated by an attacker who is able to set cookies from a subdomain.</li>
</ul>
<p>OWASP&rsquo;s ordering of advice is: use your framework&rsquo;s built-in CSRF protection if it has one; otherwise add tokens to state-changing requests and validate them on the server; consider Fetch Metadata headers with a fallback; then add defense-in-depth measures such as <code>SameSite</code>.</p>
<p>Tokens cost more to maintain than the alternatives. Every form, every JavaScript request and every cached page has to carry the right token, and &ldquo;invalid CSRF token&rdquo; errors after a session expires are a familiar support ticket. That maintenance cost is why the header-based approach below has gained ground. This is our assessment of the trade-off, not OWASP&rsquo;s wording.</p>
<h2 id="fetch-metadata-let-the-browser-tell-you">Fetch Metadata: let the browser tell you</h2>
<p>Browsers attach a set of <code>Sec-Fetch-*</code> headers to requests. The one that matters here is <code>Sec-Fetch-Site</code>, which MDN defines as indicating &ldquo;the relationship between a request initiator&rsquo;s origin and the origin of the requested resource.&rdquo; Its values:</p>
<table>
  <thead>
      <tr>
          <th>Value</th>
          <th>Meaning</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>same-origin</code></td>
          <td>The page making the request has the same scheme, host and port as the server</td>
      </tr>
      <tr>
          <td><code>same-site</code></td>
          <td>Same site, different origin, for example another subdomain</td>
      </tr>
      <tr>
          <td><code>cross-site</code></td>
          <td>A different site</td>
      </tr>
      <tr>
          <td><code>none</code></td>
          <td>A user-initiated action, such as typing a URL or opening a bookmark</td>
      </tr>
  </tbody>
</table>
<p>Two properties make it usable as a security control. It is a forbidden request header, so page JavaScript cannot set or alter it. And MDN lists it as available across browsers since March 2023.</p>
<p>One limit matters: MDN notes the header is only sent to potentially trustworthy URLs, which in practice means HTTPS. Over plain HTTP it is absent.</p>
<p>The check itself is short: for any request that is not GET, HEAD or OPTIONS, reject it unless <code>Sec-Fetch-Site</code> is <code>same-origin</code> or <code>none</code>.</p>
<h3 id="what-gos-standard-library-does">What Go&rsquo;s standard library does</h3>
<p>Go 1.25, released in August 2025, added <code>net/http.CrossOriginProtection</code>. The release notes describe it as rejecting &ldquo;non-safe cross-origin browser requests&rdquo; using Fetch metadata, and say it &ldquo;doesn&rsquo;t require tokens or cookies&rdquo;. Its logic, as we read the Go 1.25 source, is a useful reference whatever language you use:</p>
<ol>
<li>GET, HEAD and OPTIONS are always allowed.</li>
<li>If <code>Sec-Fetch-Site</code> is present, the request is allowed only when the value is <code>same-origin</code> or <code>none</code>.</li>
<li>If <code>Sec-Fetch-Site</code> is absent but <code>Origin</code> is present, the <code>Origin</code> host is compared with the <code>Host</code> header, and the request is allowed if they match.</li>
<li>If neither header is present, the request is allowed, on the reasoning that it is either same-origin or not from a browser.</li>
<li>Explicitly trusted origins and bypass patterns are exempt.</li>
</ol>
<p>Notice that step 2 rejects <code>same-site</code>. That closes Gap 3: a sibling subdomain is treated as untrusted unless you list it. Google&rsquo;s older web.dev guidance on Fetch Metadata, published in June 2020, allows <code>same-site</code> in its example policy. Which is right for you depends on whether you trust everything hosted under your domain. If you are unsure, reject <code>same-site</code> and add specific origins back.</p>
<h3 id="a-working-example-in-python">A working example in Python</h3>
<p>The same logic as a Flask <code>before_request</code> hook. We ran this with Flask 3.1 on Python 3.13; it is a minimal illustration, not a drop-in library.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> urllib.parse <span style="color:#f92672">import</span> urlsplit
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> flask <span style="color:#f92672">import</span> Flask, abort, request
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> Flask(__name__)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>SAFE_METHODS <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;GET&#34;</span>, <span style="color:#e6db74">&#34;HEAD&#34;</span>, <span style="color:#e6db74">&#34;OPTIONS&#34;</span>}
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Origins other than our own that may send state-changing requests, e.g. a separate admin UI.</span>
</span></span><span style="display:flex;"><span>TRUSTED_ORIGINS <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;https://admin.example.com&#34;</span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">is_cross_origin</span>(req) <span style="color:#f92672">-&gt;</span> bool:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;True when a browser tells us the request came from another origin.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> req<span style="color:#f92672">.</span>headers<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;Origin&#34;</span>) <span style="color:#f92672">in</span> TRUSTED_ORIGINS:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>    site <span style="color:#f92672">=</span> req<span style="color:#f92672">.</span>headers<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;Sec-Fetch-Site&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> site <span style="color:#f92672">is</span> <span style="color:#f92672">not</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># same-origin: our own pages. none: typed URL or bookmark.</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> site <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> (<span style="color:#e6db74">&#34;same-origin&#34;</span>, <span style="color:#e6db74">&#34;none&#34;</span>)
</span></span><span style="display:flex;"><span>    origin <span style="color:#f92672">=</span> req<span style="color:#f92672">.</span>headers<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;Origin&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> origin <span style="color:#f92672">is</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># Neither header: not a current browser (curl, server-to-server).</span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># CSRF needs a browser that attaches the victim&#39;s cookies.</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">False</span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e"># Older browser: compare the Origin host with the Host we were addressed as.</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> urlsplit(origin)<span style="color:#f92672">.</span>netloc <span style="color:#f92672">!=</span> req<span style="color:#f92672">.</span>host
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.before_request</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">reject_cross_origin_writes</span>():
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> request<span style="color:#f92672">.</span>method <span style="color:#f92672">in</span> SAFE_METHODS:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> is_cross_origin(request):
</span></span><span style="display:flex;"><span>        abort(<span style="color:#ae81ff">403</span>, <span style="color:#e6db74">&#34;cross-origin request rejected&#34;</span>)
</span></span></code></pre></div><p>We tested it with Flask&rsquo;s test client against a POST route and a GET route:</p>
<table>
  <thead>
      <tr>
          <th>Request</th>
          <th>Headers sent</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>POST from the application&rsquo;s own page</td>
          <td><code>Sec-Fetch-Site: same-origin</code></td>
          <td>200</td>
      </tr>
      <tr>
          <td>POST from another site</td>
          <td><code>Sec-Fetch-Site: cross-site</code></td>
          <td>403</td>
      </tr>
      <tr>
          <td>POST from a sibling subdomain</td>
          <td><code>Sec-Fetch-Site: same-site</code></td>
          <td>403</td>
      </tr>
      <tr>
          <td>POST from the listed admin origin</td>
          <td><code>Sec-Fetch-Site: same-site</code>, trusted <code>Origin</code></td>
          <td>200</td>
      </tr>
      <tr>
          <td>POST with no <code>Sec-Fetch-Site</code>, matching <code>Origin</code></td>
          <td><code>Origin</code> equals host</td>
          <td>200</td>
      </tr>
      <tr>
          <td>POST with no <code>Sec-Fetch-Site</code>, foreign <code>Origin</code></td>
          <td><code>Origin</code> differs</td>
          <td>403</td>
      </tr>
      <tr>
          <td>POST with no <code>Sec-Fetch-Site</code>, <code>Origin: null</code></td>
          <td>Sandboxed or opaque origin</td>
          <td>403</td>
      </tr>
      <tr>
          <td>POST with neither header</td>
          <td>As sent by curl</td>
          <td>200</td>
      </tr>
      <tr>
          <td>GET from another site</td>
          <td><code>Sec-Fetch-Site: cross-site</code></td>
          <td>200</td>
      </tr>
  </tbody>
</table>
<p>The last two rows are the limits, and they are deliberate:</p>
<ul>
<li><strong>Requests with neither header pass.</strong> A command-line client or another server does not carry a victim&rsquo;s browser cookies, so there is nothing to forge. This does mean the check is not an authentication or anti-automation control.</li>
<li><strong>GET is not checked.</strong> The hook protects nothing if a GET route changes state.</li>
<li><strong>The <code>Host</code> comparison trusts your proxy setup.</strong> Behind a reverse proxy, the application must see the hostname the browser used. OWASP&rsquo;s cheat sheet advises accepting a forwarded host header only from proxies you control.</li>
<li><strong>Plain HTTP gets the weaker path.</strong> Without HTTPS the browser omits <code>Sec-Fetch-Site</code> and the check falls back to <code>Origin</code>.</li>
</ul>
<h2 id="what-none-of-these-stop">What none of these stop</h2>
<p><strong>Cross-site scripting.</strong> OWASP states that cross-site scripting (XSS) vulnerabilities can bypass CSRF protections. Script running in your own origin sends requests that are genuinely same-origin, can read tokens from the page, and gets the right <code>Sec-Fetch-Site</code> value. CSRF defenses assume your own pages are not hostile.</p>
<p><strong>Client-side CSRF.</strong> OWASP describes a variant where your own JavaScript builds a request from attacker-influenced input, such as a URL fragment. The request is same-origin and carries a valid token, so server-side checks pass. The fix is validating what the script uses to build requests.</p>
<p><strong>Login CSRF.</strong> An attacker can submit a login form with their own credentials, leaving the victim signed in to the attacker&rsquo;s account. OWASP recommends protecting login forms too, using a pre-session, and regenerating the session after authentication.</p>
<h2 id="what-to-do">What to do</h2>
<p>This table is our recommendation, built on the sources above.</p>
<table>
  <thead>
      <tr>
          <th>Your situation</th>
          <th>Recommended setup</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Framework with built-in CSRF protection (for example Django, Rails, ASP.NET)</td>
          <td>Leave it on. Set the session cookie&rsquo;s <code>SameSite</code> explicitly. Audit for GET routes that change state</td>
      </tr>
      <tr>
          <td>Go 1.25 or later</td>
          <td>Wrap handlers with <code>http.CrossOriginProtection</code>; list trusted origins explicitly</td>
      </tr>
      <tr>
          <td>No built-in protection, HTTPS everywhere</td>
          <td>Add the Fetch Metadata check with the <code>Origin</code> fallback; set <code>SameSite=Lax</code> or <code>Strict</code>; keep or add tokens on the highest-risk actions</td>
      </tr>
      <tr>
          <td>Cookie-authenticated JSON API</td>
          <td>The Fetch Metadata check, plus rejecting state-changing requests that are not <code>application/json</code>, plus a restrictive CORS policy that never combines a wildcard origin with credentials</td>
      </tr>
      <tr>
          <td>Token-only API (<code>Authorization</code> header, no cookies)</td>
          <td>Classic CSRF does not apply. Confirm no cookie-based path exists</td>
      </tr>
      <tr>
          <td>Untrusted or user-controlled subdomains</td>
          <td>Treat <code>same-site</code> as cross-site everywhere; do not rely on <code>SameSite</code> cookies</td>
      </tr>
  </tbody>
</table>
<p>Three checks apply in every row:</p>
<ol>
<li>Search your routes for GET handlers with side effects and convert them.</li>
<li>Set <code>SameSite</code> explicitly on session cookies, with <code>Secure</code> and <code>HttpOnly</code>.</li>
<li>Fix XSS first. It defeats everything on this page.</li>
</ol>
<p>The session cookie being protected here is the same one an attacker wants to steal outright. For the token side of that problem, see <a href="/posts/how-to-secure-oauth-tokens-in-production/">how to secure OAuth tokens in production</a>, and for the credentials behind the session, <a href="/posts/password-hashing-argon2id-vs-bcrypt-vs-scrypt-which-to-use-and-with-what-parameters/">password hashing parameters</a>. If you arrived looking for the server-side attack with the similar name, that is <a href="/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/">SSRF, explained here</a>.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 7, 2026. Browser behavior changes between releases, so confirm the SameSite defaults for the browsers you support against MDN&rsquo;s compatibility data. We tested the Python example with Flask&rsquo;s test client, not in real browsers.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SameSite Strict, Lax and None definitions; None requires Secure; default-Lax two-minute POST allowance</td>
          <td><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie">MDN, Set-Cookie</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Sec-Fetch-Site values; forbidden request header; available across browsers since March 2023; sent only to potentially trustworthy URLs</td>
          <td><a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site">MDN, Sec-Fetch-Site</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>OWASP ordering of defenses; synchronizer and signed double-submit patterns; SameSite as defense in depth; never use GET for state changes; XSS bypasses CSRF protections; client-side and login CSRF</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html">OWASP CSRF Prevention Cheat Sheet</a>, as published October 7, 2026</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Go 1.25 added CrossOriginProtection; released August 2025</td>
          <td><a href="https://go.dev/doc/go1.25">Go 1.25 release notes</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>CrossOriginProtection logic (safe methods, Sec-Fetch-Site, Origin and Host fallback, neither header allowed)</td>
          <td><a href="https://github.com/golang/go/blob/release-branch.go1.25/src/net/http/csrf.go">Go 1.25 source, net/http/csrf.go</a></td>
          <td>Verified: our reading of the source</td>
      </tr>
      <tr>
          <td>web.dev example policy allows same-site</td>
          <td><a href="https://web.dev/articles/fetch-metadata">web.dev, Protect your resources from web attacks with Fetch Metadata</a>, June 4, 2020</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Python example behavior in the results table</td>
          <td>Run in our build environment with Flask 3.1 on Python 3.13</td>
          <td>Verified: test client only, not real browsers</td>
      </tr>
      <tr>
          <td>Browsers differ on applying Lax by default</td>
          <td>MDN compatibility notes for Set-Cookie</td>
          <td>Qualified: we did not list per-browser behavior</td>
      </tr>
      <tr>
          <td>Framework examples with built-in CSRF protection</td>
          <td>General knowledge of Django, Rails and ASP.NET; OWASP names .NET and Go 1.25</td>
          <td>Qualified: check your framework&rsquo;s documentation</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Argon2id vs bcrypt vs scrypt: OWASP Parameters to Use</title><link>https://firewallsync.com/posts/password-hashing-argon2id-vs-bcrypt-vs-scrypt-which-to-use-and-with-what-parameters/</link><guid>https://firewallsync.com/posts/password-hashing-argon2id-vs-bcrypt-vs-scrypt-which-to-use-and-with-what-parameters/</guid><pubDate>Sun, 04 Oct 2026 06:35:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>Use Argon2id for new systems, keep bcrypt if you already have it and handle its 72-byte limit, and use PBKDF2 only when FIPS requires it. The harder part is choosing parameters your login servers can afford.</description><enclosure url="https://firewallsync.com/posts/password-hashing-argon2id-vs-bcrypt-vs-scrypt-which-to-use-and-with-what-parameters/cover_hu9040332994792319567.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/password-hashing-argon2id-vs-bcrypt-vs-scrypt-which-to-use-and-with-what-parameters/cover_hu9040332994792319567.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/password-hashing-argon2id-vs-bcrypt-vs-scrypt-which-to-use-and-with-what-parameters/cover_hu9040332994792319567.jpg" alt="Heavy steel bank vault door with a large circular locking wheel" width="1200" height="630"></p><p>For a new application, hash passwords with Argon2id. If your platform has no good Argon2id library, use scrypt. If you already use bcrypt with a work factor of 10 or more, you can keep it, provided you deal with its 72-byte input limit. Use PBKDF2 only when you need FIPS-validated cryptography. That ordering is from the OWASP <a href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">Password Storage Cheat Sheet</a>, and the rest of this article is about the parameters, because choosing the algorithm is the easy part.</p>
<h2 id="what-a-password-hash-has-to-do">What a password hash has to do</h2>
<p>A password hashing function turns a password into a value you can store and later compare against, without storing the password. OWASP&rsquo;s reasoning for hashing rather than encrypting is that hashing is &ldquo;a one-way function&rdquo;, so there is no key that turns the database back into passwords. (For where encryption is the right tool, see <a href="/posts/symmetric-vs-asymmetric-encryption-when-to-use-each/">symmetric vs asymmetric encryption</a>.)</p>
<p>One-way is not enough. If your user table leaks, an attacker guesses candidate passwords, hashes each guess and compares. The defense is to make every guess expensive. Three properties do that:</p>
<ul>
<li><strong>A unique salt per password.</strong> A salt is a random value mixed into each hash, so identical passwords produce different hashes and each account has to be attacked separately.</li>
<li><strong>A tunable work factor.</strong> A setting that makes each hash slower, which you raise as hardware improves.</li>
<li><strong>Memory hardness.</strong> A requirement that each hash uses a set amount of RAM. This matters because graphics cards and custom chips run enormous numbers of cheap computations in parallel, and memory is the resource that is hard to multiply.</li>
</ul>
<p>General-purpose hashes such as SHA-256 have none of the last two. On the machine we used for the timings below, plain Python computed 1,000,000 SHA-256 hashes in about 0.43 seconds on one core. A single Argon2id hash at OWASP&rsquo;s minimum setting took about 25 milliseconds on the same machine. That gap is the point of the exercise.</p>
<h2 id="the-four-options-compared">The four options compared</h2>
<table>
  <thead>
      <tr>
          <th></th>
          <th>Argon2id</th>
          <th>scrypt</th>
          <th>bcrypt</th>
          <th>PBKDF2</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Specified in</td>
          <td>RFC 9106 (September 2021)</td>
          <td>RFC 7914 (August 2016)</td>
          <td>Provos and Mazières, USENIX 1999</td>
          <td>NIST SP 800-132</td>
      </tr>
      <tr>
          <td>Tunable time cost</td>
          <td>Yes (<code>t</code>, iterations)</td>
          <td>Yes (<code>N</code>)</td>
          <td>Yes (work factor)</td>
          <td>Yes (iterations)</td>
      </tr>
      <tr>
          <td>Tunable memory cost</td>
          <td>Yes (<code>m</code>), independent of time</td>
          <td>Yes, but tied to <code>N</code> and <code>r</code></td>
          <td>No memory setting</td>
          <td>No memory setting</td>
      </tr>
      <tr>
          <td>Input length limit</td>
          <td>No practical limit</td>
          <td>No practical limit</td>
          <td>72 bytes in most implementations</td>
          <td>No practical limit</td>
      </tr>
      <tr>
          <td>OWASP position</td>
          <td>First choice</td>
          <td>Use when Argon2id is not available</td>
          <td>Legacy systems</td>
          <td>When FIPS compliance is required</td>
      </tr>
      <tr>
          <td>OWASP minimum</td>
          <td>19 MiB, 2 iterations, parallelism 1</td>
          <td>N=2^17, r=8, p=1</td>
          <td>Work factor 10</td>
          <td>600,000 iterations with HMAC-SHA-256</td>
      </tr>
  </tbody>
</table>
<p>Argon2 won the Password Hashing Competition, an open contest that ran from 2012 and announced its winner in July 2015. It has three variants. RFC 9106 states that &ldquo;Argon2id MUST be supported by any implementation&rdquo;, and OWASP recommends that variant because &ldquo;it provides a balanced approach to resisting both side-channel and GPU-based attacks.&rdquo; Unless you have a specific reason, do not choose Argon2i or Argon2d.</p>
<h2 id="parameters-the-numbers-and-where-they-come-from">Parameters: the numbers and where they come from</h2>
<h3 id="argon2id">Argon2id</h3>
<p>Argon2id has three settings: memory <code>m</code> (in kibibytes), iterations <code>t</code>, and parallelism <code>p</code> (the number of lanes, which maps to threads).</p>
<p>OWASP lists five combinations that it says give an equal level of defense, trading memory for CPU time:</p>
<table>
  <thead>
      <tr>
          <th>Memory <code>m</code></th>
          <th>Iterations <code>t</code></th>
          <th>Parallelism <code>p</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>47104 KiB (46 MiB)</td>
          <td>1</td>
          <td>1</td>
      </tr>
      <tr>
          <td>19456 KiB (19 MiB)</td>
          <td>2</td>
          <td>1</td>
      </tr>
      <tr>
          <td>12288 KiB (12 MiB)</td>
          <td>3</td>
          <td>1</td>
      </tr>
      <tr>
          <td>9216 KiB (9 MiB)</td>
          <td>4</td>
          <td>1</td>
      </tr>
      <tr>
          <td>7168 KiB (7 MiB)</td>
          <td>5</td>
          <td>1</td>
      </tr>
  </tbody>
</table>
<p>RFC 9106 recommends considerably more:</p>
<table>
  <thead>
      <tr>
          <th>RFC 9106 option</th>
          <th>Memory</th>
          <th>Iterations</th>
          <th>Lanes</th>
          <th>Salt</th>
          <th>Output</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>First recommended</td>
          <td>2 GiB</td>
          <td>1</td>
          <td>4</td>
          <td>128 bits</td>
          <td>256 bits</td>
      </tr>
      <tr>
          <td>Second recommended</td>
          <td>64 MiB</td>
          <td>3</td>
          <td>4</td>
          <td>128 bits</td>
          <td>256 bits</td>
      </tr>
  </tbody>
</table>
<p>These two sources are not in conflict. OWASP states a minimum. The RFC states what its authors recommend when memory is available. A reasonable reading: treat the OWASP row as the floor you must not go below, and the RFC&rsquo;s second option as the target if your servers can carry it.</p>
<p>Your library has an opinion too. The Python library <code>argon2-cffi</code> (version 25.1.0) defaults to 64 MiB, 3 iterations and parallelism 4, which its documentation identifies as the RFC 9106 low-memory profile. Other libraries default to other values, so read the defaults rather than assuming them.</p>
<h3 id="scrypt">scrypt</h3>
<p>scrypt has a CPU and memory cost <code>N</code>, a block size <code>r</code> and a parallelization setting <code>p</code>. Memory and time are both driven by <code>N</code>, so you cannot raise one without the other. OWASP&rsquo;s equivalent options:</p>
<table>
  <thead>
      <tr>
          <th><code>N</code></th>
          <th>Memory (per OWASP)</th>
          <th><code>r</code></th>
          <th><code>p</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>2^17</td>
          <td>128 MiB</td>
          <td>8</td>
          <td>1</td>
      </tr>
      <tr>
          <td>2^16</td>
          <td>64 MiB</td>
          <td>8</td>
          <td>2</td>
      </tr>
      <tr>
          <td>2^15</td>
          <td>32 MiB</td>
          <td>8</td>
          <td>3</td>
      </tr>
      <tr>
          <td>2^14</td>
          <td>16 MiB</td>
          <td>8</td>
          <td>5</td>
      </tr>
      <tr>
          <td>2^13</td>
          <td>8 MiB</td>
          <td>8</td>
          <td>10</td>
      </tr>
  </tbody>
</table>
<h3 id="bcrypt">bcrypt</h3>
<p>bcrypt has one setting, the work factor (also called cost or rounds). Each increase of one doubles the work. OWASP says the work factor &ldquo;should be as large as verification server performance will allow, with a minimum of 10.&rdquo;</p>
<p>bcrypt has no memory setting. That is the main technical reason it is no longer the first choice: its cost can only be raised in time, not in RAM.</p>
<h3 id="pbkdf2">PBKDF2</h3>
<p>OWASP&rsquo;s iteration counts depend on the inner hash: 600,000 for PBKDF2-HMAC-SHA256, 220,000 for PBKDF2-HMAC-SHA512, and 1,400,000 for PBKDF2-HMAC-SHA1 (legacy only). Like bcrypt, PBKDF2 has no memory cost.</p>
<h3 id="what-nist-requires">What NIST requires</h3>
<p>NIST SP 800-63B-4, the current US federal digital identity guideline, does not name an algorithm in its password verifier requirements. It says passwords &ldquo;SHALL be salted and hashed using a suitable password hashing scheme&rdquo;, that the cost factor &ldquo;SHOULD be as high as practical without negatively impacting verifier performance&rdquo;, and that the salt &ldquo;SHALL be at least 32 bits in length&rdquo;. It points to NIST SP 800-132, the PBKDF2 specification, for approved schemes, which is why PBKDF2 is the answer when a contract requires FIPS-validated cryptography.</p>
<h2 id="what-these-settings-cost-measured-timings">What these settings cost: measured timings</h2>
<p>We ran each configuration on one machine to show relative cost. This is a single measurement on a small cloud virtual machine with 2 virtual CPUs, using Python 3.13, <code>argon2-cffi</code> 25.1.0, <code>bcrypt</code> 5.0.0 and the standard library&rsquo;s <code>hashlib</code>. Each figure is the median of five or seven runs. Your hardware will give different absolute numbers; measure on your own login servers.</p>
<table>
  <thead>
      <tr>
          <th>Configuration</th>
          <th>Time per hash (this machine)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Argon2id, 19 MiB, t=2, p=1 (OWASP minimum)</td>
          <td>about 25 ms</td>
      </tr>
      <tr>
          <td>Argon2id, 46 MiB, t=1, p=1 (OWASP alternative)</td>
          <td>about 48 ms</td>
      </tr>
      <tr>
          <td>Argon2id, 64 MiB, t=3, p=4 (RFC 9106 second option)</td>
          <td>about 115 ms</td>
      </tr>
      <tr>
          <td>bcrypt, work factor 10</td>
          <td>about 68 ms</td>
      </tr>
      <tr>
          <td>bcrypt, work factor 12</td>
          <td>about 274 ms</td>
      </tr>
      <tr>
          <td>bcrypt, work factor 14</td>
          <td>about 1,064 ms</td>
      </tr>
      <tr>
          <td>scrypt, N=2^17, r=8, p=1</td>
          <td>about 404 ms</td>
      </tr>
      <tr>
          <td>PBKDF2-HMAC-SHA256, 600,000 iterations</td>
          <td>about 150 ms</td>
      </tr>
  </tbody>
</table>
<p>Three things to take from this:</p>
<ul>
<li>The OWASP &ldquo;minimums&rdquo; are not equal in server cost. On this machine the scrypt minimum took roughly 16 times as long as the Argon2id minimum. Do not read these timings as a ranking of attacker cost either: the algorithms differ in how well they resist specialized hardware, which is the reason memory-hard functions are preferred.</li>
<li>bcrypt&rsquo;s doubling is visible. Two steps of work factor, from 10 to 12 and from 12 to 14, each multiplied the time by about four.</li>
<li>OWASP&rsquo;s guidance is that &ldquo;calculating a hash should take less than one second.&rdquo; bcrypt at work factor 14 exceeded that here.</li>
</ul>
<h3 id="size-memory-for-concurrency-not-for-one-login">Size memory for concurrency, not for one login</h3>
<p>Time is only half the budget. Argon2id and scrypt allocate their memory for every hash in progress, so the number that matters is memory multiplied by simultaneous logins.</p>
<p>A worked example, with assumed figures: if a login service peaks at 50 password verifications in progress at once, Argon2id at 19 MiB needs about 950 MiB for hashing at that moment, and at 64 MiB it needs 3,200 MiB (about 3.1 GiB). Neither figure includes the rest of the application.</p>
<p>This has a security side. An unauthenticated login endpoint that allocates 64 MiB per request is a way to exhaust a server. Cap concurrent hash operations with a queue or semaphore, and rate limit login attempts before they reach the hashing step. OWASP also warns that some PBKDF2 implementations make very long passwords much more expensive to hash, so set a maximum password length. NIST SP 800-63B-4 says verifiers should accept at least 64 characters, which makes 64 the floor for that maximum, not the ceiling.</p>
<h2 id="the-bcrypt-72-byte-limit">The bcrypt 72-byte limit</h2>
<p>OWASP&rsquo;s wording: &ldquo;bcrypt has a maximum length input length of 72 bytes for most implementations, so you should enforce a maximum password length of 72 bytes.&rdquo;</p>
<p>Two details make this more than trivia.</p>
<p>First, the limit is in bytes, not characters. A passphrase in a script that uses three bytes per character in UTF-8 reaches 72 bytes at 24 characters.</p>
<p>Second, implementations disagree on what happens past the limit. Some truncate silently, which means two long passwords that share their first 72 bytes are treated as the same password. The Python <code>bcrypt</code> library, in the 5.0.0 release we tested, refuses instead: hashing a 73-byte input raised <code>ValueError: password cannot be longer than 72 bytes, truncate manually if necessary</code>. Find out which behavior your library has before a long passphrase finds out for you.</p>
<p>The common workaround is to hash the password with a fast hash first and feed the result to bcrypt. OWASP describes this as dangerous if done naively, for two reasons: a raw hash can contain a NULL byte, which truncates the input in some bcrypt implementations, and an unkeyed pre-hash enables &ldquo;password shucking&rdquo;, where an attacker tests hashes leaked from another site directly against yours. The construction OWASP gives avoids both by using a keyed hash and encoding the output:</p>
<pre tabindex="0"><code>bcrypt(base64(hmac-sha384(data: password, key: pepper)), salt, cost)
</code></pre><p>If you are writing new code, this is a good argument for Argon2id, which has no such limit.</p>
<h2 id="a-working-example">A working example</h2>
<p>This is Python using <code>argon2-cffi</code> 25.1.0. We ran this code; it is a minimal illustration of the hash, verify and upgrade flow, not a complete authentication system.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> argon2 <span style="color:#f92672">import</span> PasswordHasher
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> argon2.exceptions <span style="color:#f92672">import</span> VerifyMismatchError, VerificationError, InvalidHashError
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># OWASP minimum: m=19456 KiB (19 MiB), t=2, p=1. Raise these if your servers allow.</span>
</span></span><span style="display:flex;"><span>ph <span style="color:#f92672">=</span> PasswordHasher(time_cost<span style="color:#f92672">=</span><span style="color:#ae81ff">2</span>, memory_cost<span style="color:#f92672">=</span><span style="color:#ae81ff">19456</span>, parallelism<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">hash_password</span>(password: str) <span style="color:#f92672">-&gt;</span> str:
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> ph<span style="color:#f92672">.</span>hash(password)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">verify_password</span>(stored_hash: str, password: str) <span style="color:#f92672">-&gt;</span> tuple[bool, str <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span>]:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Return (ok, new_hash). new_hash is set when the stored hash should be replaced.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>        ph<span style="color:#f92672">.</span>verify(stored_hash, password)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">except</span> (VerifyMismatchError, VerificationError, InvalidHashError):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">False</span>, <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ph<span style="color:#f92672">.</span>check_needs_rehash(stored_hash):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">True</span>, ph<span style="color:#f92672">.</span>hash(password)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">True</span>, <span style="color:#66d9ef">None</span>
</span></span></code></pre></div><p><code>hash_password</code> returns a string of this shape:</p>
<pre tabindex="0"><code>$argon2id$v=19$m=19456,t=2,p=1$vAqAbNgPClqje7Xsyzs9bA$K4vezmEN/bOYW9Jwno28K7vgP4vbehiavrZMUlDyZ5Y
</code></pre><p>Everything needed to verify the password later is in that one string: the variant, the version, the three parameters, the salt and the hash. That is why you do not need a separate salt column, and why you can change parameters later without breaking existing accounts.</p>
<p>Notes on the choices:</p>
<ul>
<li><code>verify</code> raises an exception on a mismatch rather than returning <code>False</code>. The code catches the three documented exception types and treats all of them as a failed login.</li>
<li><code>check_needs_rehash</code> compares the parameters stored in the hash with the ones the <code>PasswordHasher</code> is configured with. When you raise the settings, every user is upgraded at their next successful login. In our test, a hash created with weaker settings verified successfully and came back with a replacement hash at the current settings.</li>
<li>Do not compare hash strings yourself, and do not write your own salt generation. The library does both.</li>
</ul>
<h2 id="changing-algorithm-without-a-password-reset">Changing algorithm without a password reset</h2>
<p>You cannot convert a bcrypt hash into an Argon2id hash, because you do not have the password. You do have it at one moment: during a successful login. OWASP describes this as the most common approach, &ldquo;to wait until the user next authenticates, then re-hash their password.&rdquo;</p>
<p>The steps:</p>
<ol>
<li>Store hashes in a format that identifies the algorithm. The <code>$argon2id$</code> and <code>$2b$</code> prefixes already do this.</li>
<li>At login, verify against whichever algorithm the stored hash uses.</li>
<li>On success, if the stored hash is not at the current algorithm and settings, hash the password again and overwrite the stored value.</li>
</ol>
<p>The gap is accounts that never log in. They keep the old hash indefinitely. There are two ways to close it. One is to expire those hashes after a set period and require a reset. The other, which OWASP describes for upgrading legacy hashes, is to wrap them: run the old hash through the new algorithm now, so the database holds for example <code>argon2id(old_hash)</code>, and replace it with a direct hash at the next login. Wrapping protects every account immediately but means your verify code has to handle the layered form until each user returns.</p>
<h2 id="should-you-add-a-pepper">Should you add a pepper?</h2>
<p>A pepper is a secret key applied to every password in addition to its salt. Unlike a salt, it is not stored with the hash. OWASP: &ldquo;The pepper should not be public and should not be stored along with the generated hash.&rdquo;</p>
<p>NIST SP 800-63B-4 recommends the same idea in different words: verifiers &ldquo;SHOULD perform an additional iteration of a keyed hashing or encryption operation using a secret key known only to the verifier&rdquo;, and that key &ldquo;SHALL be stored separately from the hashed passwords&rdquo;, ideally in a hardware security module.</p>
<p>What it buys you: an attacker who steals only the database, for example through SQL injection or a leaked backup, cannot test guesses at all without the key. What it costs: a key you must store, protect and plan to rotate. It does not help if the attacker compromises the application server that holds the key. Treat it as a second layer on top of a properly configured hash, not a replacement for one.</p>
<h2 id="common-mistakes">Common mistakes</h2>
<ul>
<li><strong>Using a fast hash with a salt.</strong> Salted SHA-256 or MD5 is still fast to guess against. The salt only prevents reuse of work across accounts.</li>
<li><strong>Leaving parameters at whatever a tutorial used.</strong> Work factors that were reasonable years ago are low now. Review them on a schedule, and use rehash-on-login to roll changes out.</li>
<li><strong>Raising memory without checking concurrency.</strong> See the sizing example above.</li>
<li><strong>Ignoring bcrypt truncation.</strong> Enforce a 72-byte maximum or use the keyed pre-hash construction.</li>
<li><strong>Encrypting passwords instead of hashing them.</strong> Encryption is reversible by anyone with the key, including an attacker who obtains it.</li>
<li><strong>Treating the hash as the whole defense.</strong> A strong hash slows offline guessing after a breach. It does nothing about phishing or credential stuffing against the live login form. That is the job of multi-factor authentication, covered in <a href="/posts/mfa-fatigue-attacks-what-actually-stops-them/">MFA fatigue attacks and what stops them</a>.</li>
</ul>
<h2 id="a-short-decision-guide">A short decision guide</h2>
<table>
  <thead>
      <tr>
          <th>Your situation</th>
          <th>What to do</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>New application, no compliance constraint</td>
          <td>Argon2id. Start at the RFC 9106 second option (64 MiB, t=3, p=4) if capacity allows, and never below OWASP&rsquo;s 19 MiB, t=2, p=1</td>
      </tr>
      <tr>
          <td>No maintained Argon2id library for your platform</td>
          <td>scrypt at N=2^17, r=8, p=1 or an OWASP equivalent</td>
      </tr>
      <tr>
          <td>Existing bcrypt at work factor 10 or more</td>
          <td>Keep it, enforce the 72-byte limit, raise the work factor as far as login latency allows, plan a rehash-on-login move to Argon2id</td>
      </tr>
      <tr>
          <td>Existing bcrypt below work factor 10</td>
          <td>Raise the work factor now and rehash on login</td>
      </tr>
      <tr>
          <td>FIPS-validated cryptography required</td>
          <td>PBKDF2-HMAC-SHA256 at 600,000 iterations or more</td>
      </tr>
      <tr>
          <td>Unsalted or fast hashes (MD5, SHA-1, SHA-256)</td>
          <td>Wrap existing hashes in Argon2id immediately, then replace at next login</td>
      </tr>
  </tbody>
</table>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 4, 2026. Recommended parameters change as hardware improves, so confirm them against the linked sources before relying on this page later. The timings are our own measurements on one machine and are illustrative.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Algorithm order: Argon2id, then scrypt, bcrypt for legacy systems, PBKDF2 for FIPS; all minimum parameters and equivalent configurations</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">OWASP Password Storage Cheat Sheet</a>, as published October 4, 2026</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Argon2id must be supported; first and second recommended options; 128-bit salt</td>
          <td><a href="https://www.rfc-editor.org/rfc/rfc9106.html">RFC 9106</a>, September 2021</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>scrypt parameters N, r and p</td>
          <td><a href="https://www.rfc-editor.org/rfc/rfc7914.html">RFC 7914</a>, August 2016</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>bcrypt introduced with an adaptable cost</td>
          <td><a href="https://www.usenix.org/legacy/events/usenix99/provos.html">Provos and Mazières, &ldquo;A Future-Adaptable Password Scheme&rdquo;</a>, USENIX 1999</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Argon2 announced as Password Hashing Competition winner in July 2015</td>
          <td><a href="https://www.password-hashing.net/">password-hashing.net</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>NIST requirements for salting, cost factor, salt length, keyed hash and password length</td>
          <td><a href="https://pages.nist.gov/800-63-4/sp800-63b.html">NIST SP 800-63B-4</a>, password verifier requirements</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>argon2-cffi</code> defaults match the RFC 9106 low-memory profile</td>
          <td><a href="https://argon2-cffi.readthedocs.io/en/stable/api.html">argon2-cffi API reference</a>, version 25.1.0, and the installed library</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>bcrypt 72-byte limit; pre-hashing risks; recommended keyed construction</td>
          <td>OWASP Password Storage Cheat Sheet</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Python <code>bcrypt</code> 5.0.0 raises an error above 72 bytes</td>
          <td>Run in our build environment</td>
          <td>Verified for that version only</td>
      </tr>
      <tr>
          <td>Timings table</td>
          <td>Measured on a 2 vCPU virtual machine, Python 3.13</td>
          <td>Qualified: one machine, illustrative</td>
      </tr>
      <tr>
          <td>Concurrency memory example</td>
          <td>Arithmetic on assumed figures (50 concurrent logins)</td>
          <td>Qualified: example, not a measurement</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Cloud Misconfiguration Checklists Don't Work — Here's What Does</title><link>https://firewallsync.com/posts/cloud-misconfiguration-checklists-dont-work-heres-what-does/</link><guid>https://firewallsync.com/posts/cloud-misconfiguration-checklists-dont-work-heres-what-does/</guid><pubDate>Sat, 03 Oct 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>Cloud Security</category><description>Checklist-based cloud security reviews catch what's on the list and miss everything that changed since it was written. Drift detection closes the gap a checklist can't.</description><enclosure url="https://firewallsync.com/posts/cloud-misconfiguration-checklists-dont-work-heres-what-does/cover_hu6633924292829235487.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/cloud-misconfiguration-checklists-dont-work-heres-what-does/cover_hu6633924292829235487.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/cloud-misconfiguration-checklists-dont-work-heres-what-does/cover_hu6633924292829235487.jpg" alt="Person writing a list in a notebook" width="1200" height="630"></p><p>Cloud misconfiguration remains one of the most common root causes behind cloud breaches, which is strange given how many teams run a security checklist against their environment. The problem isn&rsquo;t that the checklists are wrong — it&rsquo;s that a checklist is a snapshot, and cloud environments change constantly between the review that passed and the incident that didn&rsquo;t.</p>
<h2 id="point-in-time-review-versus-continuous-drift-detection">Point-in-time review versus continuous drift detection</h2>
<p>A checklist run quarterly catches whatever was misconfigured on review day. It says nothing about the storage bucket permissions someone loosened for a debugging session last Tuesday and forgot to revert, or the security group rule a new engineer added without realizing its blast radius. Continuous configuration monitoring — tools that diff your actual cloud state against a defined baseline in near-real-time — catches drift the moment it happens instead of up to three months later.</p>
<h2 id="checklists-dont-scale-with-account-sprawl">Checklists don&rsquo;t scale with account sprawl</h2>
<p>A single-account checklist reviewed by hand doesn&rsquo;t scale once you&rsquo;re running dozens of accounts or projects across teams, which is most orgs past a certain size. Policy-as-code (applying the same rule set programmatically across every account) is what actually makes &ldquo;we check for public S3 buckets&rdquo; mean something at scale — a manual checklist run against 40 accounts either takes weeks or, more realistically, only gets applied to the few accounts someone remembers to check.</p>
<h2 id="default-deny-beats-detect-and-fix">Default-deny beats detect-and-fix</h2>
<p>The highest-leverage move isn&rsquo;t catching misconfigurations faster — it&rsquo;s making the insecure state harder to create in the first place. Guardrails enforced at the account or organization level (blocking public bucket creation outright, restricting which regions resources can be created in, requiring encryption by default) remove entire categories of misconfiguration from the checklist because they&rsquo;re no longer possible to create accidentally. Spend the review time you&rsquo;d have spent checking for these on building the guardrail instead — it only has to be built once.</p>
]]></content:encoded></item><item><title>SSRF Explained: How Server-Side Request Forgery Works and How to Prevent It</title><link>https://firewallsync.com/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/</link><guid>https://firewallsync.com/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/</guid><pubDate>Sat, 03 Oct 2026 08:05:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>Any feature that fetches a URL on a user's behalf lets that user borrow your server's network position. Here is how SSRF works, why URL filters keep failing, and the layered controls that hold.</description><enclosure url="https://firewallsync.com/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/cover_hu8386556676597346944.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/cover_hu8386556676597346944.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/cover_hu8386556676597346944.jpg" alt="A rack of servers with network cables attached" width="1200" height="630"></p><p>Server-side request forgery (SSRF) is what happens when your application fetches a destination an attacker chose. The attacker cannot reach your internal network or your cloud credentials directly, but your server can, and a feature that fetches URLs on request will do the reaching for them.</p>
<p>The fix is not a cleverer URL filter. It is a set of layers: avoid taking raw URLs where you can, validate the address you actually connect to, refuse redirects, restrict what the fetching component can reach at the network level, and harden the most valuable target. The rest of this article explains why each layer exists.</p>
<h2 id="what-ssrf-is">What SSRF is</h2>
<p>MITRE&rsquo;s definition for <a href="https://cwe.mitre.org/data/definitions/918.html">CWE-918</a> is precise: &ldquo;The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.&rdquo;</p>
<p>The vulnerable feature is usually one somebody asked for on purpose:</p>
<ul>
<li>Webhooks, where a customer registers a URL for you to call</li>
<li>Link previews and URL unfurling</li>
<li>&ldquo;Import from URL&rdquo; for images, documents or feeds</li>
<li>HTML-to-PDF and screenshot services that load remote resources</li>
<li>Server-side integrations that build a backend URL from request parameters</li>
</ul>
<p>In each case the request originates inside your infrastructure. It passes any firewall that trusts your server, and it may carry whatever ambient identity the server has. CWE-918 lists the consequences as reading application data, executing unauthorized code or commands, and bypassing protection mechanisms such as firewalls.</p>
<p>Two variants matter for testing. In the basic form the response is returned to the attacker, who can read internal pages directly. In blind SSRF nothing is returned, but the attacker can still trigger state-changing requests or infer what exists from timing and error differences.</p>
<h2 id="where-it-sits-in-the-owasp-top-10">Where it sits in the OWASP Top 10</h2>
<p>SSRF became its own category, A10, in the <a href="https://top10.owasp.org/2021/A10_2021-Server-Side_Request_Forgery_(SSRF)/">OWASP Top 10 2021</a>, added from the community survey rather than from incidence data. In the 2025 edition it no longer has a separate entry: the <a href="https://top10.owasp.org/2025/0x00_2025-Introduction/">2025 introduction</a> states that &ldquo;Server-Side Request Forgery (SSRF) has been rolled into&rdquo; A01 Broken Access Control.</p>
<p>That reclassification is a useful way to think about the bug. SSRF is an access control failure: the server is authorized to reach something, and the application lets an unauthorized party direct that access. If you already review authorization as a class of bug, SSRF belongs in the same reviews.</p>
<h2 id="why-cloud-metadata-makes-it-serious">Why cloud metadata makes it serious</h2>
<p>Every major cloud gives a virtual machine a local HTTP endpoint that describes the instance and, critically, can hand out temporary credentials for the identity attached to it. On AWS, Google Cloud and Azure the IPv4 address is the same link-local address, <code>169.254.169.254</code>.</p>
<p>An SSRF bug that can reach that address may be able to retrieve credentials and use them from outside your network. How easy that is depends on the provider&rsquo;s protections:</p>
<table>
  <thead>
      <tr>
          <th>Provider</th>
          <th>Endpoint</th>
          <th>Protection against a naive SSRF request</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>AWS, IMDSv1</td>
          <td><code>169.254.169.254</code></td>
          <td>None: a plain GET request returns metadata</td>
      </tr>
      <tr>
          <td>AWS, IMDSv2</td>
          <td><code>169.254.169.254</code>, and <code>fd00:ec2::254</code> on Nitro instances in IPv6 subnets</td>
          <td>Requires a session token obtained with a PUT request, then sent in the <code>X-aws-ec2-metadata-token</code> header. Default response hop limit is 1</td>
      </tr>
      <tr>
          <td>Google Cloud</td>
          <td><code>169.254.169.254</code>, <code>metadata.google.internal</code></td>
          <td>Requires the <code>Metadata-Flavor: Google</code> header. Requests carrying <code>X-Forwarded-For</code> are rejected</td>
      </tr>
      <tr>
          <td>Azure</td>
          <td><code>169.254.169.254</code></td>
          <td>Requires the <code>Metadata: true</code> header. Requests carrying <code>X-Forwarded-For</code> are rejected</td>
      </tr>
  </tbody>
</table>
<p>IMDS stands for Instance Metadata Service. The header requirements matter because many SSRF bugs give the attacker control of the URL only, not the HTTP method or headers. They stop helping when the vulnerable feature lets the attacker set headers too, which some webhook and integration features do by design.</p>
<p>On AWS, both versions are accepted by default according to the <a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html">EC2 documentation</a>, so IMDSv1 stays reachable until you require IMDSv2. The commands for that are in the hardening section below.</p>
<p>Metadata is the best-known target, not the only one. The same bug reaches internal admin panels, unauthenticated services bound to localhost, databases with HTTP interfaces, and other tenants&rsquo; callbacks.</p>
<h2 id="why-url-filtering-keeps-failing">Why URL filtering keeps failing</h2>
<p>The intuitive defense is to inspect the URL and reject internal addresses. OWASP&rsquo;s guidance on that is blunt: &ldquo;Do not mitigate SSRF via the use of a deny list or regular expression. Attackers have payload lists, tools, and skills to bypass deny lists.&rdquo;</p>
<p>The underlying problem is that the string you inspect and the address you connect to are different things. Four gaps account for most bypasses:</p>
<ol>
<li><strong>Redirects.</strong> The URL points to a public host that passes your check. That host answers with a redirect to <code>http://169.254.169.254/</code>, and your HTTP client follows it.</li>
<li><strong>DNS that changes between check and use.</strong> Your code resolves the hostname, sees a public address and approves it. The HTTP client then resolves the name again and gets an internal address. This is DNS rebinding, a time-of-check to time-of-use race.</li>
<li><strong>Alternate notations.</strong> <code>127.0.0.1</code> can be written as the decimal integer <code>2130706433</code>, in hexadecimal, or as the IPv4-mapped IPv6 address <code>::ffff:127.0.0.1</code>. A string comparison against &ldquo;127.0.0.1&rdquo; misses all of them, while many resolvers accept them.</li>
<li><strong>Parser disagreement.</strong> The library that validates the URL and the library that fetches it may split the same string differently, for example around <code>@</code>, <code>#</code> or backslashes. The <a href="https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html">OWASP SSRF Prevention Cheat Sheet</a> recommends treating parser disagreement as a rejection.</li>
</ol>
<h2 id="how-to-prevent-ssrf">How to prevent SSRF</h2>
<p>OWASP&rsquo;s cheat sheet separates two situations, and the right control depends on which one you are in.</p>
<h3 id="case-1-the-destinations-are-known">Case 1: the destinations are known</h3>
<p>If the feature only ever needs to call a fixed set of services, do not accept a URL at all. Accept an identifier, map it to a destination on the server, and build the request yourself. Where a hostname must be supplied, compare it against an allow list with exact matching.</p>
<p>This is the strong position: there is nothing to bypass because the user never controls the destination.</p>
<h3 id="case-2-the-destination-can-be-anywhere">Case 2: the destination can be anywhere</h3>
<p>Webhooks and link previews cannot use an allow list, because the whole point is to reach arbitrary external hosts. Here OWASP&rsquo;s cheat sheet accepts that a block list is the available tool, while warning that deny lists are bypass-prone. That is consistent with the Top 10 advice quoted above: a block list is acceptable as one layer when applied to the resolved IP address and backed by network controls. It is not acceptable as a string filter standing alone.</p>
<p>The application-layer checks, in order:</p>
<ol>
<li>Allow only the schemes and ports you need, typically <code>https</code> on port 443.</li>
<li>Reject URLs containing embedded credentials.</li>
<li>Resolve the hostname and reject the request if any returned address is not publicly routable.</li>
<li>Connect to the address you validated, not to the hostname again.</li>
<li>Disable redirect following, or re-run every check on each redirect target.</li>
<li>Do not return the raw upstream response to the caller.</li>
</ol>
<p>The following Python function implements steps 1 to 3. It is illustrative, assumes Python 3.9 or later, and uses only the standard library.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> ipaddress
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> socket
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> urllib.parse <span style="color:#f92672">import</span> urlsplit
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ALLOWED_SCHEMES <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;https&#34;</span>}
</span></span><span style="display:flex;"><span>ALLOWED_PORTS <span style="color:#f92672">=</span> {<span style="color:#ae81ff">443</span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">vet_url</span>(url: str) <span style="color:#f92672">-&gt;</span> list[str]:
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Return the public IPs a URL resolves to, or raise ValueError.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    parts <span style="color:#f92672">=</span> urlsplit(url)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> parts<span style="color:#f92672">.</span>scheme <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> ALLOWED_SCHEMES:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">&#34;scheme not allowed&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> parts<span style="color:#f92672">.</span>username <span style="color:#f92672">or</span> parts<span style="color:#f92672">.</span>password:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">&#34;credentials in URL not allowed&#34;</span>)
</span></span><span style="display:flex;"><span>    host <span style="color:#f92672">=</span> parts<span style="color:#f92672">.</span>hostname
</span></span><span style="display:flex;"><span>    port <span style="color:#f92672">=</span> parts<span style="color:#f92672">.</span>port <span style="color:#f92672">or</span> <span style="color:#ae81ff">443</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> host <span style="color:#f92672">or</span> port <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> ALLOWED_PORTS:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">&#34;host or port not allowed&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    infos <span style="color:#f92672">=</span> socket<span style="color:#f92672">.</span>getaddrinfo(host, port, type<span style="color:#f92672">=</span>socket<span style="color:#f92672">.</span>SOCK_STREAM)
</span></span><span style="display:flex;"><span>    addresses <span style="color:#f92672">=</span> {info[<span style="color:#ae81ff">4</span>][<span style="color:#ae81ff">0</span>] <span style="color:#66d9ef">for</span> info <span style="color:#f92672">in</span> infos}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> raw <span style="color:#f92672">in</span> addresses:
</span></span><span style="display:flex;"><span>        ip <span style="color:#f92672">=</span> ipaddress<span style="color:#f92672">.</span>ip_address(raw)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ip<span style="color:#f92672">.</span>version <span style="color:#f92672">==</span> <span style="color:#ae81ff">6</span> <span style="color:#f92672">and</span> ip<span style="color:#f92672">.</span>ipv4_mapped:
</span></span><span style="display:flex;"><span>            ip <span style="color:#f92672">=</span> ip<span style="color:#f92672">.</span>ipv4_mapped
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> ip<span style="color:#f92672">.</span>is_global:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>host<span style="color:#e6db74">}</span><span style="color:#e6db74"> resolves to non-public address </span><span style="color:#e6db74">{</span>ip<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> sorted(addresses)
</span></span></code></pre></div><p>Three design choices are worth noting:</p>
<ul>
<li><strong>It tests for &ldquo;public&rdquo;, not for &ldquo;private&rdquo;.</strong> Python&rsquo;s <a href="https://docs.python.org/3/library/ipaddress.html"><code>ipaddress</code></a> documentation notes that <code>is_private</code> is <code>False</code> for the shared address space <code>100.64.0.0/10</code>, and so is <code>is_global</code>. Checking <code>not ip.is_global</code> rejects that range, loopback, link-local (including <code>169.254.169.254</code>) and private ranges in one condition. Checking <code>is_private</code> alone would let <code>100.64.0.0/10</code> through.</li>
<li><strong>It validates what the resolver returns, not the string.</strong> Decimal and hexadecimal notations are normalized by resolution before the check runs, which closes the alternate-notation gap without a list of patterns.</li>
<li><strong>It rejects if any returned address is non-public</strong>, not only the first.</li>
</ul>
<p>What it does not do is equally important. It does not close the DNS rebinding gap on its own: if you call <code>vet_url()</code> and then hand the original hostname to an HTTP client, the client resolves again. To close that gap, connect to one of the returned IP addresses while still sending the original hostname for TLS verification and the <code>Host</code> header, or route outbound fetches through a proxy that enforces the same address rules at connection time. When you make the request, turn off redirect following, for example <code>allow_redirects=False</code> in the Python <code>requests</code> library.</p>
<h3 id="network-layer-controls">Network-layer controls</h3>
<p>Application checks fail open when someone adds a second fetch path and forgets them. Network controls do not. OWASP&rsquo;s 2021 guidance lists segmenting remote resource access into separate networks, enforcing deny-by-default firewall policy for all but essential intranet traffic, and logging both accepted and blocked flows.</p>
<p>In practice that means running URL-fetching code in a component with its own egress rules: no route to internal subnets, no route to the metadata address, and outbound access only to the public internet. A dedicated fetch service or egress proxy also gives you one place to log every outbound destination, which is the evidence you will want when you investigate a suspected SSRF. Our article on <a href="/posts/logging-for-incidents-not-for-dashboards/">logging for incidents rather than dashboards</a> covers what to keep.</p>
<h3 id="harden-the-metadata-service">Harden the metadata service</h3>
<p>On AWS, require IMDSv2 so that a plain GET no longer returns anything. For an existing instance:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>aws ec2 modify-instance-metadata-options <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --instance-id i-1234567890abcdef0 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --http-tokens required <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --http-endpoint enabled
</span></span></code></pre></div><p>To make it the default for new instances launched in a Region, set the account-level default, which AWS documents per Region:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>aws ec2 modify-instance-metadata-defaults <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --region us-east-1 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>    --http-tokens required
</span></span></code></pre></div><p>Replace the instance ID and Region with your own. AWS documents that account-level defaults &ldquo;do not affect existing instances&rdquo;, and that settings given at launch take precedence over them, so you need both commands to cover a running fleet.</p>
<p>Two cautions. First, check for software still using IMDSv1 before enforcing, because those calls will start failing with HTTP 401. AWS provides the <code>MetadataNoToken</code> CloudWatch metric for this: it counts IMDSv1 calls, and an instance is ready when it records none. Second, the default response hop limit of 1 keeps the token response on the instance itself. AWS notes that &ldquo;in a container environment, a hop limit of <code>1</code> can cause issues&rdquo; and its examples use 2 there. A higher limit also widens what can reach the service, so raise it only where a workload needs it.</p>
<p>AWS introduced IMDSv2 in a <a href="https://aws.amazon.com/blogs/security/defense-in-depth-open-firewalls-reverse-proxies-ssrf-vulnerabilities-ec2-instance-metadata-service/">November 2019 announcement</a> titled &ldquo;Add defense in depth against open firewalls, reverse proxies, and SSRF vulnerabilities&rdquo;. Defense in depth is the accurate description. Requiring IMDSv2 removes the easiest path to credentials. It does not remove the SSRF bug or protect anything else the server can reach.</p>
<p>The credential an attacker obtains is only as powerful as the role attached to the instance, so scoping that role tightly limits the damage of any metadata exposure. The rollout approach in <a href="/posts/least-privilege-access-controls-that-dont-slow-teams-down/">least-privilege access controls that don&rsquo;t slow teams down</a> applies directly.</p>
<h2 id="common-mistakes">Common mistakes</h2>
<ul>
<li><strong>Validating once, fetching twice.</strong> The check and the request each resolve DNS. This is the rebinding gap described above.</li>
<li><strong>Leaving redirects on.</strong> Many HTTP clients follow redirects by default, including the Python <code>requests</code> library for GET requests.</li>
<li><strong>Blocking only <code>169.254.169.254</code> and <code>127.0.0.1</code>.</strong> This misses IPv6 loopback <code>::1</code>, the AWS IPv6 endpoint <code>fd00:ec2::254</code>, private ranges and internal hostnames.</li>
<li><strong>Trusting internal callers.</strong> A service that fetches URLs passed to it by another internal service inherits that service&rsquo;s SSRF exposure.</li>
<li><strong>Returning upstream errors verbatim.</strong> Detailed error messages turn blind SSRF into a port scanner.</li>
<li><strong>Relying on a web application firewall alone.</strong> A WAF sees the inbound request string. It does not see what your server resolves and connects to.</li>
</ul>
<h2 id="how-to-find-it-in-your-own-code">How to find it in your own code</h2>
<p>Search for every place the application makes an outbound request, then trace whether any part of the destination comes from user input, stored user data or another service&rsquo;s response. HTTP client calls are the obvious ones. Less obvious are image and document processors, PDF renderers, XML parsers that resolve external entities, and SDK calls that accept an endpoint parameter.</p>
<p>For each one, ask three questions: can the caller influence the host, can the caller influence the scheme or port, and does the caller see the response. A yes to the first is an SSRF candidate regardless of the other two.</p>
<p>In testing, use a destination you control and watch for the inbound connection. That confirms the fetch happens and shows which component makes it, without touching internal systems. Only test systems you are authorized to test.</p>
<h2 id="recommendations">Recommendations</h2>
<ol>
<li>Inventory outbound fetch features and remove user-controlled destinations where an identifier would do.</li>
<li>Where arbitrary destinations are required, validate the resolved address, connect to that address and disable redirects.</li>
<li>Put URL-fetching code behind egress rules that block internal ranges and the metadata address.</li>
<li>Require IMDSv2 on AWS, and scope instance roles to least privilege on every cloud.</li>
<li>Log outbound destinations from fetch components and alert on attempts to reach internal or link-local addresses.</li>
</ol>
<p>No single item on that list is sufficient. Each one covers a failure mode of the others, which is the point of layering them.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 3, 2026. Cloud provider behavior is as documented on that date and can change.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Definition of SSRF and its consequences</td>
          <td><a href="https://cwe.mitre.org/data/definitions/918.html">MITRE CWE-918</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>SSRF was category A10 in the OWASP Top 10 2021, added from the community survey</td>
          <td><a href="https://top10.owasp.org/2021/A10_2021-Server-Side_Request_Forgery_(SSRF)/">OWASP Top 10 2021, A10</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>SSRF was rolled into A01 Broken Access Control in the 2025 edition</td>
          <td><a href="https://top10.owasp.org/2025/0x00_2025-Introduction/">OWASP Top 10 2025 introduction</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>OWASP advises against deny lists and regular expressions as the mitigation</td>
          <td><a href="https://top10.owasp.org/2021/A10_2021-Server-Side_Request_Forgery_(SSRF)/">OWASP Top 10 2021, A10</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Allow list where destinations are known; address validation, DNS checks and disabled redirects where they are not</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html">OWASP SSRF Prevention Cheat Sheet</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>IMDSv2 requires a PUT-issued session token; default hop limit is 1; both versions accepted by default</td>
          <td><a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html">AWS EC2 User Guide, instance metadata service</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Account-level IMDSv2 default is set per Region and does not affect existing instances</td>
          <td><a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-options.html">AWS EC2 User Guide, instance metadata options</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>MetadataNoToken</code> tracks IMDSv1 calls before enforcement</td>
          <td><a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-IMDS-existing-instances.html">AWS EC2 User Guide, existing instances</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Google Cloud requires <code>Metadata-Flavor: Google</code> and rejects <code>X-Forwarded-For</code></td>
          <td><a href="https://docs.cloud.google.com/compute/docs/metadata/querying-metadata">Google Cloud, querying VM metadata</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Azure requires <code>Metadata: true</code> and rejects <code>X-Forwarded-For</code></td>
          <td><a href="https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service">Azure Instance Metadata Service</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>is_private</code> and <code>is_global</code> are both <code>False</code> for <code>100.64.0.0/10</code></td>
          <td><a href="https://docs.python.org/3/library/ipaddress.html">Python <code>ipaddress</code> documentation</a></td>
          <td>Verified</td>
      </tr>
  </tbody>
</table>
<p>The Python example is illustrative rather than production-ready. It reduces risk as one layer and does not guarantee protection on its own.</p>
]]></content:encoded></item><item><title>The Vulnerability Backlog That Never Shrinks</title><link>https://firewallsync.com/posts/the-vulnerability-backlog-that-never-shrinks/</link><guid>https://firewallsync.com/posts/the-vulnerability-backlog-that-never-shrinks/</guid><pubDate>Thu, 01 Oct 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>Vulnerability backlogs grow faster than teams can patch because most programs measure the wrong thing: total count instead of exploitability and exposure.</description><enclosure url="https://firewallsync.com/posts/the-vulnerability-backlog-that-never-shrinks/cover_hu10616505088535232965.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/the-vulnerability-backlog-that-never-shrinks/cover_hu10616505088535232965.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/the-vulnerability-backlog-that-never-shrinks/cover_hu10616505088535232965.jpg" alt="Sticky notes arranged on a wall" width="1200" height="630"></p><p>A vulnerability backlog that only grows isn&rsquo;t a resourcing problem — it&rsquo;s usually a prioritization problem wearing a resourcing costume. Programs that triage purely by CVSS score treat a critical-severity bug on an air-gapped internal tool the same as a critical-severity bug on an internet-facing login page, which guarantees the backlog fills with things that will never realistically get fixed in order.</p>
<h2 id="exploitability-beats-severity-score">Exploitability beats severity score</h2>
<p>CVSS measures theoretical worst-case impact, not the likelihood anyone will actually exploit it in your environment. Layering in exploit-prediction signals (is there a public PoC, is it being actively exploited in the wild per CISA&rsquo;s KEV catalog, does your WAF or network segmentation already block the vector) turns a flat severity list into an actual priority queue. Teams that adopt this consistently clear their genuinely urgent findings faster, because they stop competing with theoretical risks for the same sprint capacity.</p>
<h2 id="exposure-context-cuts-the-list-before-you-even-start">Exposure context cuts the list before you even start</h2>
<p>A critical vulnerability on a host with no external network path and no sensitive data is a different problem than the same CVE on a public-facing API. Most backlogs don&rsquo;t tag findings with exposure context at all, so triage happens blind to it. Tagging assets by internet-facing status and data sensitivity at ingestion time — not after the fact — lets you filter out a meaningful chunk of the backlog as &ldquo;real but not urgent&rdquo; without ever opening a ticket.</p>
<h2 id="set-an-explicit-sla-miss-policy-not-just-an-sla">Set an explicit SLA-miss policy, not just an SLA</h2>
<p>Every vulnerability program has an SLA. Few have an explicit, documented answer for what happens when a finding blows past it — so it just sits there indefinitely, and the backlog becomes a graveyard nobody trusts. Define what happens at SLA breach (escalation, risk acceptance sign-off, or forced remediation sprint) and enforce it consistently; a backlog where every item has an owner and a forced next step is a very different problem than one where items just accumulate.</p>
]]></content:encoded></item><item><title>Dependency Audits Without Slowing Down Releases</title><link>https://firewallsync.com/posts/dependency-audits-without-slowing-down-releases/</link><guid>https://firewallsync.com/posts/dependency-audits-without-slowing-down-releases/</guid><pubDate>Tue, 29 Sep 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>Most supply-chain security advice assumes you can afford to review every dependency by hand. Here's a tiered approach that scales with a normal release cadence.</description><enclosure url="https://firewallsync.com/posts/dependency-audits-without-slowing-down-releases/cover_hu13734281080967451350.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/dependency-audits-without-slowing-down-releases/cover_hu13734281080967451350.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/dependency-audits-without-slowing-down-releases/cover_hu13734281080967451350.jpg" alt="Lines of code on a dark screen" width="1200" height="630"></p><p>The standard advice on software supply chain security — review every new dependency before it merges — assumes a review capacity most teams don&rsquo;t have. Applied literally, it either gets ignored under deadline pressure or becomes a bottleneck that developers route around by vendoring code instead of adding a package. Neither outcome improves security.</p>
<h2 id="tier-dependencies-by-blast-radius-not-by-novelty">Tier dependencies by blast radius, not by novelty</h2>
<p>Not every dependency deserves the same scrutiny. A left-pad-style utility with no filesystem or network access carries a different risk profile than a package that touches auth tokens, makes outbound requests, or runs a postinstall script. Build a simple tiering rule — anything with install scripts, network access, or crypto/auth involvement gets manual review; everything else gets automated scanning only — and most of your review capacity goes where it actually matters.</p>
<h2 id="automate-the-boring-80">Automate the boring 80%</h2>
<p>Automated tooling (SCA scanners, lockfile diffing, typosquat detection) should catch known-bad packages, suspicious version jumps, and newly-added transitive dependencies without a human in the loop. Wire this into CI as a blocking check for the high-risk tier only — for everything else, let it flag and log rather than block, so it doesn&rsquo;t become the thing engineers learn to bypass.</p>
<h2 id="audit-on-update-not-just-on-add">Audit on update, not just on add</h2>
<p>Most supply-chain incidents involve a package that was fine when it was added and got compromised later — through a maintainer account takeover or a malicious version bump. A one-time review at add-time misses this entirely. Add a lightweight second check specifically for version bumps in your high-risk tier: does the diff match what the changelog claims, and did maintainership change hands recently? This catches the incident pattern that&rsquo;s actually been showing up in real breach reports, which add-time review structurally can&rsquo;t.</p>
]]></content:encoded></item><item><title>Container Image Scanning: What CI Pipelines Still Miss</title><link>https://firewallsync.com/posts/container-image-scanning-what-ci-pipelines-still-miss/</link><guid>https://firewallsync.com/posts/container-image-scanning-what-ci-pipelines-still-miss/</guid><pubDate>Mon, 28 Sep 2026 09:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>Image scanning in CI catches known CVEs in base layers, but most pipelines still ship vulnerable configs and secrets that scanners aren't tuned to see.</description><enclosure url="https://firewallsync.com/posts/container-image-scanning-what-ci-pipelines-still-miss/cover_hu3539560511430577827.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/container-image-scanning-what-ci-pipelines-still-miss/cover_hu3539560511430577827.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/container-image-scanning-what-ci-pipelines-still-miss/cover_hu3539560511430577827.jpg" alt="Stacked orange shipping containers against a blue sky" width="1200" height="630"></p><p>Most teams that added container scanning to CI did it for one reason: catch known CVEs before a vulnerable base image ships to production. That part works well now — scanners are mature and fast. What they consistently miss is everything that isn&rsquo;t a CVE: misconfigurations, embedded secrets, and drift between what was scanned and what actually runs.</p>
<h2 id="scanning-the-image-isnt-scanning-the-runtime-config">Scanning the image isn&rsquo;t scanning the runtime config</h2>
<p>A clean scan on the image itself says nothing about the Kubernetes manifest or Helm chart that deploys it — a container with zero CVEs can still run as root, mount the host filesystem, or expose a debug port to the internet. If your pipeline only gates on image scan results, add a separate policy check on the deployment manifest (tools like Kubernetes admission controllers or policy-as-code frameworks catch this class of issue that image scanners structurally can&rsquo;t).</p>
<h2 id="secrets-baked-in-during-build-not-commit">Secrets baked in during build, not commit</h2>
<p>Scanners tuned to catch secrets in source code miss keys that get pulled in during the Docker build itself — a <code>COPY .env .</code> line, a build arg that ends up baked into a layer, or a credential fetched mid-build and never cleaned up. Audit your Dockerfiles specifically for this pattern; it&rsquo;s one of the more common ways a key ends up in a public registry months after anyone remembers it was there.</p>
<h2 id="the-scan-to-deploy-gap">The scan-to-deploy gap</h2>
<p>A pipeline that scans on merge but deploys hours or days later is scanning against a CVE database that&rsquo;s already stale by deploy time. If a critical CVE gets published for a package already baked into a pending release, nothing in the pipeline re-checks it. Add a scheduled re-scan of images already in your registry, not just at build time — this is the difference between catching a zero-day the week it&rsquo;s disclosed versus the next time someone happens to rebuild.</p>
]]></content:encoded></item><item><title>The API Key Mistakes That Keep Making Breach Reports</title><link>https://firewallsync.com/posts/the-api-key-mistakes-that-keep-making-breach-reports/</link><guid>https://firewallsync.com/posts/the-api-key-mistakes-that-keep-making-breach-reports/</guid><pubDate>Mon, 24 Aug 2026 00:00:00 +0000</pubDate><dc:creator>FirewallSync Editorial</dc:creator><category>AppSec &amp; APIs</category><description>Secrets scanning tools have gotten good. The breaches keep happening anyway, mostly for three preventable reasons.</description><enclosure url="https://firewallsync.com/posts/the-api-key-mistakes-that-keep-making-breach-reports/cover_hu17581053162965116179.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/the-api-key-mistakes-that-keep-making-breach-reports/cover_hu17581053162965116179.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/the-api-key-mistakes-that-keep-making-breach-reports/cover_hu17581053162965116179.jpg" alt="Laptop displaying program code" width="1200" height="630"></p><p>Secrets scanning is mature technology at this point — most CI pipelines can catch a hardcoded key before it merges. Yet leaked API keys remain one of the most common root causes in breach disclosures. The gap isn&rsquo;t tooling. It&rsquo;s three specific habits that scanners don&rsquo;t catch.</p>
<h2 id="keys-with-no-expiry">Keys with no expiry</h2>
<p>A scanner can tell you a key is exposed in a commit. It can&rsquo;t tell you that the key was never going to expire anyway, so rotating it after the leak doesn&rsquo;t actually close the window — the same key pattern gets reused in the next service, with the same no-expiry default. Default every new key to expire, and make the renewal request the normal path, not the exception.</p>
<h2 id="shared-keys-across-environments">Shared keys across environments</h2>
<p>Using the same API key for staging and production is still common because it&rsquo;s convenient — one less secret to manage. It also means a staging environment with weaker access controls becomes a direct path to production data. Environment-scoped keys should be a hard requirement in your provisioning process, not a &ldquo;best practice&rdquo; that gets skipped under deadline pressure.</p>
<h2 id="keys-stored-in-places-scanners-dont-look">Keys stored in places scanners don&rsquo;t look</h2>
<p>Secrets scanners are tuned for source code. They generally don&rsquo;t scan Slack messages, shared docs, or ticket comments — all places engineers paste a key &ldquo;just this once&rdquo; while debugging. That key often outlives the debugging session by months. If your threat model assumes secrets only live in git, you&rsquo;re missing the majority of where they actually leak from in practice.</p>
<h2 id="where-to-focus-first">Where to focus first</h2>
<ol>
<li>Audit for non-expiring keys before adding more scanning tools</li>
<li>Split shared staging/production keys this quarter, not next</li>
<li>Treat chat and docs as a secrets surface, with periodic manual sweeps</li>
</ol>
<p>Scanning your repos is necessary. It was never sufficient.</p>
]]></content:encoded></item></channel></rss>