<?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>Sbom | FirewallSync</title><link>https://firewallsync.com/tags/sbom/</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>Fri, 06 Nov 2026 04:40:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/sbom/index.xml" rel="self" type="application/rss+xml"/><item><title>OpenVEX Products and Subcomponents: purl Matching Rules</title><link>https://firewallsync.com/posts/openvex-products-subcomponents-purl-matching-trivy/</link><guid>https://firewallsync.com/posts/openvex-products-subcomponents-purl-matching-trivy/</guid><pubDate>Fri, 06 Nov 2026 04:40:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>An OpenVEX statement suppresses a finding only when its products, subcomponents and purls line up with what the scanner found. Here is how the go-vex library behind Trivy v0.76.2 matches them: versionless purls, qualifier subsets, subcomponent scoping, timestamp inheritance and which status wins, with a schema-valid example document and a tested Python port of the rules.</description><enclosure url="https://firewallsync.com/posts/openvex-products-subcomponents-purl-matching-trivy/cover_hu10637076205791324779.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/openvex-products-subcomponents-purl-matching-trivy/cover_hu10637076205791324779.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/openvex-products-subcomponents-purl-matching-trivy/cover_hu10637076205791324779.jpg" alt="Three weathered wooden crates side by side on a white counter in front of a white brick wall" width="1200" height="630"></p><p>An OpenVEX statement only changes a scanner&rsquo;s output if its <strong>product</strong>, its <strong>subcomponents</strong> and the <strong>purls</strong> inside them match what the scanner found. In the go-vex library that Trivy v0.76.2 uses, the rules are short: the VEX purl is a pattern (no version means every version, and every qualifier it lists must be present), subcomponents narrow a statement to specific packages, and among matching statements the one with the newest timestamp wins. Most &ldquo;VEX didn&rsquo;t work&rdquo; tickets come down to one of those three.</p>
<p>This article is about the identifiers and the matching. For what the statuses and justifications mean, see <a href="/posts/vex-explained-what-a-not-affected-statement-means-and-how-to-read-one/">VEX explained</a>. For converting CycloneDX VEX to OpenVEX, see the <a href="/posts/cyclonedx-vex-analysis-state-justification-openvex-csaf-mapping/">CycloneDX VEX mapping</a>. For CSAF&rsquo;s product tree, see <a href="/posts/csaf-vex-product-tree-product-status-purl-sbom-matching/">CSAF VEX product_tree</a>.</p>
<h2 id="products-and-subcomponents-in-the-spec">Products and subcomponents in the spec</h2>
<p>The <a href="https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md">OpenVEX specification v0.2.0</a> defines a product as &ldquo;a logical unit representing a piece of software&rdquo;, broad enough that &ldquo;anything that can be described in a Software Bill of Materials (SBOM) can be thought of as a product.&rdquo; A subcomponent is &ldquo;any components possibly included in the product where the vulnerability originates.&rdquo; The spec adds that subcomponents &ldquo;SHOULD also list software identifiers and they SHOULD also be listed in the product SBOM.&rdquo;</p>
<p>Products and subcomponents share one structure, which the spec calls <code>Component</code>:</p>
<table>
  <thead>
      <tr>
          <th>Field</th>
          <th>Required</th>
          <th>What it holds</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>@id</code></td>
          <td>No</td>
          <td>An IRI for the component. A purl is a valid IRI, so <code>@id</code> can be a purl.</td>
      </tr>
      <tr>
          <td><code>identifiers</code></td>
          <td>No</td>
          <td>A map of identifier type to value. The spec lists <code>purl</code>, <code>cpe22</code> and <code>cpe23</code>, and recommends purls.</td>
      </tr>
      <tr>
          <td><code>hashes</code></td>
          <td>No</td>
          <td>A map of hash algorithm to digest, using IANA hash names.</td>
      </tr>
      <tr>
          <td><code>subcomponents</code></td>
          <td>No (products only)</td>
          <td>A list of components the statement is scoped to.</td>
      </tr>
  </tbody>
</table>
<p>All three identifying fields are optional, but the spec says a valid statement &ldquo;MUST identify a product.&rdquo; It also says the product should list &ldquo;as many software identifiers as possible to help VEX processors when matching the product.&rdquo;</p>
<p><code>products</code> itself is optional in a statement because it can be inherited from an encapsulating document, such as the subject of an in-toto attestation. Timestamps can be inherited too. The spec&rsquo;s inheritance rules are: a statement timestamp overrides the document timestamp, which overrides the encapsulating document&rsquo;s; a product ID in a statement overrides product data in the encapsulating document.</p>
<h2 id="how-go-vex-matches-a-statement">How go-vex matches a statement</h2>
<p>Trivy loads OpenVEX documents with <a href="https://github.com/openvex/go-vex">openvex/go-vex</a>, pinned to v0.2.9 in Trivy v0.76.2&rsquo;s <code>go.mod</code>. Its matching code is small enough to read in full. For a finding (CVE, product, optional subcomponent), a statement matches when all of these hold:</p>
<ol>
<li><strong>Vulnerability.</strong> The CVE ID equals the statement&rsquo;s <code>vulnerability.name</code>, its <code>@id</code>, or one of its <code>aliases</code>.</li>
<li><strong>Product.</strong> One entry in <code>products</code> matches the product identifier. A component matches if its <code>@id</code> equals the identifier exactly, or its <code>@id</code> is a purl that matches by the purl rules below, or any value in <code>identifiers</code> equals it (with purl matching for the <code>purl</code> key), or any value in <code>hashes</code> equals it.</li>
<li><strong>Subcomponent.</strong> If that product entry has no <code>subcomponents</code>, or the caller passed no subcomponent, the product match is enough. Otherwise one listed subcomponent must match the subcomponent identifier by the same component rules.</li>
</ol>
<h3 id="the-purl-rules">The purl rules</h3>
<p>go-vex&rsquo;s <code>PurlMatches(purl1, purl2)</code> treats the VEX purl as the general one and the scanned purl as the specific one:</p>
<table>
  <thead>
      <tr>
          <th>VEX purl</th>
          <th>Scanned purl</th>
          <th>Match?</th>
          <th>Rule</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>pkg:apk/alpine/git</code></td>
          <td><code>pkg:apk/alpine/git@2.43.0-r0?arch=x86_64</code></td>
          <td>Yes</td>
          <td>No version in the VEX purl matches any version</td>
      </tr>
      <tr>
          <td><code>pkg:apk/alpine/git@2.43.0-r0</code></td>
          <td><code>pkg:apk/alpine/git@2.43.0-r1</code></td>
          <td>No</td>
          <td>Versions must be equal when both are set</td>
      </tr>
      <tr>
          <td><code>pkg:apk/alpine/git@2.43.0-r0</code></td>
          <td><code>pkg:apk/alpine/git</code></td>
          <td>No</td>
          <td>A versioned VEX purl never matches a versionless scan result</td>
      </tr>
      <tr>
          <td><code>pkg:oci/app?repository_url=ghcr.io%2Facme%2Fapp</code></td>
          <td><code>pkg:oci/app?repository_url=ghcr.io%2Facme%2Fapp&amp;tag=v2</code></td>
          <td>Yes</td>
          <td>Extra qualifiers on the scanned side are ignored</td>
      </tr>
      <tr>
          <td><code>pkg:oci/app?repository_url=ghcr.io%2Facme%2Fapp</code></td>
          <td><code>pkg:oci/app</code></td>
          <td>No</td>
          <td>Every VEX qualifier must be present with the same value</td>
      </tr>
      <tr>
          <td><code>pkg:apk/alpine/git</code></td>
          <td><code>pkg:apk/alpine/git-daemon@2.43.0-r0</code></td>
          <td>No</td>
          <td>Type, namespace and name must be equal</td>
      </tr>
  </tbody>
</table>
<p>The function&rsquo;s own comment says &ldquo;Purl version ranges are not supported yet.&rdquo; A statement covering versions 1.0 to 1.4 needs either one purl per version or no version at all. Leaving the version off is the broadest possible statement, so use it only when you mean every version. Trivy&rsquo;s documentation notes that these matching rules come from a community discussion, not from the purl specification.</p>
<h3 id="which-statement-wins">Which statement wins</h3>
<p>go-vex collects every matching statement and sorts them by timestamp, using the document <code>timestamp</code> for any statement without its own. Trivy takes the last one. It removes the finding only if that statement&rsquo;s status is <code>not_affected</code> or <code>fixed</code>; <code>affected</code> and <code>under_investigation</code> leave the finding in the report.</p>
<p>This is why the spec&rsquo;s update example keeps the original document timestamp on the first statement when a second one is added. If you edit a document, bump <code>timestamp</code> and <code>version</code> at the document level, and the old statements lose their original time, the ordering of statements for the same CVE can change.</p>
<h2 id="a-tested-example">A tested example</h2>
<p>The document below has two statements for CVE-2024-32002 on a container image (the first <code>under_investigation</code> with its own timestamp, the second <code>not_affected</code> inheriting the newer document timestamp) and one statement for a Go service. It validates against the OpenVEX JSON schema (<code>openvex_json_schema.json</code> from the spec repository, checked with Python <code>jsonschema</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;@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:#f92672">&#34;@id&#34;</span>: <span style="color:#e6db74">&#34;https://example.com/vex/app-2026-11-06&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;author&#34;</span>: <span style="color:#e6db74">&#34;Example Security Team &lt;security@example.com&gt;&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;timestamp&#34;</span>: <span style="color:#e6db74">&#34;2026-11-06T09:00:00Z&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;version&#34;</span>: <span style="color:#ae81ff">2</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;statements&#34;</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-2024-32002&#34;</span>},
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;timestamp&#34;</span>: <span style="color:#e6db74">&#34;2026-11-01T10:00:00Z&#34;</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;products&#34;</span>: [
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">&#34;@id&#34;</span>: <span style="color:#e6db74">&#34;pkg:oci/app?repository_url=ghcr.io%2Facme%2Fapp&#34;</span>,
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">&#34;subcomponents&#34;</span>: [{<span style="color:#f92672">&#34;@id&#34;</span>: <span style="color:#e6db74">&#34;pkg:apk/alpine/git&#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;status&#34;</span>: <span style="color:#e6db74">&#34;under_investigation&#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-2024-32002&#34;</span>},
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;products&#34;</span>: [
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">&#34;@id&#34;</span>: <span style="color:#e6db74">&#34;pkg:oci/app?repository_url=ghcr.io%2Facme%2Fapp&#34;</span>,
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">&#34;subcomponents&#34;</span>: [{<span style="color:#f92672">&#34;@id&#34;</span>: <span style="color:#e6db74">&#34;pkg:apk/alpine/git&#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;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;status_notes&#34;</span>: <span style="color:#e6db74">&#34;git is installed for build-time use only; the runtime entrypoint never invokes git clone.&#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-2023-0001&#34;</span>},
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">&#34;products&#34;</span>: [
</span></span><span style="display:flex;"><span>        {
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">&#34;@id&#34;</span>: <span style="color:#e6db74">&#34;pkg:golang/example.com/svc@v1.4.0&#34;</span>,
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">&#34;subcomponents&#34;</span>: [{<span style="color:#f92672">&#34;@id&#34;</span>: <span style="color:#e6db74">&#34;pkg:golang/golang.org/x/net@v0.17.0&#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;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_present&#34;</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  ]
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The image, service names and CVE-2023-0001 statement are illustrative. To check the matching without a registry or a real image, we wrote a Python port of go-vex v0.2.9&rsquo;s <code>PurlMatches</code>, <code>Component.Matches</code>, <code>Product.Matches</code>, <code>Statement.Matches</code> and timestamp sort, using the <code>packageurl-python</code> parser, and ran it with Python 3 against the document. It is a port, not the Go library itself, so treat it as a reading aid. Output, with long purls shortened:</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>product                                    subcomponent                          result
</span></span><span style="display:flex;"><span>pkg:oci/app?repository_url=…%2Fapp         pkg:apk/alpine/git@2.43.0-r0?arch=…   2 matches, effective=not_affected
</span></span><span style="display:flex;"><span>pkg:oci/app?repository_url=…%2Fapp         pkg:apk/alpine/git-daemon@2.43.0-r0   no match
</span></span><span style="display:flex;"><span>pkg:oci/app?repository_url=…%2Fapp         (none)                                2 matches, effective=not_affected
</span></span><span style="display:flex;"><span>pkg:oci/app                                pkg:apk/alpine/git@2.43.0-r0          no match
</span></span><span style="display:flex;"><span>pkg:oci/app?repository_url=…%2Fapp&amp;tag=v2  pkg:apk/alpine/git@2.43.0-r0          2 matches, effective=not_affected
</span></span><span style="display:flex;"><span>pkg:golang/example.com/svc@v1.4.0          pkg:golang/golang.org/x/net@v0.17.0   1 match,  effective=not_affected
</span></span><span style="display:flex;"><span>pkg:golang/example.com/svc@v1.5.0          pkg:golang/golang.org/x/net@v0.17.0   no match
</span></span><span style="display:flex;"><span>pkg:golang/example.com/svc@v1.4.0          pkg:golang/golang.org/x/net@v0.18.0   no match
</span></span></code></pre></div><p>What the rows show:</p>
<ul>
<li><strong>Row 1:</strong> the versionless <code>pkg:apk/alpine/git</code> subcomponent covers the installed <code>git@2.43.0-r0</code>. Both statements match, and the <code>not_affected</code> one wins because it inherits the later document timestamp.</li>
<li><strong>Row 2:</strong> <code>git-daemon</code> is a different package name, so the statement does not cover it, even though it ships from the same source.</li>
<li><strong>Row 3:</strong> with no subcomponent passed, the product match alone is enough. Trivy does not hit this case for an image root (see below), but other consumers of go-vex may.</li>
<li><strong>Rows 4 and 5:</strong> the <code>repository_url</code> qualifier on the VEX side must be present on the scanned side. A scan purl without it fails; one with an extra <code>tag</code> qualifier passes.</li>
<li><strong>Rows 6 to 8:</strong> pinned versions on both product and subcomponent mean a new service release (v1.5.0) or a dependency bump (x/net v0.18.0) silently drops the statement. That is often what you want, since the reachability analysis was for that build, but it means the document needs updating each release.</li>
</ul>
<h2 id="how-trivy-applies-it-to-a-dependency-tree">How Trivy applies it to a dependency tree</h2>
<p>Trivy&rsquo;s VEX guide and its <code>filterVulnerabilities</code> code show how it feeds this matcher. For each finding it first asks whether the vulnerable package itself is a not-affected product (no subcomponent). If not, it walks up the dependency graph from the vulnerable package and asks, for each parent, whether &ldquo;parent as product, vulnerable package as subcomponent&rdquo; is not affected. The finding is removed only if no path reaches the root without passing a not-affected node.</p>
<p>Two consequences:</p>
<ul>
<li><strong>A statement on an intermediate dependency can suppress a finding, but only if every path goes through it.</strong> Trivy&rsquo;s documentation gives the example of Module C reached through both B and D: a VEX statement for B does not suppress the finding, because D might still be affected.</li>
<li><strong>A statement on the image with no subcomponents suppresses the CVE in every package in that image.</strong> Trivy&rsquo;s docs call this out and recommend subcomponents for &ldquo;reducing the risk of incorrect filtering.&rdquo;</li>
</ul>
<p>Trivy needs a purl on the scanned side. Its OpenVEX matcher returns nothing when the product has no purl, so cpe-only or hash-only VEX products are not matched by Trivy even though the spec allows them. Use the purls Trivy prints in its JSON report.</p>
<h2 id="writing-statements-that-match">Writing statements that match</h2>
<p>These are our recommendations, based on the rules above:</p>
<ol>
<li><strong>Copy purls from the scanner or SBOM output.</strong> Do not type them by hand; the type, namespace and name must match exactly. Our guide to <a href="/posts/cve-packageurl-purl-cve-json-5-2-affected-sbom-matching/">CVE packageURL and SBOM purls</a> covers common purl pitfalls.</li>
<li><strong>Always add subcomponents for image-level or application-level products.</strong> A product with no subcomponents is a blanket statement.</li>
<li><strong>Add a version only when the claim depends on the build.</strong> Reachability claims (<code>vulnerable_code_not_in_execute_path</code>) usually do. &ldquo;This package never ships the vulnerable file&rdquo; claims may not.</li>
<li><strong>Be sparing with qualifiers in the VEX purl.</strong> Each one is a requirement. <code>arch</code> is useful if only one architecture is unaffected; otherwise leave it out.</li>
<li><strong>Keep timestamps on old statements when you append new ones,</strong> and bump the document <code>version</code>. Use <code>status_notes</code> to record how the status was determined. Trivy does not read it, but reviewers do.</li>
<li><strong>Track the result.</strong> Our <a href="/posts/vulnerability-prioritization-workflow-cve-record-to-patch-ticket/">prioritization workflow</a> shows where a VEX decision sits between the CVE record and the ticket.</li>
</ol>
<h2 id="limits-of-this-article">Limits of this article</h2>
<ul>
<li>Matching behaviour is from go-vex v0.2.9 and Trivy v0.76.2 source and docs. Grype, vexctl and commercial scanners have their own matchers and may differ.</li>
<li>Our test used a Python port of the go-vex rules, not the Go library or a Trivy scan. The port follows the source shown in the references; differences in purl parsing between <code>packageurl-python</code> and <code>packageurl-go</code> are possible for unusual purls.</li>
<li>We did not cover OpenVEX attestations attached to OCI images (<code>--vex oci</code>) or VEX repositories.</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>Product, subcomponent, Component fields, identifier types, inheritance and update rules</td>
          <td><a href="https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md">OpenVEX specification v0.2.0</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Example document is schema-valid</td>
          <td><a href="https://github.com/openvex/spec/blob/main/openvex_json_schema.json">openvex_json_schema.json</a>, checked with Python jsonschema</td>
          <td>Verified (tested)</td>
      </tr>
      <tr>
          <td>Trivy v0.76.2 uses go-vex v0.2.9; applies only not_affected and fixed; latest statement wins; requires a product purl</td>
          <td><a href="https://github.com/aquasecurity/trivy/blob/v0.76.2/pkg/vex/openvex.go">Trivy pkg/vex/openvex.go</a> and go.mod</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Component, product, statement, vulnerability and purl matching rules; no version-range support; timestamp sort</td>
          <td><a href="https://github.com/openvex/go-vex/tree/v0.2.9/pkg/vex">go-vex v0.2.9 pkg/vex</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Dependency-tree traversal and the multiple-paths rule</td>
          <td><a href="https://github.com/aquasecurity/trivy/blob/v0.76.2/docs/guide/supply-chain/vex/file.md">Trivy local VEX files guide</a> and pkg/vex/vex.go</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Match results in the example table</td>
          <td>Python port of go-vex v0.2.9 rules, run against the example document</td>
          <td>Qualified: port, not the Go library</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><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>CSAF VEX product_tree: Resolve Product IDs to SBOM purls</title><link>https://firewallsync.com/posts/csaf-vex-product-tree-product-status-purl-sbom-matching/</link><guid>https://firewallsync.com/posts/csaf-vex-product-tree-product-status-purl-sbom-matching/</guid><pubDate>Thu, 05 Nov 2026 04:30:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>Security Operations</category><description>A CSAF VEX file never puts a purl next to a status. Product IDs in product_status point into the product_tree, often through relationships. Here is how to walk branches and relationships, collapse the eight status lists into four VEX answers, and join the result to a CycloneDX SBOM, with a tested Python script.</description><enclosure url="https://firewallsync.com/posts/csaf-vex-product-tree-product-status-purl-sbom-matching/cover_hu13121586183071927737.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/csaf-vex-product-tree-product-status-purl-sbom-matching/cover_hu13121586183071927737.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/csaf-vex-product-tree-product-status-purl-sbom-matching/cover_hu13121586183071927737.jpg" alt="Thin bare tree branches crossing in a dense tangle against a flat grey-blue sky" width="1200" height="630"></p><p><strong>Scope.</strong> This is a field-level guide for engineers who consume vendor CSAF VEX files and need to apply them to their own SBOMs. We read the CSAF 2.0 prose and JSON schema in the OASIS CSAF TC&rsquo;s GitHub repository, Red Hat&rsquo;s July 2024 announcement of its CSAF VEX files, and Trivy&rsquo;s CSAF matcher and documentation at tag v0.76.1. The example document and the Python script below are ours; we validated the document against the CSAF 2.0 JSON schema and ran the script with Python 3.13. The script is illustrative, not a production matcher.</p>
<h2 id="the-short-answer">The short answer</h2>
<p>A CSAF VEX file never puts a purl next to a status. It says &ldquo;product ID <code>GW-4.2.1:LIB-1.8.0</code> is <code>known_not_affected</code>&rdquo;, and you have to work out what that ID is:</p>
<ol>
<li>Find the ID in the <code>product_tree</code>: at the end of a <code>branches</code> path, in <code>full_product_names</code>, or as the <code>full_product_name</code> of a <code>relationships</code> entry.</li>
<li>If it is a relationship, resolve both sides: <code>product_reference</code> (the component) and <code>relates_to_product_reference</code> (the product it sits in).</li>
<li>Read <code>product_identification_helper.purl</code> (or <code>cpe</code>) for each side.</li>
<li>Apply the status only when your SBOM contains the component <strong>and</strong> the parent matches the product you are assessing.</li>
<li>Collapse the eight <code>product_status</code> lists to the four VEX answers before you write anything to a ticket.</li>
</ol>
<p>The rest of this article goes through each step, shows a schema-valid example, and gives a script that does the join.</p>
<h2 id="what-the-vex-profile-requires">What the VEX profile requires</h2>
<p>CSAF 2.0 has been an OASIS Standard since 18 November 2022. The VEX profile is section 4.5. A document satisfies it when <code>/document/category</code> is <code>csaf_vex</code> and it contains:</p>
<ul>
<li>a <code>product_tree</code> &ldquo;which lists all products referenced later on in the CSAF document regardless of their state&rdquo;;</li>
<li><code>vulnerabilities</code>, each with <code>notes</code> and at least one of <code>cve</code> or <code>ids</code>;</li>
<li>at least one of the <code>fixed</code>, <code>known_affected</code>, <code>known_not_affected</code> or <code>under_investigation</code> lists.</li>
</ul>
<p>Two conditions are per product, and they are the ones consumers should check:</p>
<ul>
<li>Every product in <code>known_not_affected</code> needs an impact statement, either &ldquo;as machine readable flag in <code>/vulnerabilities[]/flags</code> or as human readable justification in <code>/vulnerabilities[]/threats</code>&rdquo; with <code>category</code> set to <code>impact</code>.</li>
<li>Every product in <code>known_affected</code> needs &ldquo;additional product specific information&rdquo; in <code>remediations</code> &ldquo;as an action statement&rdquo;. The categories <code>no_fix_planned</code> and <code>none_available</code> count.</li>
</ul>
<p>The five flag labels are the CISA justification codes: <code>component_not_present</code>, <code>vulnerable_code_not_present</code>, <code>vulnerable_code_cannot_be_controlled_by_adversary</code>, <code>vulnerable_code_not_in_execute_path</code> and <code>inline_mitigations_already_exist</code>. If you need the meaning of each one, the <a href="/posts/vex-explained-what-a-not-affected-statement-means-and-how-to-read-one/">VEX explainer</a> covers them. The <a href="/posts/cyclonedx-vex-analysis-state-justification-openvex-csaf-mapping/">CycloneDX VEX mapping guide</a> covers converting between CSAF and other VEX formats. This article is about something they do not cover: finding the products a CSAF statement actually refers to.</p>
<h2 id="step-1-walk-the-product-tree">Step 1: walk the product tree</h2>
<p>The <code>product_tree</code> can name products in three places. A consumer has to read all three.</p>
<table>
  <thead>
      <tr>
          <th>Where</th>
          <th>What it holds</th>
          <th>Product ID comes from</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>branches</code> (nested)</td>
          <td>A hierarchy such as <code>vendor</code> → <code>product_name</code> → <code>product_version</code>. A leaf branch has a <code>product</code> object.</td>
          <td><code>branches[]...product.product_id</code></td>
      </tr>
      <tr>
          <td><code>full_product_names</code></td>
          <td>Flat list of products with no hierarchy</td>
          <td><code>full_product_names[].product_id</code></td>
      </tr>
      <tr>
          <td><code>relationships</code></td>
          <td>A new product made of two existing ones, such as a library inside an image</td>
          <td><code>relationships[].full_product_name.product_id</code></td>
      </tr>
  </tbody>
</table>
<p>Every one of these is a &ldquo;full product name&rdquo;: <code>name</code> and <code>product_id</code> are required, and <code>product_identification_helper</code> is optional. That helper is where <code>purl</code>, <code>cpe</code>, hashes and SBOM URLs go. The CSAF schema only checks the purl against a loose pattern (<code>^pkg:[A-Za-z\.\-\+][A-Za-z0-9\.\-\+]*/.+</code>) and says it &ldquo;does not completely evaluate whether a PURL is valid&rdquo;, so validate purls yourself before matching on them.</p>
<p>There is also <code>product_groups</code>, which bundles product IDs under a group ID. Product status lists only hold product IDs, but <code>flags</code>, <code>threats</code> and <code>remediations</code> can reference groups through <code>group_ids</code>, so expand groups when you collect impact and action statements.</p>
<p>The spec recommends the <code>vendor</code> → <code>product_name</code> → <code>product_version</code> shape &ldquo;whenever possible to support the identification and matching of products on the consumer side&rdquo;. It also allows <code>product_version_range</code> branches, but recommends enumerating versions instead. If you meet a range branch, you need a version-range parser for whatever scheme it uses; the purl join below will not help.</p>
<h2 id="step-2-treat-relationships-as-component-in-product">Step 2: treat relationships as &ldquo;component in product&rdquo;</h2>
<p>A relationship has four required fields: <code>category</code>, <code>product_reference</code>, <code>relates_to_product_reference</code> and <code>full_product_name</code>. The categories are <code>default_component_of</code>, <code>external_component_of</code>, <code>installed_on</code>, <code>installed_with</code> and <code>optional_component_of</code>. The spec gives the reason relationships exist: a product may be &ldquo;vulnerable only when installed together with another&rdquo;, or you may need &ldquo;to describe operating system components&rdquo;.</p>
<p>This is the part consumers get wrong. In our example below, <code>LIB-1.8.0</code> is the library on its own and <code>GW-4.2.1:LIB-1.8.0</code> is &ldquo;libexample 1.8.0 as shipped in Example Gateway 4.2.1&rdquo;. A vendor that marks the second ID <code>known_not_affected</code> is saying the gateway does not reach the vulnerable code. It is not saying libexample 1.8.0 is safe everywhere. If your matcher resolves the relationship ID to the component purl alone, you will suppress the CVE for every other image that ships libexample 1.8.0. That is the false negative VEX is supposed to prevent.</p>
<p>The rule we use: for a relationship ID, apply the status only when the component purl matches a component in the SBOM <strong>and</strong> the parent purl matches the SBOM&rsquo;s subject (for CycloneDX, <code>metadata.component</code>).</p>
<p>Red Hat&rsquo;s VEX files are a real case of this structure. Red Hat publishes one CSAF VEX file per CVE. Its July 2024 announcement says each file has a <code>product_tree</code> object listing &ldquo;products, their components, and the relationships between them&rdquo;. In the general-availability release Red Hat &ldquo;extended the scope of the <code>purl</code> identifiers to unfixed components as well&rdquo; for rpm, oci and rpmmod components. In the October 2023 beta, only fixed components carried a purl. Check which one you are parsing.</p>
<h2 id="step-3-collapse-eight-status-lists-into-four-answers">Step 3: collapse eight status lists into four answers</h2>
<p>CSAF defines eight <code>product_status</code> lists. VEX consumers need four answers. The spec&rsquo;s own test for contradicting statuses (section 6.1.6) gives the grouping, and the sets &ldquo;MUST be pairwise disjoint&rdquo; within one vulnerability:</p>
<table>
  <thead>
      <tr>
          <th>VEX answer</th>
          <th>CSAF <code>product_status</code> lists</th>
          <th>What to do</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Affected</td>
          <td><code>first_affected</code>, <code>known_affected</code>, <code>last_affected</code></td>
          <td>Open or keep the ticket. Read <code>remediations</code> for the action statement.</td>
      </tr>
      <tr>
          <td>Not affected</td>
          <td><code>known_not_affected</code></td>
          <td>Suppress for that product only, and keep the flag or impact text as evidence.</td>
      </tr>
      <tr>
          <td>Fixed</td>
          <td><code>first_fixed</code>, <code>fixed</code></td>
          <td>Close if you run that version. The spec says these versions &ldquo;may not be the recommended fixed versions&rdquo;.</td>
      </tr>
      <tr>
          <td>Under investigation</td>
          <td><code>under_investigation</code></td>
          <td>Keep the scanner finding and re-pull the document later.</td>
      </tr>
      <tr>
          <td>(none)</td>
          <td><code>recommended</code></td>
          <td>Not part of any contradiction group. The spec notes an issuer might recommend a version &ldquo;from any group - also from the affected group&rdquo;. Use it to choose an upgrade target, not to decide status.</td>
      </tr>
  </tbody>
</table>
<p>If you find one product ID in two groups for the same CVE, the document fails the CSAF mandatory test. Reject the statement for that product rather than picking one.</p>
<p>For affected products, the remediation categories tell you what kind of action is available: <code>vendor_fix</code>, <code>workaround</code>, <code>mitigation</code>, <code>none_available</code> or <code>no_fix_planned</code>. <code>vendor_fix</code> cannot be combined with <code>none_available</code> or <code>no_fix_planned</code> for the same product.</p>
<h2 id="a-schema-valid-example">A schema-valid example</h2>
<p>This document is ours, written for this article. It has one product version where a library is not reachable and a later version where it was updated. It passes validation against the CSAF 2.0 JSON schema from the OASIS repository (Python <code>jsonschema</code>, Draft 2020-12, format checks on; 0 errors). Schema validation does not run CSAF&rsquo;s mandatory tests, so it does not prove the profile rules are met. We checked those by hand.</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;document&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;category&#34;</span>: <span style="color:#e6db74">&#34;csaf_vex&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;csaf_version&#34;</span>: <span style="color:#e6db74">&#34;2.0&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;publisher&#34;</span>: {<span style="color:#f92672">&#34;category&#34;</span>: <span style="color:#e6db74">&#34;vendor&#34;</span>, <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;Example Vendor&#34;</span>, <span style="color:#f92672">&#34;namespace&#34;</span>: <span style="color:#e6db74">&#34;https://vendor.example&#34;</span>},
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;title&#34;</span>: <span style="color:#e6db74">&#34;Example Gateway: CVE-2099-0001 in libexample&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;tracking&#34;</span>: {<span style="color:#f92672">&#34;id&#34;</span>: <span style="color:#e6db74">&#34;EXV-2099-0001&#34;</span>, <span style="color:#f92672">&#34;status&#34;</span>: <span style="color:#e6db74">&#34;final&#34;</span>, <span style="color:#f92672">&#34;version&#34;</span>: <span style="color:#e6db74">&#34;2&#34;</span>, <span style="color:#f92672">&#34;...&#34;</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:#f92672">&#34;product_tree&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;branches&#34;</span>: [{<span style="color:#f92672">&#34;category&#34;</span>: <span style="color:#e6db74">&#34;vendor&#34;</span>, <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;Example Vendor&#34;</span>, <span style="color:#f92672">&#34;branches&#34;</span>: [
</span></span><span style="display:flex;"><span>      {<span style="color:#f92672">&#34;category&#34;</span>: <span style="color:#e6db74">&#34;product_name&#34;</span>, <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;Example Gateway&#34;</span>, <span style="color:#f92672">&#34;branches&#34;</span>: [
</span></span><span style="display:flex;"><span>        {<span style="color:#f92672">&#34;category&#34;</span>: <span style="color:#e6db74">&#34;product_version&#34;</span>, <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;4.2.1&#34;</span>,
</span></span><span style="display:flex;"><span>         <span style="color:#f92672">&#34;product&#34;</span>: {<span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;Example Gateway 4.2.1&#34;</span>, <span style="color:#f92672">&#34;product_id&#34;</span>: <span style="color:#e6db74">&#34;GW-4.2.1&#34;</span>,
</span></span><span style="display:flex;"><span>           <span style="color:#f92672">&#34;product_identification_helper&#34;</span>: {<span style="color:#f92672">&#34;purl&#34;</span>: <span style="color:#e6db74">&#34;pkg:oci/example-gateway@sha256%3A1111?tag=4.2.1&#34;</span>}}}
</span></span><span style="display:flex;"><span>      ]},
</span></span><span style="display:flex;"><span>      {<span style="color:#f92672">&#34;category&#34;</span>: <span style="color:#e6db74">&#34;product_name&#34;</span>, <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;libexample&#34;</span>, <span style="color:#f92672">&#34;branches&#34;</span>: [
</span></span><span style="display:flex;"><span>        {<span style="color:#f92672">&#34;category&#34;</span>: <span style="color:#e6db74">&#34;product_version&#34;</span>, <span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;1.8.0&#34;</span>,
</span></span><span style="display:flex;"><span>         <span style="color:#f92672">&#34;product&#34;</span>: {<span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;libexample 1.8.0&#34;</span>, <span style="color:#f92672">&#34;product_id&#34;</span>: <span style="color:#e6db74">&#34;LIB-1.8.0&#34;</span>,
</span></span><span style="display:flex;"><span>           <span style="color:#f92672">&#34;product_identification_helper&#34;</span>: {<span style="color:#f92672">&#34;purl&#34;</span>: <span style="color:#e6db74">&#34;pkg:deb/debian/libexample@1.8.0-1&#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;relationships&#34;</span>: [
</span></span><span style="display:flex;"><span>      {<span style="color:#f92672">&#34;category&#34;</span>: <span style="color:#e6db74">&#34;default_component_of&#34;</span>,
</span></span><span style="display:flex;"><span>       <span style="color:#f92672">&#34;product_reference&#34;</span>: <span style="color:#e6db74">&#34;LIB-1.8.0&#34;</span>,
</span></span><span style="display:flex;"><span>       <span style="color:#f92672">&#34;relates_to_product_reference&#34;</span>: <span style="color:#e6db74">&#34;GW-4.2.1&#34;</span>,
</span></span><span style="display:flex;"><span>       <span style="color:#f92672">&#34;full_product_name&#34;</span>: {<span style="color:#f92672">&#34;name&#34;</span>: <span style="color:#e6db74">&#34;libexample 1.8.0 in Example Gateway 4.2.1&#34;</span>,
</span></span><span style="display:flex;"><span>                             <span style="color:#f92672">&#34;product_id&#34;</span>: <span style="color:#e6db74">&#34;GW-4.2.1:LIB-1.8.0&#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;vulnerabilities&#34;</span>: [{
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;cve&#34;</span>: <span style="color:#e6db74">&#34;CVE-2099-0001&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;notes&#34;</span>: [{<span style="color:#f92672">&#34;category&#34;</span>: <span style="color:#e6db74">&#34;description&#34;</span>, <span style="color:#f92672">&#34;text&#34;</span>: <span style="color:#e6db74">&#34;Illustrative vulnerability in libexample&#39;s XML parser.&#34;</span>}],
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;product_status&#34;</span>: {<span style="color:#f92672">&#34;known_not_affected&#34;</span>: [<span style="color:#e6db74">&#34;GW-4.2.1:LIB-1.8.0&#34;</span>], <span style="color:#f92672">&#34;fixed&#34;</span>: [<span style="color:#e6db74">&#34;GW-4.3.0:LIB-1.8.2&#34;</span>]},
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;flags&#34;</span>: [{<span style="color:#f92672">&#34;label&#34;</span>: <span style="color:#e6db74">&#34;vulnerable_code_not_in_execute_path&#34;</span>, <span style="color:#f92672">&#34;product_ids&#34;</span>: [<span style="color:#e6db74">&#34;GW-4.2.1:LIB-1.8.0&#34;</span>]}],
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;threats&#34;</span>: [{<span style="color:#f92672">&#34;category&#34;</span>: <span style="color:#e6db74">&#34;impact&#34;</span>, <span style="color:#f92672">&#34;details&#34;</span>: <span style="color:#e6db74">&#34;Example Gateway never calls the XML parser in libexample.&#34;</span>,
</span></span><span style="display:flex;"><span>                 <span style="color:#f92672">&#34;product_ids&#34;</span>: [<span style="color:#e6db74">&#34;GW-4.2.1:LIB-1.8.0&#34;</span>]}]
</span></span><span style="display:flex;"><span>  }]
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The excerpt trims the tracking block and the 4.3.0 / 1.8.2 branches and relationship, which follow the same pattern. The CVE ID is a placeholder.</p>
<h2 id="step-4-join-to-the-sbom">Step 4: join to the SBOM</h2>
<p>The script resolves every product ID in <code>product_status</code> to purls, follows relationships, and checks both sides against a CycloneDX SBOM. For purl comparison it uses the matching rules Trivy documents, which Trivy itself notes &ldquo;are not formally defined within the current official PURL specification&rdquo;: a VEX purl without a version matches every version; every qualifier in the VEX purl must be present in the SBOM purl; extra SBOM qualifiers are ignored.</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;Resolve CSAF VEX product_status entries to purls and join them to a CycloneDX SBOM.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Illustrative: purl matching follows the rules in purl_match(); no version-range logic.&#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> urllib.parse <span style="color:#f92672">import</span> unquote
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">parse</span>(p):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Split a purl into (type/namespace/name, version or None, qualifiers dict). Subpath ignored.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    p <span style="color:#f92672">=</span> p<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>    core, _, q <span style="color:#f92672">=</span> p<span style="color:#f92672">.</span>partition(<span style="color:#e6db74">&#34;?&#34;</span>)
</span></span><span style="display:flex;"><span>    quals <span style="color:#f92672">=</span> dict(kv<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> kv <span style="color:#f92672">in</span> q<span style="color:#f92672">.</span>split(<span style="color:#e6db74">&#34;&amp;&#34;</span>) <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;=&#34;</span> <span style="color:#f92672">in</span> kv)
</span></span><span style="display:flex;"><span>    path, _, ver <span style="color:#f92672">=</span> core<span style="color:#f92672">.</span>rpartition(<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> 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:#66d9ef">else</span> (core, <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> unquote(path)<span style="color:#f92672">.</span>lower(), unquote(ver) <span style="color:#f92672">or</span> <span style="color:#66d9ef">None</span>, {k<span style="color:#f92672">.</span>lower(): unquote(v) <span style="color:#66d9ef">for</span> k, v <span style="color:#f92672">in</span> quals<span style="color:#f92672">.</span>items()}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">purl_match</span>(vex_purl, sbom_purl):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Community matching rules (as documented by Trivy): a VEX purl without a version matches
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    every version; VEX qualifiers must all be present in the SBOM purl; extra SBOM qualifiers are ignored.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    vp, vv, vq <span style="color:#f92672">=</span> parse(vex_purl)
</span></span><span style="display:flex;"><span>    sp, sv, sq <span style="color:#f92672">=</span> parse(sbom_purl)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> vp <span style="color:#f92672">==</span> sp <span style="color:#f92672">and</span> (vv <span style="color:#f92672">is</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">or</span> vv <span style="color:#f92672">==</span> sv) <span style="color:#f92672">and</span> all(sq<span style="color:#f92672">.</span>get(k) <span style="color:#f92672">==</span> v <span style="color:#66d9ef">for</span> k, v <span style="color:#f92672">in</span> vq<span style="color:#f92672">.</span>items())
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">walk_branches</span>(branches, out):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> b <span style="color:#f92672">in</span> branches <span style="color:#f92672">or</span> []:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#e6db74">&#34;product&#34;</span> <span style="color:#f92672">in</span> b:
</span></span><span style="display:flex;"><span>            fpn <span style="color:#f92672">=</span> b[<span style="color:#e6db74">&#34;product&#34;</span>]
</span></span><span style="display:flex;"><span>            out[fpn[<span style="color:#e6db74">&#34;product_id&#34;</span>]] <span style="color:#f92672">=</span> fpn<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;product_identification_helper&#34;</span>, {})<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;purl&#34;</span>)
</span></span><span style="display:flex;"><span>        walk_branches(b<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;branches&#34;</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">load_products</span>(doc):
</span></span><span style="display:flex;"><span>    tree <span style="color:#f92672">=</span> doc[<span style="color:#e6db74">&#34;product_tree&#34;</span>]
</span></span><span style="display:flex;"><span>    purls <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span>    walk_branches(tree<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;branches&#34;</span>), purls)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> fpn <span style="color:#f92672">in</span> tree<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;full_product_names&#34;</span>, []):
</span></span><span style="display:flex;"><span>        purls[fpn[<span style="color:#e6db74">&#34;product_id&#34;</span>]] <span style="color:#f92672">=</span> fpn<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;product_identification_helper&#34;</span>, {})<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;purl&#34;</span>)
</span></span><span style="display:flex;"><span>    rels <span style="color:#f92672">=</span> {}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> tree<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;relationships&#34;</span>, []):
</span></span><span style="display:flex;"><span>        rels[r[<span style="color:#e6db74">&#34;full_product_name&#34;</span>][<span style="color:#e6db74">&#34;product_id&#34;</span>]] <span style="color:#f92672">=</span> (
</span></span><span style="display:flex;"><span>            r[<span style="color:#e6db74">&#34;category&#34;</span>], r[<span style="color:#e6db74">&#34;product_reference&#34;</span>], r[<span style="color:#e6db74">&#34;relates_to_product_reference&#34;</span>])
</span></span><span style="display:flex;"><span>    groups <span style="color:#f92672">=</span> {g[<span style="color:#e6db74">&#34;group_id&#34;</span>]: g[<span style="color:#e6db74">&#34;product_ids&#34;</span>] <span style="color:#66d9ef">for</span> g <span style="color:#f92672">in</span> tree<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;product_groups&#34;</span>, [])}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> purls, rels, groups
</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>(vex_path, sbom_path):
</span></span><span style="display:flex;"><span>    doc <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>load(open(vex_path))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">assert</span> doc[<span style="color:#e6db74">&#34;document&#34;</span>][<span style="color:#e6db74">&#34;category&#34;</span>] <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;csaf_vex&#34;</span>, <span style="color:#e6db74">&#34;not a VEX profile document&#34;</span>
</span></span><span style="display:flex;"><span>    purls, rels, groups <span style="color:#f92672">=</span> load_products(doc)
</span></span><span style="display:flex;"><span>    sbom <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>load(open(sbom_path))
</span></span><span style="display:flex;"><span>    parent <span style="color:#f92672">=</span> sbom[<span style="color:#e6db74">&#34;metadata&#34;</span>][<span style="color:#e6db74">&#34;component&#34;</span>][<span style="color:#e6db74">&#34;purl&#34;</span>]
</span></span><span style="display:flex;"><span>    inventory <span style="color:#f92672">=</span> [c[<span style="color:#e6db74">&#34;purl&#34;</span>] <span style="color:#66d9ef">for</span> c <span style="color:#f92672">in</span> sbom<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;components&#34;</span>, []) <span style="color:#66d9ef">if</span> c<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;purl&#34;</span>)]
</span></span><span style="display:flex;"><span>    in_sbom <span style="color:#f92672">=</span> <span style="color:#66d9ef">lambda</span> vex_purl: any(purl_match(vex_purl, s) <span style="color:#66d9ef">for</span> s <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">for</span> v <span style="color:#f92672">in</span> doc[<span style="color:#e6db74">&#34;vulnerabilities&#34;</span>]:
</span></span><span style="display:flex;"><span>        flags <span style="color:#f92672">=</span> {pid: f[<span style="color:#e6db74">&#34;label&#34;</span>] <span style="color:#66d9ef">for</span> f <span style="color:#f92672">in</span> v<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;flags&#34;</span>, [])
</span></span><span style="display:flex;"><span>                 <span style="color:#66d9ef">for</span> pid <span style="color:#f92672">in</span> f<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;product_ids&#34;</span>, []) <span style="color:#f92672">+</span> sum((groups[g] <span style="color:#66d9ef">for</span> g <span style="color:#f92672">in</span> f<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;group_ids&#34;</span>, [])), [])}
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> status, pids <span style="color:#f92672">in</span> v<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;product_status&#34;</span>, {})<span style="color:#f92672">.</span>items():
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">for</span> pid <span style="color:#f92672">in</span> pids:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> pid <span style="color:#f92672">in</span> rels:                      <span style="color:#75715e"># component-in-product: match BOTH sides</span>
</span></span><span style="display:flex;"><span>                    cat, comp, prod <span style="color:#f92672">=</span> rels[pid]
</span></span><span style="display:flex;"><span>                    comp_p, prod_p <span style="color:#f92672">=</span> purls<span style="color:#f92672">.</span>get(comp), purls<span style="color:#f92672">.</span>get(prod)
</span></span><span style="display:flex;"><span>                    hit <span style="color:#f92672">=</span> bool(comp_p <span style="color:#f92672">and</span> prod_p <span style="color:#f92672">and</span> purl_match(prod_p, parent) <span style="color:#f92672">and</span> in_sbom(comp_p))
</span></span><span style="display:flex;"><span>                    target <span style="color:#f92672">=</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">{</span>comp_p<span style="color:#e6db74">}</span><span style="color:#e6db74"> </span><span style="color:#e6db74">{</span>cat<span style="color:#e6db74">}</span><span style="color:#e6db74"> </span><span style="color:#e6db74">{</span>prod_p<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">else</span>:                                <span style="color:#75715e"># standalone product</span>
</span></span><span style="display:flex;"><span>                    p <span style="color:#f92672">=</span> purls<span style="color:#f92672">.</span>get(pid)
</span></span><span style="display:flex;"><span>                    hit <span style="color:#f92672">=</span> bool(p <span style="color:#f92672">and</span> (in_sbom(p) <span style="color:#f92672">or</span> purl_match(p, parent)))
</span></span><span style="display:flex;"><span>                    target <span style="color:#f92672">=</span> p
</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>v<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;cve&#39;</span>)<span style="color:#e6db74">}</span><span style="color:#ae81ff">\t</span><span style="color:#e6db74">{</span>status<span style="color:#e6db74">}</span><span style="color:#ae81ff">\t</span><span style="color:#e6db74">{</span>flags<span style="color:#f92672">.</span>get(pid, <span style="color:#e6db74">&#39;-&#39;</span>)<span style="color:#e6db74">}</span><span style="color:#ae81ff">\t</span><span style="color:#e6db74">{</span><span style="color:#e6db74">&#39;MATCH&#39;</span> <span style="color:#66d9ef">if</span> hit <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#39;no match&#39;</span><span style="color:#e6db74">}</span><span style="color:#ae81ff">\t</span><span style="color:#e6db74">{</span>target<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">if</span> __name__ <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;__main__&#34;</span>:
</span></span><span style="display:flex;"><span>    main(<span style="color:#f92672">*</span>sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">1</span>:<span style="color:#ae81ff">3</span>])
</span></span></code></pre></div><h3 id="test-run">Test run</h3>
<p>We ran it (<code>python3 -I csaf_vex_join.py vex.json sbom.cdx.json</code>) against the full example document and three small CycloneDX 1.6 SBOMs.</p>
<p><strong>SBOM 1</strong>, image 4.2.1 (<code>metadata.component.purl</code> = <code>pkg:oci/example-gateway@sha256%3A1111?tag=4.2.1</code>), containing <code>pkg:deb/debian/libexample@1.8.0-1?arch=amd64&amp;distro=debian-12</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-text" data-lang="text"><span style="display:flex;"><span>CVE-2099-0001  known_not_affected  vulnerable_code_not_in_execute_path  MATCH     pkg:deb/debian/libexample@1.8.0-1 default_component_of pkg:oci/example-gateway@sha256%3A1111?tag=4.2.1
</span></span><span style="display:flex;"><span>CVE-2099-0001  fixed               -                                    no match  pkg:deb/debian/libexample@1.8.2-1 default_component_of pkg:oci/example-gateway@sha256%3A2222?tag=4.3.0
</span></span></code></pre></div><p>The SBOM&rsquo;s extra <code>arch</code> and <code>distro</code> qualifiers did not block the match, because the VEX purl has none.</p>
<p><strong>SBOM 2</strong>, image 4.3.0 with libexample 1.8.2: the results flip. The <code>fixed</code> statement matches and the <code>known_not_affected</code> one does not.</p>
<p><strong>SBOM 3</strong>, the 4.2.1 SBOM again, but with the VEX component purl changed to <code>...libexample@1.8.0-1?arch=arm64</code>: <code>no match</code>, because the VEX purl now requires a qualifier value the SBOM lacks.</p>
<p>What the script does not do: version ranges (<code>product_version_range</code>), CPE-only products, hashes, <code>product_groups</code> in status lists (the schema does not allow them there), or the mandatory-test checks. It also prints matches and nothing else; it does not decide what to do with a CVE your scanner found but the VEX file does not mention.</p>
<h2 id="how-trivy-applies-csaf-vex">How Trivy applies CSAF VEX</h2>
<p>Trivy is a common place engineers first meet CSAF VEX. At tag v0.76.1, its documentation says CSAF works for container images, filesystems, code repositories, VM images, Kubernetes and SBOM targets, passed with <code>--vex</code>. Its matcher (<code>pkg/vex/csaf.go</code>) shows three things worth knowing:</p>
<ul>
<li><strong>Only two statuses act.</strong> The status map holds <code>known_not_affected</code> and <code>fixed</code>. A match on either filters the finding. <code>known_affected</code> and <code>under_investigation</code> statements never change the result.</li>
<li><strong>Matching is by purl.</strong> If the detected package has no purl, or the vulnerability has no <code>product_status</code>, nothing is filtered.</li>
<li><strong>Relationship categories are not distinguished.</strong> The documentation says &ldquo;the specified relationship category is not taken into account&rdquo; and that <code>default_component_of</code>, <code>installed_on</code> and <code>installed_with</code> &ldquo;are treated internally as <code>depends_on</code>&rdquo;. For a relationship, the code requires the parent purl to match a product higher in the dependency tree (such as the scanned image) and the component purl to match the sub-component, which is the both-sides rule from Step 2. <code>external_component_of</code> and <code>optional_component_of</code> relationships are skipped. A status on a plain product ID, with no relationship, is matched against the package&rsquo;s own purl, so it applies wherever that package appears.</li>
</ul>
<p>So if a vendor&rsquo;s CSAF file uses <code>optional_component_of</code> for a component, Trivy will not apply that status through the relationship. That is safe in direction (no false suppression), but it can surprise you if you expect the finding to drop.</p>
<h2 id="common-mistakes">Common mistakes</h2>
<ul>
<li><strong>Matching the component alone.</strong> Covered above. It turns a statement about one product into a statement about all of them.</li>
<li><strong>Trusting <code>known_not_affected</code> with no impact statement.</strong> The profile requires a flag or an impact threat for each such product. If neither exists, the document does not satisfy the profile; send it back to the vendor rather than suppressing.</li>
<li><strong>Reading only <code>branches</code>.</strong> Products in <code>full_product_names</code> and relationships carry IDs too. A parser that only walks branches will report &ldquo;unknown product&rdquo; for most relationship-heavy files.</li>
<li><strong>Treating <code>recommended</code> as fixed.</strong> The spec allows recommending a version from the affected group.</li>
<li><strong>Ignoring <code>tracking.version</code> and <code>current_release_date</code>.</strong> VEX files are revised as investigations finish. Store the version you applied, and re-apply when it changes.</li>
</ul>
<h2 id="where-this-fits">Where this fits</h2>
<p>VEX is one input to a patch decision, not the decision. In our <a href="/posts/vulnerability-prioritization-workflow-cve-record-to-patch-ticket/">vulnerability prioritization workflow</a>, a supplier VEX statement is the step that can close a ticket before KEV, EPSS or SSVC are weighed. For the SBOM side of the join, the <a href="/posts/sbom-minimum-elements-2026-what-cisa-and-partners-changed-from-the-2021-ntia-baseline/">SBOM minimum elements guide</a> covers which identifiers your SBOM should carry in the first place. Without purls in the SBOM, none of this matching works.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We read CSAF 2.0 from the OASIS TC&rsquo;s GitHub repository (the editor&rsquo;s draft prose, headed &ldquo;OASIS Standard, 18 November 2022&rdquo;, and the JSON schema) rather than the copy hosted on docs.oasis-open.org. CSAF 2.1 is still a draft in that repository and may change field names or add statuses. We did not download a Red Hat VEX file; Red Hat&rsquo;s structure is described from its own announcements. The purl matching rules are a community convention, not part of the purl standard. Our test document is synthetic.</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>CSAF 2.0 is an OASIS Standard dated 18 November 2022; VEX profile requirements (section 4.5)</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, OASIS TC repository</a></td>
          <td>Qualified (TC repository copy)</td>
      </tr>
      <tr>
          <td>Eight product_status lists and their definitions; flag labels; remediation categories; relationship categories</td>
          <td>Same document, sections 3.2.3.9, 3.2.3.5, 3.2.3.12.1, 3.2.2.4</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Contradiction groups must be pairwise disjoint; recommended may come from any group</td>
          <td>Same document, section 6.1.6</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>purl helper pattern and &ldquo;does not completely evaluate&rdquo; note</td>
          <td>Same document, section 3.1.3.3.4</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Example document is schema-valid</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>, validated with Python jsonschema</td>
          <td>Verified (our test)</td>
      </tr>
      <tr>
          <td>Red Hat VEX files: per CVE, product_tree with relationships, purls extended to unfixed rpm/oci/rpmmod components at GA</td>
          <td><a href="https://www.redhat.com/en/blog/red-hat-vex-files-cves-are-now-generally-available">Red Hat blog, 10 July 2024</a>; <a href="https://www.redhat.com/en/blog/vulnerability-exploitability-exchange-vex-beta-files-now-available">beta announcement, 16 October 2023</a></td>
          <td>Qualified (vendor announcement; GA post read via a localized copy)</td>
      </tr>
      <tr>
          <td>Trivy CSAF matcher uses known_not_affected and fixed; three relationship categories</td>
          <td><a href="https://github.com/aquasecurity/trivy/blob/v0.76.1/pkg/vex/csaf.go">Trivy v0.76.1 pkg/vex/csaf.go</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Trivy CSAF targets, relationship handling and purl matching rules</td>
          <td><a href="https://github.com/aquasecurity/trivy/blob/v0.76.1/docs/guide/supply-chain/vex/file.md">Trivy v0.76.1 VEX file documentation</a></td>
          <td>Verified</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>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></channel></rss>