<?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>Package Url | FirewallSync</title><link>https://firewallsync.com/tags/package-url/</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/package-url/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></channel></rss>