<?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>Security Operations | FirewallSync</title><link>https://firewallsync.com/tags/security-operations/</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>Sat, 07 Nov 2026 04:50:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/security-operations/index.xml" rel="self" type="application/rss+xml"/><item><title>EPSS Scores for KEV CVEs: Why New Entries Score Low</title><link>https://firewallsync.com/posts/epss-scores-for-kev-cves-low-after-listing-threshold-audit/</link><guid>https://firewallsync.com/posts/epss-scores-for-kev-cves-low-after-listing-threshold-audit/</guid><pubDate>Sat, 07 Nov 2026 04:50:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>Security Operations</category><description>An EPSS cutoff of 0.1 misses a quarter of CISA's KEV catalog today, and half of the entries added in the last 90 days. We tracked 113 KEV additions through 145 daily EPSS v5 files: the median score on the day of listing was 0.0076, and entries that crossed 0.1 after listing took a median of 14 days. Data, a tested audit script and the rule it implies.</description><enclosure url="https://firewallsync.com/posts/epss-scores-for-kev-cves-low-after-listing-threshold-audit/cover_hu7102096413798716976.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/epss-scores-for-kev-cves-low-after-listing-threshold-audit/cover_hu7102096413798716976.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/epss-scores-for-kev-cves-low-after-listing-threshold-audit/cover_hu7102096413798716976.jpg" alt="A wooden-framed hourglass with grey sand running from the upper bulb into the lower one, photographed against a black background" width="1200" height="630"></p><p>If your patch pipeline uses an EPSS cutoff to decide what is urgent, it will miss about a quarter of CISA&rsquo;s Known Exploited Vulnerabilities (KEV) catalog, and roughly <strong>half of the entries CISA added in the last 90 days</strong>. Joining KEV catalog 2026.11.06 to the EPSS file for November 6, 2026, 436 of 1,768 scored KEV CVEs sit below 0.1. Tracking recent additions day by day shows why: EPSS scores for newly listed CVEs typically start low and take about two weeks to cross 0.1, which is the window in which a KEV listing matters most.</p>
<p>We argued in our <a href="/posts/cvss-vs-epss-vs-kev-which-signal-should-decide-what-you-patch-first/">CVSS vs EPSS vs KEV comparison</a> that KEV should be checked before EPSS. This article measures what happens when it is not, and gives you a script to test your own cutoff.</p>
<h2 id="the-snapshot-kev-cves-below-common-epss-cutoffs">The snapshot: KEV CVEs below common EPSS cutoffs</h2>
<p>Data: the <a href="https://github.com/cisagov/kev-data">KEV JSON</a> (catalog version 2026.11.06, released 2026-11-06T19:44Z, 1,769 entries) and the <a href="https://github.com/empiricalsec/epss_scores">EPSS daily file</a> for November 6, 2026 (<code>model_version:v2026.06.15</code>, 400,985 scored CVEs). One KEV entry, CVE-2026-19868, added that day, had no EPSS score yet.</p>
<table>
  <thead>
      <tr>
          <th>EPSS cutoff</th>
          <th>KEV CVEs below it (of 1,768)</th>
          <th>Share</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0.5</td>
          <td>861</td>
          <td>48.7%</td>
      </tr>
      <tr>
          <td>0.1</td>
          <td>436</td>
          <td>24.7%</td>
      </tr>
      <tr>
          <td>0.05</td>
          <td>307</td>
          <td>17.4%</td>
      </tr>
      <tr>
          <td>0.01</td>
          <td>54</td>
          <td>3.1%</td>
      </tr>
      <tr>
          <td>90th percentile</td>
          <td>248</td>
          <td>14.0%</td>
      </tr>
      <tr>
          <td>95th percentile</td>
          <td>405</td>
          <td>22.9%</td>
      </tr>
  </tbody>
</table>
<p>The median KEV CVE scores 0.536 (99.0th percentile), so the typical entry is well above any cutoff. The problem is the tail, and the tail is not random. If you are unsure what the <code>epss</code> and <code>percentile</code> columns mean, our <a href="/posts/epss-data-feed-columns-percentile-vs-probability-and-how-to-join-it-to-a-scanner-export/">EPSS data feed guide</a> covers them.</p>
<h2 id="newest-kev-entries-score-lowest">Newest KEV entries score lowest</h2>
<p>Split by <code>dateAdded</code>, the share below 0.1 falls as entries age:</p>
<table>
  <thead>
      <tr>
          <th>Listed in KEV</th>
          <th>KEV CVEs</th>
          <th>Below 0.1</th>
          <th>Share</th>
          <th>Median EPSS</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Within 90 days</td>
          <td>106</td>
          <td>52</td>
          <td>49.1%</td>
          <td>0.104</td>
      </tr>
      <tr>
          <td>91 to 365 days ago</td>
          <td>207</td>
          <td>57</td>
          <td>27.5%</td>
          <td>0.444</td>
      </tr>
      <tr>
          <td>More than 1 year ago</td>
          <td>1,455</td>
          <td>327</td>
          <td>22.5%</td>
          <td>0.583</td>
      </tr>
  </tbody>
</table>
<p>Several recent listings we covered are in that first row. On November 6, WordPress CVE-2026-119977 (added November 3) scored 0.0116, Ivanti CVE-2026-111357 and CVE-2026-118527 (added October 30) scored 0.0053 and 0.0082, and VMware ESX CVE-2026-41709 (added October 28) scored 0.0069. Each would sit below even a 0.05 cutoff.</p>
<h2 id="tracking-the-lag-113-kev-additions-day-by-day">Tracking the lag: 113 KEV additions, day by day</h2>
<p>A snapshot cannot show whether low scores are permanent or just early. So we took the 113 KEV entries added between June 16 and October 7, 2026, and looked up each one in every daily EPSS file from June 15 to November 6 (145 files). That window starts the day after EPSS v5 replaced v4, so every score comes from the same model (<code>v2026.06.15</code>); our <a href="/posts/epss-v5-score-shift-model-version-v2026-06-15/">EPSS v5 analysis</a> explains why mixing models would distort this. The October 7 end date gives every entry at least 30 days of follow-up.</p>
<table>
  <thead>
      <tr>
          <th>Day relative to KEV listing</th>
          <th>Entries with a score</th>
          <th>At or above 0.1</th>
          <th>Share</th>
          <th>Median EPSS</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Day before</td>
          <td>77</td>
          <td>15</td>
          <td>19.5%</td>
          <td>0.0072</td>
      </tr>
      <tr>
          <td>Day of listing</td>
          <td>91</td>
          <td>15</td>
          <td>16.5%</td>
          <td>0.0076</td>
      </tr>
      <tr>
          <td>7 days after</td>
          <td>113</td>
          <td>38</td>
          <td>33.6%</td>
          <td>0.0220</td>
      </tr>
      <tr>
          <td>30 days after</td>
          <td>113</td>
          <td>68</td>
          <td>60.2%</td>
          <td>0.1689</td>
      </tr>
  </tbody>
</table>
<p>Notes on reading it:</p>
<ul>
<li><strong>Missing scores are real.</strong> 36 of the 113 had no EPSS score the day before listing and 22 had none on the day itself, usually because the CVE had only just been published. By day 7 all 113 were scored.</li>
<li><strong>Crossing time.</strong> 15 entries were already at or above 0.1 the day before listing. Of the rest, 64 crossed 0.1 later, after a median of <strong>14 days</strong> (23 within a week). 34 had not reached 0.1 by November 6, 36 to 115 days after listing.</li>
<li><strong>Timing, not cause.</strong> EPSS files publish only the score. We cannot see which inputs moved a score, so we do not claim the KEV listing itself caused any rise.</li>
</ul>
<p>Why the gap exists is consistent with how each list is built. CISA adds a CVE when there is &ldquo;reliable evidence that the vulnerability has been actively exploited in the wild&rdquo; (<a href="https://cisa.gov/known-exploited-vulnerabilities">CISA</a>). EPSS is &ldquo;the probability [0-1] of exploitation in the wild in the next 30 days (following score publication)&rdquo;, estimated from data (<a href="https://github.com/empiricalsec/epss_scores">EPSS scores README</a>). The community <a href="https://riskbasedprioritization.github.io/epss/EPSS_and_CISA_KEV">Risk Based Prioritization guide</a> puts it this way: EPSS reflects &ldquo;the automated data we receive and collect&rdquo;, while KEV is &ldquo;a list of human curated CVEs with historical exploitation activity&rdquo;, so a CVE can be in one and score low in the other. It also quotes FIRST&rsquo;s EPSS FAQ: where exploitation is evidenced, &ldquo;that information should supersede anything EPSS has to say&rdquo;.</p>
<h2 id="what-to-change-in-a-pipeline">What to change in a pipeline</h2>
<p>These are our recommendations from the data above; we have not measured them on anyone&rsquo;s backlog.</p>
<ol>
<li><strong>Route on KEV membership before any EPSS rule.</strong> A listed CVE goes to the urgent queue whatever its score. This is the first step in our <a href="/posts/vulnerability-prioritization-workflow-cve-record-to-patch-ticket/">prioritization workflow</a>.</li>
<li><strong>Do not &ldquo;promote&rdquo; KEV entries by adding a bonus to the EPSS score.</strong> A bonus sized for the median KEV entry (0.536) does nothing for an entry at 0.005.</li>
<li><strong>Treat a missing EPSS score as &ldquo;unknown&rdquo;, not zero.</strong> 22 of the 113 entries had no score on the day they were listed. A join that fills nulls with 0 drops exactly those.</li>
<li><strong>Refresh KEV more often than you refresh thresholds.</strong> The <a href="/posts/cisa-kev-json-feed-what-each-field-means-and-how-to-match-it-against-a-scanner-export/">KEV JSON feed</a> updates on listing days; the lag above means the KEV check is doing nearly all of the work for the first two weeks.</li>
<li><strong>Re-run the audit after every EPSS model change</strong>, since cutoffs shift with the model.</li>
</ol>
<h2 id="audit-your-own-cutoff">Audit your own cutoff</h2>
<p>This script joins the two public files and reports how many KEV entries your cutoff misses, by listing age. We ran it with Python 3.12 on the files named above; the output below is for 0.1.</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;How many CISA KEV entries would an EPSS cutoff miss?
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">usage: kev_epss_audit.py known_exploited_vulnerabilities.json epss_scores-YYYY-MM-DD.csv.gz CUTOFF&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> csv<span style="color:#f92672">,</span> gzip<span style="color:#f92672">,</span> json<span style="color:#f92672">,</span> sys
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> datetime <span style="color:#f92672">import</span> date
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>kev_path, epss_path, cutoff <span style="color:#f92672">=</span> 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>], float(sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">3</span>])
</span></span><span style="display:flex;"><span>catalog <span style="color:#f92672">=</span> json<span style="color:#f92672">.</span>load(open(kev_path))
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">with</span> gzip<span style="color:#f92672">.</span>open(epss_path, <span style="color:#e6db74">&#34;rt&#34;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>    header <span style="color:#f92672">=</span> f<span style="color:#f92672">.</span>readline()<span style="color:#f92672">.</span>strip()                 <span style="color:#75715e"># &#34;#model_version:...,score_date:...&#34;</span>
</span></span><span style="display:flex;"><span>    scores <span style="color:#f92672">=</span> {r[<span style="color:#e6db74">&#34;cve&#34;</span>]: float(r[<span style="color:#e6db74">&#34;epss&#34;</span>]) <span style="color:#66d9ef">for</span> r <span style="color:#f92672">in</span> csv<span style="color:#f92672">.</span>DictReader(f)}
</span></span><span style="display:flex;"><span>score_day <span style="color:#f92672">=</span> date<span style="color:#f92672">.</span>fromisoformat(header<span style="color:#f92672">.</span>split(<span style="color:#e6db74">&#34;score_date:&#34;</span>)[<span style="color:#ae81ff">1</span>][:<span style="color:#ae81ff">10</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;KEV catalog </span><span style="color:#e6db74">{</span>catalog[<span style="color:#e6db74">&#39;catalogVersion&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">, EPSS </span><span style="color:#e6db74">{</span>header[<span style="color:#ae81ff">1</span>:]<span style="color:#e6db74">}</span><span style="color:#e6db74">, cutoff </span><span style="color:#e6db74">{</span>cutoff<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>buckets <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;added &lt;= 90 days ago&#34;</span>: [], <span style="color:#e6db74">&#34;91-365 days&#34;</span>: [], <span style="color:#e6db74">&#34;over 1 year&#34;</span>: []}
</span></span><span style="display:flex;"><span>unscored <span style="color:#f92672">=</span> []
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> v <span style="color:#f92672">in</span> catalog[<span style="color:#e6db74">&#34;vulnerabilities&#34;</span>]:
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> v[<span style="color:#e6db74">&#34;cveID&#34;</span>] <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> scores:
</span></span><span style="display:flex;"><span>        unscored<span style="color:#f92672">.</span>append(v[<span style="color:#e6db74">&#34;cveID&#34;</span>])
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">continue</span>
</span></span><span style="display:flex;"><span>    age <span style="color:#f92672">=</span> (score_day <span style="color:#f92672">-</span> date<span style="color:#f92672">.</span>fromisoformat(v[<span style="color:#e6db74">&#34;dateAdded&#34;</span>]))<span style="color:#f92672">.</span>days
</span></span><span style="display:flex;"><span>    name <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;added &lt;= 90 days ago&#34;</span> <span style="color:#66d9ef">if</span> age <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">90</span> <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;91-365 days&#34;</span> <span style="color:#66d9ef">if</span> age <span style="color:#f92672">&lt;=</span> <span style="color:#ae81ff">365</span> <span style="color:#66d9ef">else</span> <span style="color:#e6db74">&#34;over 1 year&#34;</span>
</span></span><span style="display:flex;"><span>    buckets[name]<span style="color:#f92672">.</span>append((v[<span style="color:#e6db74">&#34;cveID&#34;</span>], scores[v[<span style="color:#e6db74">&#34;cveID&#34;</span>]]))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>total <span style="color:#f92672">=</span> below <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> name, rows <span style="color:#f92672">in</span> buckets<span style="color:#f92672">.</span>items():
</span></span><span style="display:flex;"><span>    n_below <span style="color:#f92672">=</span> sum(<span style="color:#ae81ff">1</span> <span style="color:#66d9ef">for</span> _, s <span style="color:#f92672">in</span> rows <span style="color:#66d9ef">if</span> s <span style="color:#f92672">&lt;</span> cutoff)
</span></span><span style="display:flex;"><span>    total <span style="color:#f92672">+=</span> len(rows); below <span style="color:#f92672">+=</span> n_below
</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>name<span style="color:#e6db74">:</span><span style="color:#e6db74">22</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> </span><span style="color:#e6db74">{</span>len(rows)<span style="color:#e6db74">:</span><span style="color:#e6db74">5</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> KEV CVEs, </span><span style="color:#e6db74">{</span>n_below<span style="color:#e6db74">:</span><span style="color:#e6db74">4</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> below cutoff (</span><span style="color:#e6db74">{</span>n_below<span style="color:#f92672">/</span>len(rows)<span style="color:#e6db74">:</span><span style="color:#e6db74">.1%</span><span style="color:#e6db74">}</span><span style="color:#e6db74">)&#34;</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><span style="color:#e6db74">&#39;all scored&#39;</span><span style="color:#e6db74">:</span><span style="color:#e6db74">22</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> </span><span style="color:#e6db74">{</span>total<span style="color:#e6db74">:</span><span style="color:#e6db74">5</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> KEV CVEs, </span><span style="color:#e6db74">{</span>below<span style="color:#e6db74">:</span><span style="color:#e6db74">4</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> below cutoff (</span><span style="color:#e6db74">{</span>below<span style="color:#f92672">/</span>total<span style="color:#e6db74">:</span><span style="color:#e6db74">.1%</span><span style="color:#e6db74">}</span><span style="color:#e6db74">)&#34;</span>)
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;not in EPSS file: </span><span style="color:#e6db74">{</span>len(unscored)<span style="color:#e6db74">}</span><span style="color:#e6db74"> </span><span style="color:#e6db74">{</span>unscored[:<span style="color:#ae81ff">5</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>$ python3 kev_epss_audit.py known_exploited_vulnerabilities.json epss_scores-2026-11-06.csv.gz 0.1
</span></span><span style="display:flex;"><span>KEV catalog 2026.11.06, EPSS model_version:v2026.06.15,score_date:2026-11-06T12:00:23Z, cutoff 0.1
</span></span><span style="display:flex;"><span>added &lt;= 90 days ago     106 KEV CVEs,   52 below cutoff (49.1%)
</span></span><span style="display:flex;"><span>91-365 days              207 KEV CVEs,   57 below cutoff (27.5%)
</span></span><span style="display:flex;"><span>over 1 year             1455 KEV CVEs,  327 below cutoff (22.5%)
</span></span><span style="display:flex;"><span>all scored              1768 KEV CVEs,  436 below cutoff (24.7%)
</span></span><span style="display:flex;"><span>not in EPSS file: 1 [&#39;CVE-2026-19868&#39;]
</span></span></code></pre></div><p>Get the KEV file from <code>raw.githubusercontent.com/cisagov/kev-data/main/known_exploited_vulnerabilities.json</code> and the EPSS file from the <code>empiricalsec/epss_scores</code> repository (one gzip file per day under the year folder). The script reads the whole EPSS file into memory, which is fine for about 400,000 rows.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>The snapshot is one day (November 6, 2026). The day-by-day sample is 113 entries over one model version, which is enough to show a pattern but not to estimate a precise lag for any vendor or CVE type. &ldquo;Day of listing&rdquo; compares KEV <code>dateAdded</code> to the EPSS file of the same date; their publication times within the day differ. We did not examine why the 34 entries that stayed below 0.1 scored as they did.</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>KEV counts, below-cutoff shares, medians, age buckets, per-CVE scores</td>
          <td><a href="https://github.com/cisagov/kev-data">cisagov/kev-data</a> catalog 2026.11.06 joined to <a href="https://github.com/empiricalsec/epss_scores">empiricalsec/epss_scores</a> 2026-11-06, computed with the script above</td>
          <td>Verified (computed)</td>
      </tr>
      <tr>
          <td>113-entry day-by-day results (medians, shares, 14-day median crossing, 34 never)</td>
          <td>Same KEV file and 145 daily EPSS files 2026-06-15 to 2026-11-06, all <code>v2026.06.15</code>, computed in Python</td>
          <td>Verified (computed)</td>
      </tr>
      <tr>
          <td>EPSS column definitions and 30-day window</td>
          <td><a href="https://github.com/empiricalsec/epss_scores">EPSS scores README</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>KEV inclusion criterion</td>
          <td><a href="https://cisa.gov/known-exploited-vulnerabilities">CISA KEV catalog page</a></td>
          <td>Verified, Nov 7, 2026</td>
      </tr>
      <tr>
          <td>Data-generating difference between EPSS and KEV; FIRST FAQ &ldquo;supersede&rdquo; quote</td>
          <td><a href="https://riskbasedprioritization.github.io/epss/EPSS_and_CISA_KEV">Risk Based Prioritization guide</a> (community guide quoting FIRST)</td>
          <td>Qualified: secondary for the FIRST quote</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>EPSS v5: How Scores Shifted and What to Re-baseline</title><link>https://firewallsync.com/posts/epss-v5-score-shift-model-version-v2026-06-15/</link><guid>https://firewallsync.com/posts/epss-v5-score-shift-model-version-v2026-06-15/</guid><pubDate>Fri, 06 Nov 2026 04:45:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>Security Operations</category><description>EPSS v5 (model v2026.06.15) replaced v4 in the daily feed on June 15, 2026. We diffed the last v4 file against the first v5 file: 26% fewer CVEs at or above 0.1, 41% fewer at or above 0.5, a median score almost three times higher, and only 588 of the top 1,000 CVEs unchanged. Here is what that means for threshold rules, and a tested guard that stops your pipeline on the next model change.</description><enclosure url="https://firewallsync.com/posts/epss-v5-score-shift-model-version-v2026-06-15/cover_hu8687825451073364504.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/epss-v5-score-shift-model-version-v2026-06-15/cover_hu8687825451073364504.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/epss-v5-score-shift-model-version-v2026-06-15/cover_hu8687825451073364504.jpg" alt="Close-up of an oval dial thermometer whose glass is covered in ice crystals, with a red needle visible beneath the frost" width="1200" height="630"></p><p>The EPSS daily file changed models on <strong>June 15, 2026</strong>. The first line of the June 14 file reads <code>#model_version:v2025.03.14</code> (v4). The June 15 file reads <code>#model_version:v2026.06.15</code> (v5). We compared those two files, which together hold 340,204 CVEs scored on both days. If your pipeline gates on a fixed EPSS probability, it lost about a quarter of its flagged CVEs overnight: <strong>23,069 CVEs scored 0.1 or higher under v4 and 17,041 under v5</strong>. Nothing about those vulnerabilities changed that day; the model did.</p>
<p>This article covers what changed in v5, what the shift looks like in the data, and what to re-baseline. For the file format itself (columns, header line, how to join it to a scanner export), see our <a href="/posts/epss-data-feed-columns-percentile-vs-probability-and-how-to-join-it-to-a-scanner-export/">EPSS data feed guide</a>. That guide already lists the five model versions and their start dates; this one measures the most recent change.</p>
<h2 id="what-changed-in-v5">What changed in v5</h2>
<p>Empirical Security, which runs the EPSS model and hosts the data, <a href="https://lifestyle.kbew98country.com/story/220743/empirical-security-releases-epss-v5/">announced EPSS V5 on May 13, 2026</a>. The release says v5 &ldquo;incorporates improved modeling techniques and optimization algorithms&rdquo; and that &ldquo;the calibration step has been refined.&rdquo; It also cites a better classifier for published exploit code and &ldquo;the addition of new data feeds.&rdquo;</p>
<p>The headline claim is a 23% improvement. Empirical&rsquo;s <a href="https://research.empiricalsecurity.com/research/epss-v5-is-here">research post</a> defines the metric only loosely, as one that rewards &ldquo;ranking genuinely exploited vulnerabilities above ones that pose no real threat.&rdquo; On data from May 4, 2026, it reports that v4 scored 0.514 and v5 scored 0.633, where &ldquo;a random guess would score around 0.025, and a perfect model would score 1.0.&rdquo; We did not reproduce that evaluation.</p>
<p>The rollout is visible in the public <a href="https://github.com/empiricalsec/epss_scores">empiricalsec/epss_scores</a> repository:</p>
<table>
  <thead>
      <tr>
          <th>Date</th>
          <th>What the repository shows</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>2026-05-22 to 2026-06-08</td>
          <td><code>beta_scores/epssv5_beta-*.csv.gz</code>, header <code>#model_version:v2026.05.12</code></td>
      </tr>
      <tr>
          <td>2026-06-14</td>
          <td>Last daily file on <code>v2025.03.14</code> (v4), <code>score_date</code> 12:55:00Z</td>
      </tr>
      <tr>
          <td>2026-06-15</td>
          <td>First daily file on <code>v2026.06.15</code> (v5), <code>score_date</code> 12:03:41Z</td>
      </tr>
      <tr>
          <td>2026-11-05</td>
          <td>Still <code>v2026.06.15</code></td>
      </tr>
  </tbody>
</table>
<p>The beta files carry a different model label (<code>v2026.05.12</code>) from the production model, so treat beta scores as a preview of v5, not as v5 itself. The repository README lists the model changes for v2, v3 and v4 but, as of November 6, 2026, has no v5 entry. The file header was the reliable signal.</p>
<p>The CSV format did not change. Both files have the same comment line followed by <code>cve,epss,percentile</code>.</p>
<h2 id="how-the-scores-moved">How the scores moved</h2>
<p>We loaded <code>2026/epss_scores-2026-06-14.csv.gz</code> and <code>2026/epss_scores-2026-06-15.csv.gz</code> from the repository and compared CVEs present in both. For a baseline, we ran the same comparison on June 13 and June 14, two consecutive v4 days.</p>
<table>
  <thead>
      <tr>
          <th>Measure</th>
          <th>v4 (June 14)</th>
          <th>v5 (June 15)</th>
          <th>Normal day (June 13 to 14)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CVEs scored</td>
          <td>341,350</td>
          <td>340,247</td>
          <td>341,309 to 341,350</td>
      </tr>
      <tr>
          <td>Median EPSS</td>
          <td>0.00262</td>
          <td>0.00751</td>
          <td>unchanged</td>
      </tr>
      <tr>
          <td>Mean EPSS</td>
          <td>0.03328</td>
          <td>0.02934</td>
          <td>unchanged</td>
      </tr>
      <tr>
          <td>99th percentile of EPSS</td>
          <td>0.77230</td>
          <td>0.59470</td>
          <td>n/a</td>
      </tr>
      <tr>
          <td>CVEs at or above 0.1</td>
          <td>23,069</td>
          <td>17,041</td>
          <td>23,071 to 23,069</td>
      </tr>
      <tr>
          <td>CVEs at or above 0.5</td>
          <td>7,193</td>
          <td>4,249</td>
          <td>7,194 to 7,193</td>
      </tr>
      <tr>
          <td>Crossed 0.1 upward / downward</td>
          <td>3,820 / 9,847</td>
          <td></td>
          <td>9 / 11</td>
      </tr>
      <tr>
          <td>Crossed the 90th percentile in / out</td>
          <td>9,837 / 9,944</td>
          <td></td>
          <td>25 / 29</td>
      </tr>
      <tr>
          <td>Moved by 2x or more</td>
          <td>243,259 of 340,204 (71.5%)</td>
          <td></td>
          <td>313 of 341,309</td>
      </tr>
      <tr>
          <td>Top 1,000 by EPSS that stayed top 1,000</td>
          <td>588</td>
          <td></td>
          <td>999</td>
      </tr>
  </tbody>
</table>
<p>Three patterns stand out.</p>
<p><strong>The distribution got narrower.</strong> The lowest v4 score was 0.00001 and 93,484 CVEs sat below 0.001. Under v5 the minimum is 0.00046 and only 3,552 CVEs are below 0.001. At the top end, the 99th percentile fell from 0.772 to 0.595. The median ratio of v5 to v4 score was 2.89: most low-scored CVEs went up, while many of the high scorers came down. That fits the release&rsquo;s description of recalibration, but the release does not say this is the intended effect, so treat it as our reading of the data.</p>
<p><strong>Fixed cutoffs shrank.</strong> A rule like <code>epss &gt;= 0.1</code> matched 26.1% fewer CVEs the day v5 went live, and <code>epss &gt;= 0.5</code> matched 40.9% fewer. Of the CVEs that crossed 0.1, 9,847 dropped below it and only 3,820 rose above it.</p>
<p><strong>Percentile rules kept their size, not their members.</strong> A percentile is a rank, so the 90th-percentile bucket stays about 10% of scored CVEs. But 9,837 CVEs entered it and 9,944 left it in one day, compared with 25 and 29 on a normal v4 day. If you track &ldquo;new CVEs in the top decile&rdquo; as a work queue, June 15 would have looked like a flood.</p>
<p>1,146 CVEs scored on June 14 were missing from the June 15 file and 43 appeared for the first time. By November 5, all but 119 of the missing ones were back in the feed. We did not establish why they were absent that day.</p>
<h2 id="what-happened-to-kev-listed-cves">What happened to KEV-listed CVEs</h2>
<p>To see whether v5 demoted known-exploited vulnerabilities, we took the <a href="https://github.com/cisagov/kev-data">CISA KEV catalog</a> (catalog version 2026.11.04) and kept only the 1,619 entries whose <code>dateAdded</code> is on or before June 14, 2026, so the set matches what was known on the switch date. All 1,619 were scored on both days.</p>
<table>
  <thead>
      <tr>
          <th>KEV CVEs (added by 2026-06-14)</th>
          <th>v4</th>
          <th>v5</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Median EPSS</td>
          <td>0.704</td>
          <td>0.535</td>
      </tr>
      <tr>
          <td>At or above 0.1</td>
          <td>1,224</td>
          <td>1,219</td>
      </tr>
      <tr>
          <td>At or above the 90th percentile</td>
          <td>1,323</td>
          <td>1,366</td>
      </tr>
  </tbody>
</table>
<p>The probability for KEV entries came down along with the rest of the high end. But almost the same number cleared 0.1, and more of them landed in the top decile. KEV-listed CVEs that a 0.1 rule already caught were still caught. A rule set to 0.5 or 0.7 on the assumption that KEV CVEs sit there would have lost many of them, since the median is now about 0.54.</p>
<p>This is one reason KEV should stay a separate input instead of being folded into an EPSS cutoff. 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 why; the <a href="/posts/vulnerability-prioritization-workflow-cve-record-to-patch-ticket/">prioritization workflow</a> shows where each signal sits in the patch decision.</p>
<h2 id="what-to-re-baseline">What to re-baseline</h2>
<p>These are our recommendations, based on the data above. We have not measured their effect on any team&rsquo;s backlog.</p>
<ol>
<li><strong>Find every fixed probability cutoff.</strong> SLA rules, ticket triggers, dashboard colours and scanner policies. If one was tuned against v4 data, check how many CVEs it matches today versus before June 15.</li>
<li><strong>Decide what the cutoff is for.</strong> If it sizes a queue (&ldquo;the top few thousand CVEs&rdquo;), switch it to a percentile. If it expresses risk (&ldquo;at least a 10% chance of exploitation in 30 days&rdquo;), keep the probability, because v5&rsquo;s calibration is meant to make that reading more accurate, but accept that the queue size will move.</li>
<li><strong>Do not compare scores across the switch.</strong> A CVE that went from 0.4 to 0.2 on June 15 did not become less likely to be exploited that day. If you chart EPSS history, mark June 15, 2026 (and the earlier switches on 2022-02-04, 2023-03-07 and 2025-03-17).</li>
<li><strong>Keep KEV as an override.</strong> Do not rely on EPSS to bring KEV entries above a high cutoff.</li>
<li><strong>Store <code>model_version</code> next to every score.</strong> Then you can always tell which model produced a score your system acted on.</li>
</ol>
<h2 id="a-guard-for-the-next-model-change">A guard for the next model change</h2>
<p>There is no API callback when the model changes, but the file tells you. This script compares the header of yesterday&rsquo;s and today&rsquo;s file and exits non-zero on a model change, so a CI job or cron wrapper can stop before ingesting.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#e6db74">&#34;&#34;&#34;Stop an EPSS ingest when the model version changes.
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">Usage: python3 epss_model_guard.py yesterday.csv.gz today.csv.gz&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> gzip<span style="color:#f92672">,</span> sys
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">header</span>(path):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">with</span> gzip<span style="color:#f92672">.</span>open(path, <span style="color:#e6db74">&#34;rt&#34;</span>) <span style="color:#66d9ef">as</span> f:
</span></span><span style="display:flex;"><span>        first <span style="color:#f92672">=</span> f<span style="color:#f92672">.</span>readline()<span style="color:#f92672">.</span>strip()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> first<span style="color:#f92672">.</span>startswith(<span style="color:#e6db74">&#34;#&#34;</span>):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> {<span style="color:#e6db74">&#34;model_version&#34;</span>: <span style="color:#e6db74">&#34;pre-v2 (no header)&#34;</span>, <span style="color:#e6db74">&#34;score_date&#34;</span>: <span style="color:#e6db74">&#34;&#34;</span>}
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</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> first<span style="color:#f92672">.</span>lstrip(<span style="color:#e6db74">&#34;#&#34;</span>)<span style="color:#f92672">.</span>split(<span style="color:#e6db74">&#34;,&#34;</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>old, new <span style="color:#f92672">=</span> header(sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">1</span>]), header(sys<span style="color:#f92672">.</span>argv[<span style="color:#ae81ff">2</span>])
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;previous: </span><span style="color:#e6db74">{</span>old[<span style="color:#e6db74">&#39;model_version&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74"> scored </span><span style="color:#e6db74">{</span>old[<span style="color:#e6db74">&#39;score_date&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;current:  </span><span style="color:#e6db74">{</span>new[<span style="color:#e6db74">&#39;model_version&#39;</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74"> scored </span><span style="color:#e6db74">{</span>new[<span style="color:#e6db74">&#39;score_date&#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> old[<span style="color:#e6db74">&#34;model_version&#34;</span>] <span style="color:#f92672">!=</span> new[<span style="color:#e6db74">&#34;model_version&#34;</span>]:
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">&#34;MODEL CHANGE: hold threshold-based alerts and re-baseline before ingesting&#34;</span>)
</span></span><span style="display:flex;"><span>    sys<span style="color:#f92672">.</span>exit(<span style="color:#ae81ff">2</span>)
</span></span></code></pre></div><p>We ran it with Python 3 against files from the repository:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>$ python3 epss_model_guard.py epss_scores-2026-06-14.csv.gz epss_scores-2026-06-15.csv.gz
</span></span><span style="display:flex;"><span>previous: v2025.03.14 scored 2026-06-14T12:55:00Z
</span></span><span style="display:flex;"><span>current:  v2026.06.15 scored 2026-06-15T12:03:41Z
</span></span><span style="display:flex;"><span>MODEL CHANGE: hold threshold-based alerts and re-baseline before ingesting
</span></span><span style="display:flex;"><span>exit=2
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$ python3 epss_model_guard.py epss_scores-2026-11-04.csv.gz epss_scores-2026-11-05.csv.gz
</span></span><span style="display:flex;"><span>previous: v2026.06.15 scored 2026-11-04T12:03:12Z
</span></span><span style="display:flex;"><span>current:  v2026.06.15 scored 2026-11-05T12:03:04Z
</span></span><span style="display:flex;"><span>exit=0
</span></span></code></pre></div><p>When the guard trips, run the same comparison we did above (counts at your cutoffs, percentile churn, KEV coverage) before you turn alerts back on. Note the <code>score_date</code> format also changed with v4 (from <code>+0000</code> offsets to <code>Z</code>), so parse it as ISO 8601 instead of matching a fixed string.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<ul>
<li>We compared one pair of days. Scores on later v5 days have kept moving as new signals arrive, so today&rsquo;s counts differ from June 15.</li>
<li>The 23% improvement figure and the description of model changes come from Empirical Security. We did not test model accuracy.</li>
<li>KEV is a list of vulnerabilities known to be exploited, not a ground truth for EPSS&rsquo;s 30-day prediction window, so the KEV table shows coverage, not accuracy.</li>
<li>We did not find a FIRST page describing v5 in the sources we could reach. If FIRST publishes a different start date or model description, the file headers above are still what your pipeline will see.</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>Model version and score date of each daily and beta file; all score counts, medians, percentiles and crossings</td>
          <td><a href="https://github.com/empiricalsec/epss_scores">empiricalsec/epss_scores</a> files for 2026-06-13, 06-14, 06-15, 11-04, 11-05 and <code>beta_scores/</code>, computed with Python</td>
          <td>Verified (computed)</td>
      </tr>
      <tr>
          <td>README lists v2, v3 and v4 start dates and no v5 entry</td>
          <td><a href="https://github.com/empiricalsec/epss_scores">epss_scores README</a></td>
          <td>Verified, Nov 6, 2026</td>
      </tr>
      <tr>
          <td>v5 announced May 13, 2026; modeling, calibration, exploit-code classifier and data-feed changes; 23% improvement</td>
          <td><a href="https://lifestyle.kbew98country.com/story/220743/empirical-security-releases-epss-v5/">Empirical Security press release</a></td>
          <td>Verified (vendor claim)</td>
      </tr>
      <tr>
          <td>v4 scored 0.514 and v5 0.633 on May 4, 2026 data; random about 0.025</td>
          <td><a href="https://research.empiricalsecurity.com/research/epss-v5-is-here">EPSS V5 Is Here</a></td>
          <td>Qualified: metric not fully specified</td>
      </tr>
      <tr>
          <td>KEV entries added by 2026-06-14 (1,619) and their scores</td>
          <td><a href="https://github.com/cisagov/kev-data">cisagov/kev-data</a>, catalog 2026.11.04, joined to the EPSS files</td>
          <td>Verified (computed)</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></channel></rss>