<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nicholas Toledo</title>
    <description>The latest articles on DEV Community by Nicholas Toledo (@nicholas_toledo_5a6f9e576).</description>
    <link>https://dev.to/nicholas_toledo_5a6f9e576</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3451687%2F413060e7-6593-4c39-b8b9-b81f3e7406a4.png</url>
      <title>DEV Community: Nicholas Toledo</title>
      <link>https://dev.to/nicholas_toledo_5a6f9e576</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nicholas_toledo_5a6f9e576"/>
    <language>en</language>
    <item>
      <title>The EU Cyber Resilience Act and the new Product Liability Directive — what an engineer actually has to do</title>
      <dc:creator>Nicholas Toledo</dc:creator>
      <pubDate>Tue, 15 Sep 2026 05:08:14 +0000</pubDate>
      <link>https://dev.to/nicholas_toledo_5a6f9e576/the-eu-cyber-resilience-act-and-the-new-product-liability-directive-what-an-engineer-actually-has-1l2a</link>
      <guid>https://dev.to/nicholas_toledo_5a6f9e576/the-eu-cyber-resilience-act-and-the-new-product-liability-directive-what-an-engineer-actually-has-1l2a</guid>
      <description>&lt;p&gt;Two EU instruments now touch the code you ship. They ask different questions, and conflating them is where most of the confusion comes from.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Regulation (EU) 2024/2847&lt;/strong&gt;, the Cyber Resilience Act (CRA). Its Article 14 reporting obligations became enforceable on &lt;strong&gt;11 September 2026&lt;/strong&gt;. A regulation applies directly, with no national statute in between.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Directive (EU) 2024/2853&lt;/strong&gt;, the new Product Liability Directive (PLD). It applies to products placed on the market &lt;strong&gt;after 8 December 2026&lt;/strong&gt;. A directive binds you through your member state's transposition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am an engineer, not a lawyer. Everything below is engineering guidance — what you can check, what you can build, which common readings are wrong. It is not legal advice, and for the PLD the text that governs you is your national implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The date that is printed wrong almost everywhere
&lt;/h2&gt;

&lt;p&gt;Article 2(1) of the PLD sets the scope of the whole directive by reference to when a product was placed on the market. As published in the Official Journal, that date read &lt;strong&gt;9 December 2026&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It was corrected. &lt;a href="http://data.europa.eu/eli/dir/2024/2853/corrigendum/2026-05-07/oj" rel="noopener noreferrer"&gt;Corrigendum 2026/90364 (OJ L, 2026/90364, 7.5.2026)&lt;/a&gt; changes Article 2(1) to read "after &lt;strong&gt;8&lt;/strong&gt; December 2026".&lt;/p&gt;

&lt;p&gt;Three things follow, and the third catches people:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The corrigendum touches the &lt;strong&gt;scope&lt;/strong&gt; sentence only. Articles 20, 21 and 22(1) still say 9 December 2026, and they should — those govern transposition and the repeal of Directive 85/374/EEC, not which of your releases falls under the new regime. Do not harmonise them in your notes.&lt;/li&gt;
&lt;li&gt;EUR-Lex serves the &lt;em&gt;original&lt;/em&gt; OJ text at the &lt;code&gt;eli/dir/2024/2853/oj&lt;/code&gt; URL. Corrigenda live at separate URLs and appear in the consolidated version. If you read the directive in 2024 and wrote the date down, your note is stale.&lt;/li&gt;
&lt;li&gt;Most secondary commentary — law-firm explainers, conference slides, the summary someone pasted into your Confluence — predates the corrigendum and still says 9 December.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The delta is one day, and it is a real day: a product placed on the market on 9 December 2026 is in scope under the corrected text and arguably out of scope under the text most people quote. Whether a release counts as "placed on the market" on a particular day is a legal question I cannot answer for you. That the text changed is a fact you can check at the link above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two laws, two different questions
&lt;/h2&gt;

&lt;p&gt;Reduce each to the question it actually asks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CRA Article 14 asks: is something I ship being actively exploited right now?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is an operational reporting obligation with a cascading clock. For an actively exploited vulnerability in a product with digital elements: early warning within &lt;strong&gt;24 hours&lt;/strong&gt;, vulnerability notification within &lt;strong&gt;72 hours&lt;/strong&gt;, final report within &lt;strong&gt;14 days&lt;/strong&gt;. Reports go to the coordinating national CSIRT and to ENISA; the Commission's &lt;a href="https://digital-strategy.ec.europa.eu/en/policies/cra-reporting" rel="noopener noreferrer"&gt;CRA reporting page&lt;/a&gt; has the mechanics. Article 14 covers more than the vulnerability limb — read it in full. I deal only with the actively-exploited path because that is the one with a 24-hour clock.&lt;/p&gt;

&lt;p&gt;The words doing the work are &lt;strong&gt;actively exploited&lt;/strong&gt;. Not critical. Not high. Not "CVSS above 9". Severity is a property of the vulnerability; exploitation is a fact about the world. A CVSS 9.8 in a library you ship, with no evidence anyone is exploiting it, starts no Article 14 clock. A medium-severity auth bypass someone is using against your customers today does.&lt;/p&gt;

&lt;p&gt;The uncomfortable corollary: your severity triage — routing 9.8s to the on-call and 5.4s to the backlog — is not a CRA process. It sorts on the wrong axis. You need a second, smaller question alongside it: &lt;em&gt;is this being used?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The PLD asks: can I still supply security updates for what I shipped?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not a reporting obligation — civil liability exposure, running on years rather than hours. Recital 6 confirms no-fault product liability covers all movables "including software". Recital 32 says a product can be defective "on account of its cybersecurity vulnerability". Recital 19 is the one to internalise: a product remains within the manufacturer's control where the manufacturer retains the ability to supply software updates.&lt;/p&gt;

&lt;p&gt;So: the CRA is present tense, measured in hours. The PLD is past tense — what did you put on the market, what state is it in now — measured in the lifetime of what you shipped. One is an incident process. The other follows from your maintenance posture, decided years before it matters, by whether you can still cut a patch release for the version a customer runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can actually check, with real commands
&lt;/h2&gt;

&lt;p&gt;Everything here is a public endpoint: no key, no account.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. CISA KEV: a free, dated exploitation signal
&lt;/h3&gt;

&lt;p&gt;The Known Exploited Vulnerabilities catalogue is a single JSON file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;KEV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="s1"&gt;'{catalogVersion, count, first: .vulnerabilities[0].cveID}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shape, with values elided:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"catalogVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026.09.14"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1710&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vulnerabilities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"cveID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CVE-..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"vendorProject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"product"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dateAdded"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2021-11-03"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dueDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"knownRansomwareCampaignUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Known"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At catalogue version &lt;code&gt;2026.09.14&lt;/code&gt; it held &lt;strong&gt;1,710 entries&lt;/strong&gt;, running from &lt;code&gt;2021-11-03&lt;/code&gt; to &lt;code&gt;2026-09-14&lt;/code&gt;, with &lt;strong&gt;89 added in the preceding 90 days&lt;/strong&gt; and &lt;strong&gt;360 flagged &lt;code&gt;knownRansomwareCampaignUse: "Known"&lt;/code&gt;&lt;/strong&gt;. Reproduce both counts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="s1"&gt;'[.vulnerabilities[] | select(.knownRansomwareCampaignUse == "Known")] | length'&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="s1"&gt;'[.vulnerabilities[] | select(.dateAdded &amp;gt;= "2026-06-16")] | length'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly one new entry a day — small enough for a human to read every addition, a realistic weekly input rather than another firehose.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. OSV.dev: what advisories touch a package version
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.osv.dev/v1/query &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"package":{"ecosystem":"npm","name":"lodash"},"version":"4.17.20"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.vulns[0] | {id, aliases, summary}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/v1/query&lt;/code&gt; returns full records: &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;aliases&lt;/code&gt;, &lt;code&gt;summary&lt;/code&gt;, &lt;code&gt;affected&lt;/code&gt;, &lt;code&gt;references&lt;/code&gt;. Ecosystem strings are case-sensitive — &lt;code&gt;npm&lt;/code&gt;, &lt;code&gt;PyPI&lt;/code&gt;, &lt;code&gt;Go&lt;/code&gt;, &lt;code&gt;Maven&lt;/code&gt;, &lt;code&gt;crates.io&lt;/code&gt;. Lower-case &lt;code&gt;pypi&lt;/code&gt; matches nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The gotcha that returns a clean result forever
&lt;/h3&gt;

&lt;p&gt;For a lockfile you want &lt;code&gt;/v1/querybatch&lt;/code&gt;, which takes many queries per request. Here is the trap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# BROKEN. Runs fine. Finds nothing. Ever.&lt;/span&gt;
&lt;span class="nv"&gt;osv&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.osv.dev/v1/querybatch &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"queries":[{"package":{"ecosystem":"npm","name":"lodash"},"version":"4.17.20"}]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.results[].vulns[]?.id'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;kev&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.vulnerabilities[].cveID'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-12&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$osv&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$kev&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# empty, always&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;querybatch&lt;/code&gt; does not return the same objects as &lt;code&gt;query&lt;/code&gt;. It returns only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"vulns"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"GHSA-xxxx-xxxx-xxxx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-...Z"&lt;/span&gt;&lt;span class="p"&gt;}]}]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fields. &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;modified&lt;/code&gt;. &lt;strong&gt;No &lt;code&gt;aliases&lt;/code&gt;.&lt;/strong&gt; And the ids are GitHub Security Advisory ids — &lt;code&gt;GHSA-...&lt;/code&gt;. KEV is keyed on &lt;code&gt;CVE-...&lt;/code&gt;. The namespaces never collide, so that intersection is empty by construction, for every input, forever.&lt;/p&gt;

&lt;p&gt;This is worse than a crash. A crash tells you something is wrong. This tells you that you are clean — a green check, a quiet Slack channel and a report you can show someone, indefinitely, while you ship an exploited CVE. It is the highest-confidence wrong answer here, and it is three lines of plausible shell.&lt;/p&gt;

&lt;p&gt;The fix is a second call per advisory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://api.osv.dev/v1/vulns/GHSA-xxxx-xxxx-xxxx &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.aliases[]? | select(startswith("CVE-"))'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/v1/vulns/{id}&lt;/code&gt; returns the full record including &lt;code&gt;aliases&lt;/code&gt;, which is where the CVE lives. Resolve, &lt;em&gt;then&lt;/em&gt; intersect.&lt;/p&gt;

&lt;p&gt;Two notes. &lt;code&gt;modified&lt;/code&gt; is a cache key: cache &lt;code&gt;/v1/vulns/{id}&lt;/code&gt; on &lt;code&gt;(id, modified)&lt;/code&gt; and a repeat run over an unchanged lockfile costs one request. And add a canary — a run that resolves zero CVE aliases across all advisories is not a clean result, it is a broken pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. endoflife.date: what can no longer receive security updates
&lt;/h3&gt;

&lt;p&gt;This is the PLD-shaped question, because Recital 19 ties control to your ability to supply updates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://endoflife.date/api/v1/products | jq &lt;span class="s1"&gt;'.result | length'&lt;/span&gt;   &lt;span class="c"&gt;# 473, no key needed&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://endoflife.date/api/v1/products/nodejs &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.result.releases[] | select(.isEol == true) | "\(.name)  eol \(.eolFrom // "unknown")"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Releases carry &lt;code&gt;isEol&lt;/code&gt; booleans and &lt;code&gt;eolFrom&lt;/code&gt; dates, so that second command prints the branches that can no longer receive upstream security fixes. Run it against your runtimes, base images, database engines and frameworks — the dates move, so run it rather than trust a list.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. A sketch that ties it together
&lt;/h3&gt;

&lt;p&gt;Standard library only. A sketch, not a product: no retries, no backoff, no concurrency, no paginated OSV results, no SBOM parsing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Which of my dependencies map to a CVE that CISA lists as exploited.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;OSV_BATCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.osv.dev/v1/querybatch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;OSV_VULN&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.osv.dev/v1/vulns/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;KEV&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;hdr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;hdr&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;                       &lt;span class="c1"&gt;# deps: [(ecosystem, name, version), ...]
&lt;/span&gt;    &lt;span class="n"&gt;queries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;package&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ecosystem&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
               &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OSV_BATCH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="c1"&gt;# Step 1: querybatch gives {id, modified} only. No aliases. No CVEs.
&lt;/span&gt;    &lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;                        &lt;span class="c1"&gt;# osv id -&amp;gt; dep
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;dep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;vuln&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vulns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
            &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;vuln&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dep&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no advisories returned - check your ecosystem strings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="c1"&gt;# Step 2: resolve each id to its CVE aliases. The step people skip.
&lt;/span&gt;    &lt;span class="n"&gt;cve_of&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;                       &lt;span class="c1"&gt;# cve -&amp;gt; (osv id, dep)
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;osv_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dep&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OSV_VULN&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;osv_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aliases&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CVE-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;cve_of&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;osv_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dep&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Canary: no aliases resolved means the join below is meaningless.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cve_of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAIL: 0 CVE aliases from &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; advisories&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

    &lt;span class="c1"&gt;# Step 3: now, and only now, intersect with KEV.
&lt;/span&gt;    &lt;span class="n"&gt;kev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cveID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;KEV&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vulnerabilities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cve_of&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kev&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cve&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;osv_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eco&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cve_of&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cve&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cve&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;eco&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  osv=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;osv_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
              &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;added=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cve&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dateAdded&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
              &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ransomware=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kev&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cve&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;knownRansomwareCampaignUse&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; advisories -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cve_of&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; CVEs -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; on KEV&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;([(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lodash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4.17.20&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PyPI&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;django&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.2.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A non-empty result is not a legal conclusion. It is a prompt to find out whether the exploited path is reachable in your product, which only your team can answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the law does not say
&lt;/h2&gt;

&lt;p&gt;This is the part that gets over-read, usually toward panic. Four corrections, each tied to text you can check. Still not legal advice — these are readings I could not support from the text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The PLD does not oblige you to ship updates.&lt;/strong&gt; Recital 51 says the directive imposes no obligation to provide updates. There is no statutory patch cadence in it. Recital 19 treats your &lt;em&gt;ability&lt;/em&gt; to supply updates as evidence the product remains within your control — which affects how defectiveness is framed, not whether you owe anyone a release. "The EU now requires security patches for ten years" is not what the PLD says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pure economic loss is excluded.&lt;/strong&gt; Recital 24 puts it outside the directive's damage categories. Recital 20 does make destruction or corruption of data recoverable. Line those up and a useful distinction appears: a failure mode that destroys customer data sits much closer to recoverable damage than one that takes you offline for six hours. If your worst realistic outcome is downtime, your PLD exposure may be narrower than the headlines suggest. Two caveats: whether a given SaaS is a "product" here is not something I can settle, and recoverable damage will be worked out in national courts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The open-source carve-out is real but narrow.&lt;/strong&gt; Article 2(2) excludes free and open-source software developed or supplied outside a commercial activity. Note what it turns on: commercial activity, not licence choice. An MIT licence file does not by itself put you in the carve-out. And it protects the FOSS developer, not the integrator — ship someone else's MIT-licensed library inside your commercial product and what you placed on the market is your product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KEV is a US catalogue and the CRA does not point to it.&lt;/strong&gt; Nothing in the CRA references CISA. KEV is public domain, dated, machine-readable and free, which makes it a good &lt;em&gt;engineering&lt;/em&gt; signal, and that is the only reason it appears here. It is not a legal trigger and not exhaustive. Your own telemetry, a customer report, an upstream advisory or a researcher's email can make you aware of exploitation weeks before KEV lists it, or when KEV never does. A floor, not a definition.&lt;/p&gt;

&lt;p&gt;And again: the PLD is a directive. Twenty-seven transpositions will differ on limitation periods, disclosure and procedure. The text that binds you is your national one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do this week
&lt;/h2&gt;

&lt;p&gt;Five things, no budget or vendor required.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Know what you ship.&lt;/strong&gt; Generate an SBOM per release artifact and &lt;em&gt;store it with the artifact&lt;/em&gt;. The SBOM you regenerate from &lt;code&gt;main&lt;/code&gt; describes software nobody is running. When someone asks in fourteen months what was in version 4.2.1, the answer has to be a file, not archaeology.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know what is on KEV.&lt;/strong&gt; Run the intersection above against the dependency sets you shipped. Alert on non-empty, fail on zero-alias runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know what is past EOL.&lt;/strong&gt; One pass with endoflife.date over runtimes, base images and frameworks. Anything already EOL is something you can no longer patch — the capability Recital 19 talks about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Have a written disclosure path.&lt;/strong&gt; A &lt;code&gt;SECURITY.md&lt;/code&gt; and a &lt;code&gt;/.well-known/security.txt&lt;/code&gt; a stranger can find in thirty seconds, pointing at an address a human reads, plus the internal route from there to whoever can decide to report. An external reporter is a common way to learn you are being exploited, and a 24-hour clock is not the moment to find that your security alias forwards to someone who left.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep a dated record of reporting decisions — including the decisions not to report.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Item 5 is the one that matters. The Article 14 clock runs from awareness. Awareness is a state of mind in the past, and the only thing that evidences it later is something you wrote at the time. If you assess a vulnerability, conclude there is no evidence of active exploitation, and do not report — very often the correct call — that decision is invisible six months later unless you wrote it down. A dated note saying what you knew, when, from which source, what exploitation evidence existed, what you decided and who decided is defensible. A gap in the record is not.&lt;/p&gt;

&lt;p&gt;Format does not matter. An append-only markdown file, or one ticket per decision: the issue, when you learned of it, where from, whether there was exploitation evidence, the decision, the decider, a review date. Five minutes each. Do it for the negatives especially — you will have far more of those, and nobody records them.&lt;/p&gt;

&lt;p&gt;Optional but worth it: rehearse the 24-hour path once with a made-up vulnerability. Find your coordinating national CSIRT — which one depends on where you are established — and confirm the route before you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;None of this is legal advice. It is what I could verify, check with a command, or read directly in the text, and I have tried to be explicit about where that stops and a lawyer starts. The CRA applies directly; the PLD reaches you through your member state's law, which is what actually binds you. Read Article 14 and Article 2 yourself — both are shorter than this post.&lt;/p&gt;

&lt;p&gt;I wrote two small open-source tools while working through the above — &lt;a href="https://github.com/ntoledo319/cra-watch" rel="noopener noreferrer"&gt;cra-watch&lt;/a&gt; and &lt;a href="https://github.com/ntoledo319/pld-watch" rel="noopener noreferrer"&gt;pld-watch&lt;/a&gt; — which do roughly what the sketch above does, with the caching and the canary in place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Primary sources&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CRA, Regulation (EU) 2024/2847: &lt;a href="https://eur-lex.europa.eu/eli/reg/2024/2847/oj" rel="noopener noreferrer"&gt;https://eur-lex.europa.eu/eli/reg/2024/2847/oj&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Commission CRA reporting: &lt;a href="https://digital-strategy.ec.europa.eu/en/policies/cra-reporting" rel="noopener noreferrer"&gt;https://digital-strategy.ec.europa.eu/en/policies/cra-reporting&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PLD, Directive (EU) 2024/2853: &lt;a href="https://eur-lex.europa.eu/eli/dir/2024/2853/oj" rel="noopener noreferrer"&gt;https://eur-lex.europa.eu/eli/dir/2024/2853/oj&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PLD corrigendum 2026/90364: &lt;a href="http://data.europa.eu/eli/dir/2024/2853/corrigendum/2026-05-07/oj" rel="noopener noreferrer"&gt;http://data.europa.eu/eli/dir/2024/2853/corrigendum/2026-05-07/oj&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CISA KEV: &lt;a href="https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" rel="noopener noreferrer"&gt;https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OSV: &lt;a href="https://osv.dev" rel="noopener noreferrer"&gt;https://osv.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;endoflife.date: &lt;a href="https://endoflife.date" rel="noopener noreferrer"&gt;https://endoflife.date&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If something here is wrong, say so in the comments and I will correct it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>opensource</category>
      <category>python</category>
    </item>
    <item>
      <title>A data migration isn't done until every row is accounted for</title>
      <dc:creator>Nicholas Toledo</dc:creator>
      <pubDate>Tue, 15 Sep 2026 04:39:48 +0000</pubDate>
      <link>https://dev.to/nicholas_toledo_5a6f9e576/a-data-migration-isnt-done-until-every-row-is-accounted-for-282b</link>
      <guid>https://dev.to/nicholas_toledo_5a6f9e576/a-data-migration-isnt-done-until-every-row-is-accounted-for-282b</guid>
      <description>&lt;p&gt;You moved data from one system to another. Now someone has to say it worked, and put their name on it.&lt;/p&gt;

&lt;p&gt;The usual evidence is a row count and a few spot checks. "Source had 48,212 rows, target has 48,212 rows, I opened ten and they looked right." That sentence has signed off more migrations than any test suite, and it is worthless, because a row count cannot see the two failures that actually hurt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rows that silently &lt;strong&gt;vanished&lt;/strong&gt; (a join dropped them, a filter ate them, a key collided), and&lt;/li&gt;
&lt;li&gt;rows that silently &lt;strong&gt;appeared&lt;/strong&gt; (a default row, a duplicate insert, a fixture nobody removed).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If 300 vanish and 300 appear, the counts match. Spot checks find neither, because you spot-check the rows you thought of, and the rows you thought of are the ones that exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every row lands in exactly one bucket
&lt;/h2&gt;

&lt;p&gt;The fix is not more spot checks. It is an accounting identity: &lt;strong&gt;every source row and every target row must land in exactly one bucket, and the bucket totals must equal the raw row counts read from each file.&lt;/strong&gt; If they don't, the tool refuses to write a report at all.&lt;/p&gt;

&lt;p&gt;Source side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;bucket&lt;/th&gt;
&lt;th&gt;meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;matched&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present in the target, every compared field equal after the normalisation you declared&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;changed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present in the target, at least one compared field differs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unverified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present, but a compared field was blank where blank means "not knowable"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;missing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no target row, and no declared rejection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rejected&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;you declared it dropped, with a reason&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rejected_but_present&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;you said you would drop it. It is in the target anyway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;duplicate_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the key occurs more than once in the source, so no occurrence is authoritative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ambiguous_target&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the target holds several rows for this key, so there is nothing single to compare&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;blank_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the key columns are empty&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Target side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;bucket&lt;/th&gt;
&lt;th&gt;meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;accounted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;attributed to exactly one source row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orphan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present in the target, absent from the source — something invented this data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;duplicate_target&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the key occurs more than once in the target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ambiguous_source&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the source holds several rows for this key, so this row cannot be attributed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;blank_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the key columns are empty&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;orphan&lt;/code&gt;, &lt;code&gt;rejected_but_present&lt;/code&gt; and the two &lt;code&gt;ambiguous&lt;/code&gt; buckets are the whole reason to do this. They are the failures a row count cannot see, and most reconciliation scripts never look for them, because the person writing the script was thinking about the rows that should match, not the rows that should not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refusal is a feature
&lt;/h2&gt;

&lt;p&gt;The rule that makes this work is the one that feels wrong at first: &lt;strong&gt;if the buckets don't reconcile, write nothing and exit non-zero.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A partial report with a plausible-looking total is worse than no report, because the plausible total is the one that ends up in the sign-off email. So the invariant is checked last, after all the bucketing, against the raw counts read from the files with no parsing cleverness in between:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rows_read(source) == sum(source buckets)
rows_read(target) == sum(target buckets)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fail either, exit 2, no output. Put &lt;code&gt;--strict&lt;/code&gt; in CI and a migration that stops reconciling fails the build instead of surfacing as a support ticket in March.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three decisions that are usually made by accident
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Blank is not zero, and blank is not a match.&lt;/strong&gt; A missing balance and a balance of &lt;code&gt;0.00&lt;/code&gt; are different facts. If the old system never captured a field, declare it — &lt;code&gt;blank_policy: unknown&lt;/code&gt; — and those rows land in &lt;code&gt;unverified&lt;/code&gt; rather than quietly counting as &lt;code&gt;matched&lt;/code&gt;. You get a smaller verified number and a true one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Date order is declared, never guessed.&lt;/strong&gt; &lt;code&gt;03/04/2026&lt;/code&gt; is March 4th or April 3rd depending on who exported it, and a tool that guesses will guess consistently wrong on exactly the twelve days a month where it matters. Say &lt;code&gt;dmy&lt;/code&gt; or &lt;code&gt;mdy&lt;/code&gt; in the spec. If a value can't be parsed under the declared order, that's a finding, not a coercion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Values are text until you say otherwise.&lt;/strong&gt; Read everything as strings. Leading zeros survive (&lt;code&gt;02116&lt;/code&gt; is a Boston ZIP, &lt;code&gt;2116&lt;/code&gt; is not), a 16-digit account number never turns into &lt;code&gt;1.2345E+15&lt;/code&gt;, and &lt;code&gt;1.10&lt;/code&gt; does not silently equal &lt;code&gt;1.1&lt;/code&gt; unless you declared a numeric comparison with a tolerance. Most "the migration corrupted our data" stories are actually "the reconciliation script parsed the data on the way in."&lt;/p&gt;

&lt;h2&gt;
  
  
  The output is byte-stable on purpose
&lt;/h2&gt;

&lt;p&gt;The report's content hash covers everything except the timestamp. Re-run it next quarter on the same inputs and you get the same hash, so the sign-off can cite a number that anyone can re-derive offline, and a "did the data change since we signed?" question has a one-command answer. The HTML report has no scripts, no external fonts and makes no network requests — enforced by a test — so it can be attached to a ticket and still open in five years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to get it
&lt;/h2&gt;

&lt;p&gt;I packaged this as the &lt;a href="https://toledotechnologies.com/kit/" rel="noopener noreferrer"&gt;Migration Acceptance Kit&lt;/a&gt;: one Python file (&lt;code&gt;reconcile.py&lt;/code&gt;, standard library only, no network access, no telemetry), its 74-test suite, a written acceptance procedure, a spec reference, worked examples, and a sign-off template. It is US$240 for one organisation, delivered automatically on payment. If you would rather build your own, the bucket tables above are the spec — the hard part is deciding to refuse, not the code.&lt;/p&gt;

&lt;p&gt;One command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 reconcile.py &lt;span class="nt"&gt;--spec&lt;/span&gt; specs/customers.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source&lt;/span&gt; export-source.csv &lt;span class="nt"&gt;--target&lt;/span&gt; export-target.csv &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rejects&lt;/span&gt; agreed-exclusions.csv &lt;span class="nt"&gt;--out&lt;/span&gt; reports/2026-09-15 &lt;span class="nt"&gt;--strict&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit &lt;code&gt;0&lt;/code&gt;: reported. Exit &lt;code&gt;1&lt;/code&gt;: reported, but not clean under &lt;code&gt;--strict&lt;/code&gt;. Exit &lt;code&gt;2&lt;/code&gt;: refused — and that refusal is the point.&lt;/p&gt;

</description>
      <category>python</category>
      <category>database</category>
      <category>testing</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Designing a compliance check that does not cry wolf in CI</title>
      <dc:creator>Nicholas Toledo</dc:creator>
      <pubDate>Mon, 14 Sep 2026 20:26:48 +0000</pubDate>
      <link>https://dev.to/nicholas_toledo_5a6f9e576/designing-a-compliance-check-that-does-not-cry-wolf-in-ci-1nca</link>
      <guid>https://dev.to/nicholas_toledo_5a6f9e576/designing-a-compliance-check-that-does-not-cry-wolf-in-ci-1nca</guid>
      <description>&lt;p&gt;Every team has a disabled check. It sits in &lt;code&gt;.github/workflows/&lt;/code&gt;, commented out, with a note saying &lt;code&gt;# TODO re-enable after we fix the noise&lt;/code&gt;. Nobody ever fixes the noise.&lt;/p&gt;

&lt;p&gt;The check did not fail because it was wrong. It failed because it was annoying, and annoying is fatal in a way that wrong is not. A wrong check gets fixed. An annoying check gets deleted.&lt;/p&gt;

&lt;p&gt;I spent a while building two small compliance scanners and got this wrong in several instructive ways. Below is what I would tell myself at the start — plus one bug that was much worse than noise, because it made the tool look like it was working while it found nearly nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the bug, because it is the important part
&lt;/h2&gt;

&lt;p&gt;Both tools need to turn a declared version into something comparable against a catalogue. npm gives you &lt;code&gt;^12.0.0&lt;/code&gt;. Docker gives you &lt;code&gt;3.7-slim&lt;/code&gt;. nvm writes &lt;code&gt;v16.20.2&lt;/code&gt;. Go modules say &lt;code&gt;v1.19&lt;/code&gt;. You want the major, or major.minor.&lt;/p&gt;

&lt;p&gt;The first version of that helper used this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^(\d+)(?:\.(\d+))?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;re.match&lt;/code&gt; anchors at the start of the string, and the pattern demands a digit there. Read that again against &lt;code&gt;^12.0.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The string starts with a caret. The match fails. The function returns the empty string. The component is dropped — silently, with no warning, no error and no log line.&lt;/p&gt;

&lt;p&gt;Same inputs through the broken pattern and the fixed one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;input          buggy      fixed
'v16.20.2'     ''         '16.20'     &amp;lt;- what nvm writes into .nvmrc
'^12.0.0'      ''         '12.0'      &amp;lt;- npm's default range syntax
'~3.2'         ''         '3.2'
'&amp;gt;=16 &amp;lt;18'     ''         '16'
'v1.19'        ''         '1.19'      &amp;lt;- go.mod style
'3.7-slim'     '3.7'      '3.7'
'lts/gallium'  ''         ''          &amp;lt;- correctly unparseable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row that failed is a string that does not begin with a digit. That is the entire bug, and it happens to cover most of the ways version ranges are actually written in the wild — while leaving bare versions like &lt;code&gt;3.7-slim&lt;/code&gt; working perfectly.&lt;/p&gt;

&lt;p&gt;The fix is one word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(\d+)(?:\.(\d+))?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;search&lt;/code&gt; instead of &lt;code&gt;match&lt;/code&gt;: find the first number anywhere in the string rather than demanding it at position zero. Note &lt;code&gt;lts/gallium&lt;/code&gt; still yields nothing, correctly — there is no version in it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this is worse than a crash
&lt;/h3&gt;

&lt;p&gt;A crash is self-reporting. A stack trace in CI gets fixed that afternoon.&lt;/p&gt;

&lt;p&gt;This produced a &lt;em&gt;plausible&lt;/em&gt; report. Here is the same project scanned with the buggy helper and the fixed one — a repo with a &lt;code&gt;.nvmrc&lt;/code&gt; containing &lt;code&gt;v16.20.2&lt;/code&gt; and a &lt;code&gt;Dockerfile&lt;/code&gt; with &lt;code&gt;FROM python:3.7-slim&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;################ FIXED ################
  Found    2 runtime/framework version(s)

  2 COMPONENT(S) PAST UPSTREAM END-OF-LIFE
    Node.js 16.20   (.nvmrc)
      upstream EOL   2023-09-11
    python 3.7   (Dockerfile FROM)
      upstream EOL   2023-06-27
EXIT = 1

################ BUGGY ################
  Found    1 runtime/framework version(s)

  1 COMPONENT(S) PAST UPSTREAM END-OF-LIFE
    python 3.7   (Dockerfile FROM)
      upstream EOL   2023-06-27
EXIT = 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both runs exit 1. Both print a real finding with a correct EOL date. Both look like a working tool doing its job. The buggy one lost an end-of-life Node runtime and said nothing about it.&lt;/p&gt;

&lt;p&gt;That is the failure mode to fear. The only way to catch it is to already know the answer and compare — which is precisely the knowledge the tool exists to replace.&lt;/p&gt;

&lt;p&gt;Three habits that would have caught it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Test the strings the ecosystem actually emits, not the ones you find convenient.&lt;/strong&gt; My fixtures said &lt;code&gt;16.20.2&lt;/code&gt;. Real &lt;code&gt;.nvmrc&lt;/code&gt; files say &lt;code&gt;v16.20.2&lt;/code&gt; and real &lt;code&gt;package.json&lt;/code&gt; files say &lt;code&gt;^16.20.2&lt;/code&gt;. The parametrised test that should have existed on day one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;declared,expected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v16.20.2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;16.20&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^12.0.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;12.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;~3.2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;=16 &amp;lt;18&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;16&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1.19&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.19&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.7-slim&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.7&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lts/gallium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_version_extraction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;_major_minor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dozen lines. Would have failed on row one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Report your denominator.&lt;/strong&gt; If a tool sees 40 dependencies and can only extract versions from 3, that ratio is the most important number on screen. Both tools now print what they found and where:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Scope    /tmp/demojava
           gradle.lockfile (3)
  Unique   3 components
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Found 1&lt;/code&gt; versus &lt;code&gt;Found 2&lt;/code&gt; in the runs above is the only visible difference between a working scan and a broken one. Print that number. Any scanner that silently drops unparseable input should be treated as suspect until it tells you how much it dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Run it against a known-bad project first.&lt;/strong&gt; Not to see whether it runs — to see whether it finds the thing you already know is there. If you cannot state the expected output before running, you cannot tell success from silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit codes: three states, not two
&lt;/h2&gt;

&lt;p&gt;The default instinct is binary — 0 good, non-zero bad. That collapses a distinction that matters enormously in practice. Both tools use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;0&lt;/strong&gt; — checked, nothing found&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1&lt;/strong&gt; — checked, found something that needs a human&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2&lt;/strong&gt; — could not check&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Case 2 is the one people skip, and skipping it is what makes checks get disabled. Consider a monorepo where the security workflow runs against every directory. Some are docs. Some are Terraform. They have no lockfiles and never will. If "no manifests found" returns 1, every one of those jobs goes red, forever, for a reason unrelated to security. Two weeks later somebody adds &lt;code&gt;continue-on-error: true&lt;/code&gt; and the check is decorative.&lt;/p&gt;

&lt;p&gt;Real behaviour on an empty directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cra-watch scan &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="go"&gt;
No dependency manifests found under /tmp/emptyproj

cra-watch looks for: Cargo.lock, Gemfile.lock, Pipfile.lock, composer.lock,
go.sum, gradle.lockfile, npm-shrinkwrap.json, package-lock.json,
pnpm-lock.yaml, poetry.lock, requirements-dev.txt, requirements.txt,
yarn.lock

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="go"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit 2, and it names every file it looked for. If you expected a finding, you can immediately see whether your manifest is on the list. "No data" and "bad news" are different facts and deserve different codes. It also hands the caller a real choice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;KEV screen&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;set +e&lt;/span&gt;
    &lt;span class="s"&gt;cra-watch scan .&lt;/span&gt;
    &lt;span class="s"&gt;code=$?&lt;/span&gt;
    &lt;span class="s"&gt;set -e&lt;/span&gt;
    &lt;span class="s"&gt;case $code in&lt;/span&gt;
      &lt;span class="s"&gt;0) echo "clean" ;;&lt;/span&gt;
      &lt;span class="s"&gt;1) echo "::error::known-exploited component present"; exit 1 ;;&lt;/span&gt;
      &lt;span class="s"&gt;2) echo "::notice::no manifests here, skipping" ;;&lt;/span&gt;
      &lt;span class="s"&gt;*) echo "::warning::scanner failed, code $code" ;;&lt;/span&gt;
    &lt;span class="s"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A team that &lt;em&gt;wants&lt;/em&gt; exit 2 to be fatal — say, a repo that must always have a lockfile — can make it fatal. That is their policy decision, made explicitly, not a default imposed by my tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-commit hooks must not block a commit they have nothing to say about
&lt;/h2&gt;

&lt;p&gt;A pre-commit hook is a tax on every commit, and people notice within about two days. The rule: &lt;strong&gt;a hook must never block a commit in a repo where it has nothing to check.&lt;/strong&gt; Not "should rarely". Never. The first time a hook blocks someone's commit over a repo that has no dependencies at all, they run &lt;code&gt;git commit --no-verify&lt;/code&gt;, and once that is muscle memory the hook is gone.&lt;/p&gt;

&lt;p&gt;pre-commit's &lt;code&gt;files:&lt;/code&gt; regex solves this properly. Rather than run and then exit gracefully, do not run at all unless a relevant file changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cra-watch&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cra-watch (EU CRA Article 14 / CISA KEV screen)&lt;/span&gt;
  &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cra-watch scan&lt;/span&gt;
  &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python&lt;/span&gt;
  &lt;span class="na"&gt;pass_filenames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
    &lt;span class="s"&gt;(?x)^(&lt;/span&gt;
      &lt;span class="s"&gt;.*package-lock\.json|.*npm-shrinkwrap\.json|.*yarn\.lock|.*pnpm-lock\.yaml|&lt;/span&gt;
      &lt;span class="s"&gt;.*requirements(-dev)?\.txt|.*poetry\.lock|.*Pipfile\.lock|&lt;/span&gt;
      &lt;span class="s"&gt;.*go\.sum|.*Cargo\.lock|.*Gemfile\.lock|.*composer\.lock|&lt;/span&gt;
      &lt;span class="s"&gt;.*gradle\.lockfile|.*packages\.lock\.json&lt;/span&gt;
    &lt;span class="s"&gt;)$&lt;/span&gt;
  &lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pre-commit&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;manual&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the hook fires when a lockfile changes and is invisible otherwise. Editing a README does not call a remote API. &lt;code&gt;pass_filenames: false&lt;/code&gt; is deliberate too: the &lt;code&gt;files:&lt;/code&gt; pattern decides &lt;em&gt;whether&lt;/em&gt; to run, and the tool then scans the whole project, because a dependency change can affect the whole graph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache the catalogue
&lt;/h2&gt;

&lt;p&gt;Both tools read free, publicly funded APIs — CISA's KEV feed and endoflife.date. Neither charges and neither requires a key, but that is not the same as "hit it as often as you like". A pre-commit hook without caching would fetch a multi-megabyte JSON file on every commit. Multiply by a team, multiply by a year. That is how a free public service gets rate limits.&lt;/p&gt;

&lt;p&gt;Cache on disk, respecting the platform convention:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CACHE_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;XDG_CACHE_HOME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;home&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.cache&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cra-watch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then pick a TTL from how fast the data actually moves. KEV gains a handful of entries in a typical week, so a 6-hour TTL loses nothing. EOL dates are published months ahead, so 24 hours is generous. Always provide &lt;code&gt;--refresh&lt;/code&gt;, because the moment you cache something, someone will need to not-cache it. And report the version you used, so a stale cache is visible rather than invisible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  KEV feed    1709 entries, catalogue 2026.09.11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line is how you notice you are looking at yesterday's answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fail open on network errors
&lt;/h2&gt;

&lt;p&gt;This one generates arguments, so here is the reasoning rather than the rule.&lt;/p&gt;

&lt;p&gt;Your check calls a remote API. The API has an outage. If the tool fails closed, that outage now blocks every deploy at your company — your ability to ship a hotfix depends on the uptime of a third party you have no relationship with. Worse, the failure is &lt;em&gt;unrelated to your risk&lt;/em&gt;: nothing about your code changed, no new vulnerability appeared, a webserver in another country returned 503.&lt;/p&gt;

&lt;p&gt;So: warn loudly, use the cache if there is one, and exit 0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="nf"&gt;eprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;warning: could not refresh KEV (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;); using cached copy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error: could not fetch the CISA KEV catalogue: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same pattern for partial failures. When one OSV batch of several fails, the tool says so and continues:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;warning: OSV batch 2/3 failed (timeout); those components are unchecked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scan is now incomplete, and the output says it is incomplete. That is strictly better than either silently pretending everything is fine or blocking the release.&lt;/p&gt;

&lt;p&gt;The counter-argument — "fail open means an attacker can bypass the check by breaking the network" — is real, and it is why this is a &lt;em&gt;default&lt;/em&gt;, not a law. If your threat model includes that, make it fatal in your own pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The load-bearing principle
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A check that fires constantly gets disabled. A check that fires rarely gets read.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything above is downstream of that. Exit code 2 exists so irrelevant repos stay green. The &lt;code&gt;files:&lt;/code&gt; regex exists so the hook stays invisible. Caching exists so the check stays fast. Fail-open exists so a third-party outage does not train people to bypass it.&lt;/p&gt;

&lt;p&gt;It is also why both tools deliberately answer narrow questions. &lt;code&gt;cra-watch&lt;/code&gt; does not list your CVEs — only components on the CISA KEV catalogue, which is typically zero and occasionally two. &lt;code&gt;pld-watch&lt;/code&gt; does not rank your tech debt — only components past their upstream end-of-life date. On a healthy project both print "nothing found" and get out of the way.&lt;/p&gt;

&lt;p&gt;A security tool's most important output is the quiet one. If a developer sees your check go red, that redness needs to have meant something every previous time, or they will not look this time either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Three exit states: found nothing (0), found something (1), could not check (2)&lt;/li&gt;
&lt;li&gt;"No data" is never a failure by default; let the caller escalate it&lt;/li&gt;
&lt;li&gt;Pre-commit hooks scope with &lt;code&gt;files:&lt;/code&gt; and stay silent otherwise&lt;/li&gt;
&lt;li&gt;Cache public catalogues to disk, and print the catalogue version so staleness is visible&lt;/li&gt;
&lt;li&gt;Fail open on network errors, warn loudly, use the stale cache&lt;/li&gt;
&lt;li&gt;Print your denominator — how many things you found, how many you could parse&lt;/li&gt;
&lt;li&gt;Test against the strings the ecosystem actually emits, not the tidy ones&lt;/li&gt;
&lt;li&gt;Run against a known-bad project and check you get the answer you already knew&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last two caught a real bug. The rest is what keeps the tool installed long enough to matter.&lt;/p&gt;




&lt;p&gt;Both tools are MIT and single-file: &lt;a href="https://github.com/ntoledo319/cra-watch" rel="noopener noreferrer"&gt;cra-watch&lt;/a&gt;, &lt;a href="https://github.com/ntoledo319/pld-watch" rel="noopener noreferrer"&gt;pld-watch&lt;/a&gt;. Engineering tooling, not legal advice.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Severity is not exploitation, and only one of them starts a 24-hour clock</title>
      <dc:creator>Nicholas Toledo</dc:creator>
      <pubDate>Mon, 14 Sep 2026 20:25:41 +0000</pubDate>
      <link>https://dev.to/nicholas_toledo_5a6f9e576/severity-is-not-exploitation-and-only-one-of-them-starts-a-24-hour-clock-8c4</link>
      <guid>https://dev.to/nicholas_toledo_5a6f9e576/severity-is-not-exploitation-and-only-one-of-them-starts-a-24-hour-clock-8c4</guid>
      <description>&lt;p&gt;Run a dependency scanner on a real project and you get four hundred CVEs. Somewhere in there might be one that matters right now. The scanner cannot tell you which, so it sorts by CVSS and hopes.&lt;/p&gt;

&lt;p&gt;CVSS was never designed to answer "is anyone attacking this". It is a severity model — how bad would it be &lt;em&gt;if&lt;/em&gt; exploited, given attack vector, complexity, privileges required, impact. It is a statement about the shape of a vulnerability, not about the world.&lt;/p&gt;

&lt;p&gt;Exploitation is a statement about the world. Those are different questions, and there is now a regulation that keys on the second one and ignores the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The regulation that cares
&lt;/h2&gt;

&lt;p&gt;The EU Cyber Resilience Act, &lt;a href="https://eur-lex.europa.eu/eli/reg/2024/2847/oj" rel="noopener noreferrer"&gt;Regulation (EU) 2024/2847&lt;/a&gt;. Most of it applies from December 2027, but the reporting obligations in Article 14 have been enforceable since &lt;strong&gt;11 September 2026&lt;/strong&gt; — &lt;a href="https://digital-strategy.ec.europa.eu/en/policies/cyber-resilience-act" rel="noopener noreferrer"&gt;confirmed by the Commission&lt;/a&gt; — and they cover products already on the market.&lt;/p&gt;

&lt;p&gt;Article 14(1) and 14(2)(a):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A manufacturer shall notify any actively exploited vulnerability contained in the product with digital elements that it becomes aware of [...] an early warning notification of an actively exploited vulnerability, without undue delay and in any event &lt;strong&gt;within 24 hours&lt;/strong&gt; of the manufacturer becoming aware of it&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then 72 hours for a fuller notification, and a final report no later than 14 days after a corrective or mitigating measure is available.&lt;/p&gt;

&lt;p&gt;The trigger is not "critical". Not "CVSS 9.8". Not "there's a PoC on GitHub". Article 3(42) defines it precisely:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;'actively exploited vulnerability' means a vulnerability for which there is &lt;strong&gt;reliable evidence that a malicious actor has exploited it in a system without permission of the system owner&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two properties of that definition are worth pausing on. It requires evidence, not plausibility. And the exploitation has to have actually happened. A vulnerability can be trivially exploitable, remotely, unauthenticated, with a public exploit — and if nobody has used it, it is not an actively exploited vulnerability under Article 3(42).&lt;/p&gt;

&lt;p&gt;So: severity is a property of the vulnerability. Exploitation is a fact about the world. Only the second one starts the clock.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(I am an engineer reading a primary source, not a lawyer. Whether Article 14 applies to you, and when, is a legal question. Nothing here is legal advice.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The closest public proxy: CISA KEV
&lt;/h2&gt;

&lt;p&gt;Which raises the obvious engineering problem: how would you know?&lt;/p&gt;

&lt;p&gt;The best public answer is the &lt;a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog" rel="noopener noreferrer"&gt;CISA Known Exploited Vulnerabilities catalogue&lt;/a&gt;. CISA maintains it under Binding Operational Directive 22-01, and the inclusion criteria are three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The vulnerability has an assigned CVE ID.&lt;/li&gt;
&lt;li&gt;There is reliable evidence it has been actively exploited in the wild.&lt;/li&gt;
&lt;li&gt;There is a clear remediation action, such as a vendor-provided update.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Criterion 2 is the interesting one, and CISA's own wording is close to the CRA's: "reliable evidence that execution of malicious code was performed by an actor on a system without permission of the system owner". CISA's version also includes &lt;em&gt;attempted&lt;/em&gt; exploitation.&lt;/p&gt;

&lt;p&gt;It is a JSON file at a stable public URL. No key, no account, no rate limit worth worrying about.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'{title, catalogVersion, dateReleased, count}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CISA Catalog of Known Exploited Vulnerabilities"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"catalogVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026.09.14"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dateReleased"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-14T19:00:02.426Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1710&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1710 entries. For scale, the CVE programme published 48,185 records in 2025 alone. KEV is roughly four digits in total, across its entire history. That gap is the whole point: it is not a smaller feed of the same thing, it is a different question being asked.&lt;/p&gt;

&lt;p&gt;One entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.vulnerabilities[] | select(.cveID=="CVE-2021-44228")'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cveID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CVE-2021-44228"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vendorProject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Apache"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"product"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Log4j2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vulnerabilityName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Apache Log4j2 Remote Code Execution Vulnerability"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dateAdded"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2021-12-10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"shortDescription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Apache Log4j2 contains a vulnerability where JNDI features do not protect against attacker-controlled JNDI-related endpoints, allowing for remote code execution."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requiredAction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"For all affected software assets for which updates exist, the only acceptable remediation actions are: 1) Apply updates; OR 2) remove affected assets from agency networks. ..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dueDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2021-12-24"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"knownRansomwareCampaignUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Known"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"forensicTriage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://nvd.nist.gov/vuln/detail/CVE-2021-44228"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cwes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CWE-20"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CWE-400"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CWE-502"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful fields: &lt;code&gt;dateAdded&lt;/code&gt; (when CISA saw evidence), &lt;code&gt;knownRansomwareCampaignUse&lt;/code&gt; (&lt;code&gt;Known&lt;/code&gt; or &lt;code&gt;Unknown&lt;/code&gt;), &lt;code&gt;requiredAction&lt;/code&gt;, &lt;code&gt;cwes&lt;/code&gt;. 360 of the current 1710 entries are flagged as known ransomware campaign use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'[.vulnerabilities[] | select(.knownRansomwareCampaignUse=="Known")] | length'&lt;/span&gt;
&lt;span class="c"&gt;# 360&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flatten it to a set for intersection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.vulnerabilities[].cveID'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; kev-ids.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you have a sorted file of every CVE with public evidence of exploitation. &lt;code&gt;comm -12&lt;/code&gt; it against your own CVE list and you are done. That is the entire idea.&lt;/p&gt;

&lt;h3&gt;
  
  
  Be honest about what KEV is not
&lt;/h3&gt;

&lt;p&gt;This part matters more than the tooling.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is a US federal catalogue.&lt;/strong&gt; It exists to drive remediation in US civilian executive branch agencies under BOD 22-01. It is not an EU instrument and has no legal standing under the CRA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using it as a proxy for "actively exploited" is a judgement call, not a legal definition.&lt;/strong&gt; The CRA's definition and CISA's criteria are close in substance. Close is not the same as identical, and no regulator has said KEV is the test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Absence is not evidence of absence.&lt;/strong&gt; Plenty of exploitation never makes it into KEV. A clean result means "nothing you ship appears on this particular list today", not "nobody is attacking you".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is vendor/product-centric, not package-centric.&lt;/strong&gt; Entries name "Apache Log4j2", not &lt;code&gt;org.apache.logging.log4j:log4j-core&lt;/code&gt;. Mapping one to the other is the actual engineering work, and it is where a naive implementation quietly fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Article 14 asks about exploitation &lt;em&gt;in your product&lt;/em&gt;.&lt;/strong&gt; KEV tells you a vulnerability is exploited somewhere in the world. If the vulnerable code path is not reachable in your build, you may be outside the trigger. That is a human decision and nobody can automate it for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;KEV is the best available public signal. It is a signal, not an oracle.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example: log4j-core 2.14.1
&lt;/h2&gt;

&lt;p&gt;Take the canonical case. A &lt;code&gt;gradle.lockfile&lt;/code&gt; containing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;org.apache.logging.log4j&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;log4j-core:2.14.1=runtimeClasspath&lt;/span&gt;
&lt;span class="py"&gt;org.apache.logging.log4j&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;log4j-api:2.14.1=runtimeClasspath&lt;/span&gt;
&lt;span class="py"&gt;com.google.guava&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;guava:31.1-jre=runtimeClasspath&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask OSV.dev what is known about that exact coordinate and version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.osv.dev/v1/query &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"package":{"name":"org.apache.logging.log4j:log4j-core","ecosystem":"Maven"},
       "version":"2.14.1"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.vulns[].id'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven advisories come back. Resolved to CVE aliases and checked against KEV:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CVE                GHSA sev  KEV?        CVSS vector
CVE-2021-44228     CRITICAL  KEV         CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H/E:H
CVE-2021-44832     MODERATE  not on KEV  CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H
CVE-2021-45046     CRITICAL  KEV         CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H/E:H
CVE-2021-45105     HIGH      not on KEV  CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:N/A:H
CVE-2025-68161     MODERATE  not on KEV  CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:L/...
CVE-2026-34477     MODERATE  not on KEV  CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:L/...
CVE-2026-34480     MODERATE  not on KEV  CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven CVEs on one dependency. Two on KEV.&lt;/p&gt;

&lt;p&gt;Look at CVE-2021-45105 — rated HIGH, &lt;code&gt;AV:N/AC:L/PR:N/UI:N&lt;/code&gt;, network, low complexity, no privileges, no interaction. It sorts near the top of any severity-ranked list. It is a denial of service via recursive lookups, and it is not on KEV.&lt;/p&gt;

&lt;p&gt;Meanwhile CVE-2021-45046 has &lt;code&gt;AC:H&lt;/code&gt; — high attack complexity, which &lt;em&gt;reduces&lt;/em&gt; its CVSS base score. It is on KEV. Attack complexity is a statement about difficulty. Difficulty did not stop anyone.&lt;/p&gt;

&lt;p&gt;That inversion is the argument in one table. Severity ranking would have you look at 45105 before 45046. Exploitation ranking puts 45105 in the backlog, where it belongs, and 45046 in front of a human today.&lt;/p&gt;

&lt;p&gt;Note the two KEV vectors end in &lt;code&gt;/E:H&lt;/code&gt; — the temporal Exploit Code Maturity metric set to High. CVSS &lt;em&gt;can&lt;/em&gt; express this, via temporal metrics. Almost nobody publishes them, and scanners almost universally sort on base score. The capability exists and is unused, which is why you go to a separate catalogue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating the intersection
&lt;/h2&gt;

&lt;p&gt;The pipeline is: manifests → resolved components → OSV.dev → CVE aliases → intersect KEV. Four steps, all public APIs, no account.&lt;/p&gt;

&lt;p&gt;I wrote it up as &lt;a href="https://github.com/ntoledo319/cra-watch" rel="noopener noreferrer"&gt;&lt;code&gt;cra-watch&lt;/code&gt;&lt;/a&gt; so I would stop rebuilding it per project. Single Python file, standard library only, MIT.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/ntoledo319/cra-watch/main/cra_watch.py &lt;span class="nt"&gt;-o&lt;/span&gt; cra-watch
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x cra-watch
./cra-watch scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the lockfile above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cra-watch v1.0.0
EU Cyber Resilience Act, Article 14 - actively-exploited screen

  Scope    /tmp/demojava
           gradle.lockfile (3)
  Unique   3 components

  Advisories  3 component(s) carry at least one known advisory
  KEV feed    1709 entries, catalogue 2026.09.11

  --------------------------------------------------------------------

   2 KEV MATCH(ES) - MANUAL DECISION REQUIRED NOW

  CVE-2021-45046  (GHSA-7rjr-3q55-vv33)
    component      org.apache.logging.log4j:log4j-core 2.14.1  [Maven]
    known as       Apache Log4j2 Deserialization of Untrusted Data Vulnerability
    KEV listed     2023-05-01   ransomware use: Known

  CVE-2021-44228  (GHSA-jfh8-c2jp-5v3q)
    component      org.apache.logging.log4j:log4j-core 2.14.1  [Maven]
    known as       Apache Log4j2 Remote Code Execution Vulnerability
    KEV listed     2021-12-10   ransomware use: Known
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads 14 lockfile formats plus CycloneDX and SPDX SBOMs, and exits &lt;code&gt;0&lt;/code&gt; for no KEV matches, &lt;code&gt;1&lt;/code&gt; for at least one, &lt;code&gt;2&lt;/code&gt; when it found no manifests at all. The "manual decision required" wording is deliberate — the tool's output is the start of a human judgement, not a verdict.&lt;/p&gt;

&lt;p&gt;(That run says 1709 entries, catalogue &lt;code&gt;2026.09.11&lt;/code&gt;, while the &lt;code&gt;curl&lt;/code&gt; earlier in this post returned 1710 and &lt;code&gt;2026.09.14&lt;/code&gt;. The tool caches the catalogue on disk for six hours so repeat runs stay fast and do not hammer a free public API. Printing the catalogue version is how you notice you are reading a cached answer; &lt;code&gt;--refresh&lt;/code&gt; forces a re-download.)&lt;/p&gt;

&lt;p&gt;Run it on a schedule, not just on push. Your dependency graph did not change last night; the KEV catalogue did. A repo that was clean yesterday can be a reporting question this morning without a single commit. That asymmetry is the strongest argument for a cron job over a pre-commit hook.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take away, even if you never touch either tool
&lt;/h2&gt;

&lt;p&gt;Your scanner's ranking encodes an assumption: that severity predicts urgency. Sometimes it does. For the specific question "is someone attacking this right now", it does not, and there is a free public dataset that answers the real question directly.&lt;/p&gt;

&lt;p&gt;Pull &lt;code&gt;known_exploited_vulnerabilities.json&lt;/code&gt;, &lt;code&gt;jq -r '.vulnerabilities[].cveID'&lt;/code&gt;, and intersect it with whatever CVE list you already produce. Twenty minutes. The output is short enough that a human will actually read it — which is the only property that has ever made a security list useful.&lt;/p&gt;




&lt;p&gt;Tool: &lt;a href="https://github.com/ntoledo319/cra-watch" rel="noopener noreferrer"&gt;cra-watch&lt;/a&gt;. Engineering tooling, not legal advice, and not a compliance determination.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your EOL dependencies just became a legal question, not a tech-debt question</title>
      <dc:creator>Nicholas Toledo</dc:creator>
      <pubDate>Mon, 14 Sep 2026 20:25:35 +0000</pubDate>
      <link>https://dev.to/nicholas_toledo_5a6f9e576/your-eol-dependencies-just-became-a-legal-question-not-a-tech-debt-question-42f5</link>
      <guid>https://dev.to/nicholas_toledo_5a6f9e576/your-eol-dependencies-just-became-a-legal-question-not-a-tech-debt-question-42f5</guid>
      <description>&lt;p&gt;Every backlog has a ticket like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Upgrade Node 16 → 20. Priority: low. Nobody has complained.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On 8 December 2026 that ticket changes category. It stops being tech debt and starts being a civil liability question, at least for anything you place on the EU market.&lt;/p&gt;

&lt;p&gt;This is not a scare post. The mechanism is narrow, specific and readable in about twenty minutes of primary source. Then the engineering problem — &lt;em&gt;what, exactly, in my codebase can no longer receive a security update?&lt;/em&gt; — turns out to be a question you can answer with &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;jq&lt;/code&gt; today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The legal change, in engineering terms
&lt;/h2&gt;

&lt;p&gt;The relevant text is &lt;a href="https://eur-lex.europa.eu/eli/dir/2024/2853/oj" rel="noopener noreferrer"&gt;Directive (EU) 2024/2853&lt;/a&gt;, the revised Product Liability Directive. It replaces the 1985 directive. Three things matter to people who ship software.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Software is a product.&lt;/strong&gt; Article 4(1):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;'product' means all movables, even if integrated into, or inter-connected with, another movable or an immovable; it includes electricity, digital manufacturing files, raw materials and software&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No qualifier about delivery mechanism. Embedded firmware, a downloaded binary, a SaaS backend — the recitals are explicit that the mode of supply does not change the answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Liability is no-fault.&lt;/strong&gt; This is the part engineers usually get wrong. Strict liability does not mean "you were careless". A claimant does not have to prove negligence. They have to prove three things: the product was defective, they suffered damage, and the defect caused the damage. Your internal process quality, your code review standards, your intentions — none of that is an element of the claim.&lt;/p&gt;

&lt;p&gt;Article 7(1) defines defective as not providing "the safety that a person is entitled to expect". Article 7(2) lists the circumstances a court weighs, and 7(2)(f) includes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;relevant product safety requirements, including safety-relevant cybersecurity requirements&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;3. "It was fine when we shipped it" got much weaker.&lt;/strong&gt; Article 11(1)(c) is the classic later-defect defence: prove the defect came into being after you placed the product on the market and you are exempt. Article 11(2) then carves a hole straight through it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;By way of derogation from paragraph 1, point (c), an economic operator shall not be exempted from liability where the defectiveness of a product is due to any of the following, provided that it is within the manufacturer's control: (a) a related service; (b) software, including software updates or upgrades; &lt;strong&gt;(c) a lack of software updates or upgrades necessary to maintain safety&lt;/strong&gt;; (d) a substantial modification of the product.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read 11(2)(c) next to Recital 19, which says a product remains within the manufacturer's control "where the manufacturer retains the ability to supply software updates or upgrades itself or via a third party".&lt;/p&gt;

&lt;p&gt;That is the whole chain. If you can still ship updates, the product is still yours. And a &lt;em&gt;lack&lt;/em&gt; of the security updates needed to maintain safety is named, in the text, as something that does not get you out.&lt;/p&gt;

&lt;h3&gt;
  
  
  The scope limits nobody mentions
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It applies to products placed on the market or put into service after 8 December 2026&lt;/strong&gt; (Article 2(1)). Note the date: Article 2(1) as first published said 9 December, and was corrected to 8 December by &lt;a href="http://data.europa.eu/eli/dir/2024/2853/corrigendum/2026-05-07/oj" rel="noopener noreferrer"&gt;Corrigendum 2026/90364&lt;/a&gt;. Most commentary still says 9 December. Older products stay under the 1985 regime; it is not retroactive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;9 December 2026 is the transposition deadline&lt;/strong&gt; (Article 22) — one day after the scope date, which is why both dates appear in commentary. This is a &lt;em&gt;directive&lt;/em&gt;, not a regulation. The text that binds you is your member state's implementing law, which may differ at the edges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-commercial FOSS is out of scope.&lt;/strong&gt; Article 2(2): the directive does not apply to "free and open-source software that is developed or supplied outside the course of a commercial activity". That is about how it is supplied, not the licence. Maintaining an MIT library as a hobby is excluded. Selling a product built on it is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recoverable damage is death, personal injury, property damage and destruction or corruption of data&lt;/strong&gt; — not pure economic loss. A CRUD app that annoys people is not the target. A product that can hurt someone or destroy their data is.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am an engineer, not a lawyer. The above is me reading a primary source and quoting it, and none of it is legal advice. If you ship into the EU, take real advice on scope. The engineering question below stands regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  The engineering question
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Of everything I ship, what can no longer receive a security update at all?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is narrower than "what has a CVE". A component past its upstream end-of-life has no future patch, by definition. If a vulnerability lands in Node 16 tomorrow, there is no fix coming. Ever. "We'll patch it when it matters" is not available to you.&lt;/p&gt;

&lt;p&gt;Good news: there is a free public catalogue of exactly this, and you do not need a tool to query it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it with curl
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://endoflife.date" rel="noopener noreferrer"&gt;endoflife.date&lt;/a&gt; tracks EOL dates for runtimes, frameworks, databases and OSes. The v1 API is public, unauthenticated and rate-limit-friendly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://endoflife.date/api/v1/products/nodejs &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.result.releases[] | select(.name=="16")
        | {name, releaseDate, isEol, eolFrom, latest: .latest.name}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real output, today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"releaseDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2021-04-20"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isEol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"eolFrom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2023-09-11"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"latest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"16.20.2"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node 16 went EOL on 11 September 2023. The last release that will ever exist is 16.20.2.&lt;/p&gt;

&lt;p&gt;The full response is &lt;code&gt;{schema_version, generated_at, last_modified, result}&lt;/code&gt; where &lt;code&gt;result&lt;/code&gt; has &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;label&lt;/code&gt;, &lt;code&gt;category&lt;/code&gt;, &lt;code&gt;tags&lt;/code&gt;, &lt;code&gt;identifiers&lt;/code&gt;, &lt;code&gt;links&lt;/code&gt; and &lt;code&gt;releases[]&lt;/code&gt;. Each release carries &lt;code&gt;isEol&lt;/code&gt; / &lt;code&gt;eolFrom&lt;/code&gt;, plus &lt;code&gt;isEoas&lt;/code&gt; / &lt;code&gt;eoasFrom&lt;/code&gt; for end-of-active-support (security fixes only after this) and &lt;code&gt;isEoes&lt;/code&gt; / &lt;code&gt;eoesFrom&lt;/code&gt; for extended support.&lt;/p&gt;

&lt;p&gt;List every dead Node line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://endoflife.date/api/v1/products/nodejs &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.result.releases[] | select(.isEol) | "\(.name)\teol \(.eolFrom)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;25  eol 2026-06-01
23  eol 2025-06-01
21  eol 2024-06-01
20  eol 2026-04-30
19  eol 2023-06-01
18  eol 2025-04-30
17  eol 2022-06-01
16  eol 2023-09-11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note Node 20 in that list. An LTS release that many people still think of as current went EOL on 30 April 2026 — which means the backlog ticket at the top of this post ("upgrade Node 16 → 20") would have migrated you onto another dead runtime. That is exactly the kind of thing you want a machine to check rather than a memory.&lt;/p&gt;

&lt;p&gt;Same shape for anything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://endoflife.date/api/v1/products/python &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.result.releases[] | select(.name=="3.7") | {name, isEol, eolFrom}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3.7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isEol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"eolFrom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2023-06-27"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catalogue currently covers 473 products (&lt;code&gt;curl -s https://endoflife.date/api/v1/products | jq '.total'&lt;/code&gt;). There is also a legacy endpoint, &lt;code&gt;https://endoflife.date/api/nodejs.json&lt;/code&gt;, which returns a flat array of cycles with &lt;code&gt;cycle&lt;/code&gt; / &lt;code&gt;eol&lt;/code&gt; / &lt;code&gt;latest&lt;/code&gt; keys. It still works and you will see it in older scripts, but the v1 endpoint is the one to build on.&lt;/p&gt;

&lt;p&gt;You now have everything you need to write this yourself: parse &lt;code&gt;.nvmrc&lt;/code&gt;, &lt;code&gt;go.mod&lt;/code&gt;, &lt;code&gt;Dockerfile&lt;/code&gt; &lt;code&gt;FROM&lt;/code&gt; lines and &lt;code&gt;package.json&lt;/code&gt; engines, map each to a product slug, fetch, compare &lt;code&gt;eolFrom&lt;/code&gt; to today. It is an afternoon of work, and if you have unusual manifests it is the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it with a tool
&lt;/h2&gt;

&lt;p&gt;I wrote the afternoon-of-work version so I would stop re-writing it: &lt;a href="https://github.com/ntoledo319/pld-watch" rel="noopener noreferrer"&gt;&lt;code&gt;pld-watch&lt;/code&gt;&lt;/a&gt;. Single Python file, standard library only, MIT, no account and no telemetry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/ntoledo319/pld-watch/main/pld_watch.py &lt;span class="nt"&gt;-o&lt;/span&gt; pld-watch
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x pld-watch
./pld-watch scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Against a deliberately awful test project (&lt;code&gt;.nvmrc&lt;/code&gt; pinned to 16.20.2, &lt;code&gt;FROM python:3.7-slim&lt;/code&gt;, &lt;code&gt;next: ^12.0.0&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pld-watch v1.0.0
EU Product Liability Directive (EU) 2024/2853 - update-supportability screen

  Scope    /tmp/demoproj
  Found    6 runtime/framework version(s)
  PLD      applies from 2026-12-09 (86 days away)

  --------------------------------------------------------------------

  4 COMPONENT(S) PAST UPSTREAM END-OF-LIFE

    Node.js 16.20   (.nvmrc)
      upstream EOL   2023-09-11   latest supported: 26
    Node.js 16   (package.json engines.node)
      upstream EOL   2023-09-11   latest supported: 26
    next 12.0   (package.json)
      upstream EOL   2022-11-21   latest supported: 16
    python 3.7   (Dockerfile FROM)
      upstream EOL   2023-06-27   latest supported: 3.14
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads &lt;code&gt;.nvmrc&lt;/code&gt;, &lt;code&gt;.python-version&lt;/code&gt;, &lt;code&gt;go.mod&lt;/code&gt;, &lt;code&gt;package.json&lt;/code&gt; (engines plus a known-framework map), &lt;code&gt;requirements*.txt&lt;/code&gt; and &lt;code&gt;Dockerfile&lt;/code&gt; &lt;code&gt;FROM&lt;/code&gt; lines. Exit codes are &lt;code&gt;0&lt;/code&gt; nothing past EOL, &lt;code&gt;1&lt;/code&gt; at least one past EOL, &lt;code&gt;2&lt;/code&gt; nothing detectable to check. Responses are cached for 24 hours under &lt;code&gt;$XDG_CACHE_HOME/pld-watch&lt;/code&gt; so CI does not hammer a free API.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not do
&lt;/h2&gt;

&lt;p&gt;Be clear-eyed about it, because a tool that oversells itself is worse than no tool.&lt;/p&gt;

&lt;p&gt;It compares &lt;strong&gt;declared versions against a catalogue&lt;/strong&gt;. That is one narrow checkable fact. It does not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;prove your product is defective — that is decided by a court on the facts, not by a scanner&lt;/li&gt;
&lt;li&gt;prove the EOL component is reachable or exploitable in your build&lt;/li&gt;
&lt;li&gt;tell you whether you are in scope of the directive&lt;/li&gt;
&lt;li&gt;read binaries, container image layers or your own source&lt;/li&gt;
&lt;li&gt;know any framework outside its slug map&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The EOL date is a fact about upstream. Everything downstream of it is judgement. What the tool actually buys you is that the fact stops being invisible.&lt;/p&gt;

&lt;p&gt;And there is a broader reason to run this that has nothing to do with Brussels: a component that cannot receive a security patch is a genuine engineering liability whether or not anyone ever sues you. The directive is just the thing that finally makes it someone else's problem too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; the endoflife.date API for your top three runtimes. Ten minutes.&lt;/li&gt;
&lt;li&gt;Write down which components cannot receive a patch, and what it would cost to move each one.&lt;/li&gt;
&lt;li&gt;Put the check in CI so the list cannot silently grow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is the one that matters. EOL dates arrive on a schedule you do not control — something you shipped clean today goes EOL in April without a single commit.&lt;/p&gt;




&lt;p&gt;Tool: &lt;a href="https://github.com/ntoledo319/pld-watch" rel="noopener noreferrer"&gt;pld-watch&lt;/a&gt;. Engineering tooling, not legal advice, and not a compliance determination.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>opensource</category>
      <category>node</category>
    </item>
    <item>
      <title>I built a free in-browser EU CRA Article 14 checker — paste a lockfile, nothing uploaded</title>
      <dc:creator>Nicholas Toledo</dc:creator>
      <pubDate>Mon, 14 Sep 2026 09:32:01 +0000</pubDate>
      <link>https://dev.to/nicholas_toledo_5a6f9e576/i-built-a-free-in-browser-eu-cra-article-14-checker-paste-a-lockfile-nothing-uploaded-1a8n</link>
      <guid>https://dev.to/nicholas_toledo_5a6f9e576/i-built-a-free-in-browser-eu-cra-article-14-checker-paste-a-lockfile-nothing-uploaded-1a8n</guid>
      <description>&lt;p&gt;I wanted to make the EU Cyber Resilience Act Article 14 question answerable in ten seconds, without installing anything.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;&lt;a href="https://cra.toledotechnologies.com/check/" rel="noopener noreferrer"&gt;cra.toledotechnologies.com/check&lt;/a&gt;&lt;/strong&gt; — paste a &lt;code&gt;package-lock.json&lt;/code&gt;, &lt;code&gt;requirements.txt&lt;/code&gt;, &lt;code&gt;go.sum&lt;/code&gt;, &lt;code&gt;Cargo.lock&lt;/code&gt;, &lt;code&gt;Gemfile.lock&lt;/code&gt;, &lt;code&gt;composer.lock&lt;/code&gt;, &lt;code&gt;gradle.lockfile&lt;/code&gt;, or a CycloneDX/SPDX SBOM. Get an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question it answers
&lt;/h2&gt;

&lt;p&gt;Since &lt;strong&gt;11 September 2026&lt;/strong&gt;, Article 14 of the CRA has been enforceable: if a vulnerability in a product you've placed on the EU market is being &lt;strong&gt;actively exploited&lt;/strong&gt;, you owe an early warning to your national CSIRT and ENISA within &lt;strong&gt;24 hours&lt;/strong&gt; of becoming aware.&lt;/p&gt;

&lt;p&gt;The operative word is &lt;em&gt;exploited&lt;/em&gt;. Not "critical". Not "CVSS 9.8". Not "there's a PoC". That distinction is the entire ballgame, and it's why your scanner's severity-sorted output is answering a different question than the regulator is asking.&lt;/p&gt;

&lt;p&gt;The checker resolves your declared dependencies against &lt;a href="https://osv.dev" rel="noopener noreferrer"&gt;OSV.dev&lt;/a&gt;, maps every advisory to its CVE aliases, and intersects that with the &lt;a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog" rel="noopener noreferrer"&gt;CISA KEV catalogue&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing is uploaded
&lt;/h2&gt;

&lt;p&gt;It runs entirely in your browser. Your file contents never reach a server of mine — package names and versions go to the public OSV.dev API for lookup, and the KEV comparison happens locally in the page. No account, no logging, no backend of mine involved at all.&lt;/p&gt;

&lt;p&gt;One implementation note that might save you time if you build something similar: &lt;strong&gt;CISA's KEV feed sends no CORS headers&lt;/strong&gt;, so you cannot fetch it from a browser. I mirror it same-origin (US Government work, public domain) with explicit provenance fields in the JSON so the copy can't be mistaken for the authoritative source. OSV.dev &lt;em&gt;does&lt;/em&gt; send CORS headers and can be called directly from the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;p&gt;Clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No known-exploited components found
Checked 3 components against KEV catalogue 2026.09.11 (1709 entries).

3 component(s) do carry published advisories that are not on the KEV
list. That is ordinary patching work — not a reporting clock.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2 known-exploited components — decision required

CVE-2021-45046   org.apache.logging.log4j:log4j-core 2.14.1   Known
CVE-2021-44228   org.apache.logging.log4j:log4j-core 2.14.1   Known

Early warning   24 hours   2026-09-15 09:30:26 UTC
Notification    72 hours   2026-09-17 09:30:26 UTC
Final report    14 days    2026-09-28 09:30:26 UTC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it deliberately doesn't claim
&lt;/h2&gt;

&lt;p&gt;A KEV hit &lt;strong&gt;does not&lt;/strong&gt; mean you must file. KEV evidences exploitation &lt;em&gt;somewhere in the world&lt;/em&gt;; Article 14 asks about exploitation &lt;em&gt;in your product&lt;/em&gt;. If the vulnerable path isn't reachable in your build, the answer may be no — and the Commission's July 2026 guidance is explicit that reachability matters for third-party components. No scanner can make that call.&lt;/p&gt;

&lt;p&gt;Equally, a clean result is not a certificate. KEV lags and isn't exhaustive. Your own telemetry or a customer's incident report can start the clock before CISA lists anything.&lt;/p&gt;

&lt;p&gt;Whatever the result: write down what you knew and when you knew it. A documented decision &lt;em&gt;not&lt;/em&gt; to report is defensible. An undocumented one isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLI version
&lt;/h2&gt;

&lt;p&gt;For CI, the full tool walks your whole repo across twelve lockfile formats and exits non-zero on a match — &lt;a href="https://github.com/ntoledo319/cra-watch" rel="noopener noreferrer"&gt;github.com/ntoledo319/cra-watch&lt;/a&gt;, MIT, stdlib only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ntoledo319/cra-watch@v1.0.0&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;fail-on-match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;false'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it on a &lt;strong&gt;schedule&lt;/strong&gt;, not just on push. The KEV catalogue grows continuously — a repo that was clean yesterday can become a reporting question today with no change to your code.&lt;/p&gt;

&lt;p&gt;If you hit a lockfile format it mangles, open an issue. I'd rather fix it than be confidently wrong in public.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Not legal advice; I'm not a lawyer. Verify against Regulation (EU) 2024/2847 and your coordinating national CSIRT before filing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>showdev</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>13 advisories, 0 reporting triggers: severity is not exploitation under the EU CRA</title>
      <dc:creator>Nicholas Toledo</dc:creator>
      <pubDate>Mon, 14 Sep 2026 09:22:33 +0000</pubDate>
      <link>https://dev.to/nicholas_toledo_5a6f9e576/13-advisories-0-reporting-triggers-severity-is-not-exploitation-under-the-eu-cra-3hdh</link>
      <guid>https://dev.to/nicholas_toledo_5a6f9e576/13-advisories-0-reporting-triggers-severity-is-not-exploitation-under-the-eu-cra-3hdh</guid>
      <description>&lt;p&gt;I spent this morning reading the CISA KEV catalogue against a dozen real repositories, and I want to write down something that surprised me — because I think it's the thing most teams are going to get wrong about the EU Cyber Resilience Act.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Since 11 September 2026, Article 14 of the CRA has been enforceable. If a vulnerability in a product you've placed on the EU market is being &lt;strong&gt;actively exploited&lt;/strong&gt;, you owe an early warning to your national CSIRT and ENISA within 24 hours.&lt;/p&gt;

&lt;p&gt;Everyone reading that sentence hears "vulnerability" and thinks about their scanner output. That's the mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually found
&lt;/h2&gt;

&lt;p&gt;I scanned a real project this morning — 649 distinct components across a &lt;code&gt;package-lock.json&lt;/code&gt; and a &lt;code&gt;Cargo.lock&lt;/code&gt;. The result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Advisories  13 component(s) carry at least one known advisory
  KEV feed    1709 entries, catalogue 2026.09.11

  NO KEV MATCHES.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirteen components with published advisories. &lt;strong&gt;Zero&lt;/strong&gt; on the known-exploited list.&lt;/p&gt;

&lt;p&gt;Now, thirteen advisories would light up any dependency scanner. If you were treating scanner output as your CRA trigger, you'd have thirteen things that look like they might start a 24-hour clock. In reality, on this evidence, you have &lt;strong&gt;none&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The inverse is also true and more dangerous. I built a fixture with &lt;code&gt;log4j-core 2.14.1&lt;/code&gt; in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  2 KEV MATCH(ES) - MANUAL DECISION REQUIRED NOW

  CVE-2021-44228  (GHSA-jfh8-c2jp-5v3q)
    component      org.apache.logging.log4j:log4j-core 2.14.1  [Maven]
    known as       Apache Log4j2 Remote Code Execution Vulnerability
    KEV listed     2021-12-10   ransomware use: Known
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One component. Two known-exploited CVEs. That's the one that deserves a human in the room, and it would have been item #7 of 13 in a severity-sorted list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Severity is not exploitation
&lt;/h2&gt;

&lt;p&gt;This is the whole point and it's worth being blunt about it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CVSS 9.8&lt;/strong&gt; describes how bad it would be &lt;em&gt;if&lt;/em&gt; exploited.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KEV listing&lt;/strong&gt; describes that it &lt;em&gt;is being&lt;/em&gt; exploited, in the real world, right now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Article 14 turns on the second one. Most vulnerability tooling optimises for the first. So the list you've been living with — sorted by severity, triaged by "critical/high/medium" — is not the list that answers the regulatory question. It's a different question with a different answer set, and the overlap is smaller than you'd guess.&lt;/p&gt;

&lt;p&gt;There's a second-order effect that I think is underrated: treating every critical CVE as a potential reporting trigger doesn't just waste time, it produces &lt;strong&gt;over-reporting&lt;/strong&gt;, and over-reporting to a regulator is its own kind of problem. You want the small true list, not the big cautious one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I'm honest about the limits
&lt;/h2&gt;

&lt;p&gt;I wrote a tool to do the intersection (&lt;a href="https://github.com/ntoledo319/cra-watch" rel="noopener noreferrer"&gt;&lt;code&gt;cra-watch&lt;/code&gt;&lt;/a&gt;, MIT, stdlib-only, no account). But I want to be straight about what it can't do, because compliance tooling that overclaims is worse than no tooling:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A KEV listing doesn't mean you must report.&lt;/strong&gt; KEV says a vulnerability is being exploited &lt;em&gt;somewhere in the world&lt;/em&gt;. Article 14 asks whether it's being exploited &lt;em&gt;in your product&lt;/em&gt;. If the vulnerable code path isn't reachable in your build, the answer may genuinely be no. The Commission's July 2026 guidance is explicit that reachability matters for third-party components. No scanner can make that call — a person has to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KEV isn't the legal definition either.&lt;/strong&gt; It's a US government catalogue. It lags, and it isn't exhaustive. Your own telemetry or a customer's incident report can start the clock before CISA ever lists the CVE.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clean output isn't a certificate.&lt;/strong&gt; It means two public sources don't currently intersect for your dependency graph. Useful, dated, reproducible. Not immunity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually do on Monday
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run the intersection, not the severity sort.&lt;/strong&gt; You want the short list, and it probably is short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put it on a schedule, not on push.&lt;/strong&gt; This is the bit people miss. The KEV catalogue gains entries continuously. A repo that was clean yesterday can become a reporting question today with zero code changes on your side. A one-off audit answers a question about the past.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide who presses send, before you need to know.&lt;/strong&gt; Under a 24-hour clock, the most common failure isn't technical — it's four people each assuming someone else owns the decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down what you knew and when.&lt;/strong&gt; Whatever you decide, the contemporaneous record is what defends it later. A documented decision &lt;em&gt;not&lt;/em&gt; to report is defensible. An undocumented one is very hard to defend.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tool is free and I'd rather it were widely used than gated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/ntoledo319/cra-watch/main/cra_watch.py &lt;span class="nt"&gt;-o&lt;/span&gt; cra-watch
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x cra-watch &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ./cra-watch scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines in CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ntoledo319/cra-watch@v1.0.0&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;fail-on-match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;false'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you find a case where it gets the intersection wrong, open an issue — I'd rather fix it than be confidently wrong in public.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Not a lawyer, not legal advice. Verify against Regulation (EU) 2024/2847 and your coordinating national CSIRT's guidance before filing anything. I also sell a &lt;a href="https://cra.toledotechnologies.com" rel="noopener noreferrer"&gt;process kit&lt;/a&gt; for the decision-and-filing side; the scanner is free and always will be, and it isn't crippled or time-limited.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The EU's 24-hour vulnerability reporting clock started 3 days ago. Here's a free tool to check if it applies to you.</title>
      <dc:creator>Nicholas Toledo</dc:creator>
      <pubDate>Mon, 14 Sep 2026 09:05:11 +0000</pubDate>
      <link>https://dev.to/nicholas_toledo_5a6f9e576/the-eus-24-hour-vulnerability-reporting-clock-started-3-days-ago-heres-a-free-tool-to-check-if-1b5p</link>
      <guid>https://dev.to/nicholas_toledo_5a6f9e576/the-eus-24-hour-vulnerability-reporting-clock-started-3-days-ago-heres-a-free-tool-to-check-if-1b5p</guid>
      <description>&lt;p&gt;On &lt;strong&gt;11 September 2026&lt;/strong&gt;, the first enforceable deadline under the EU Cyber Resilience Act arrived. Almost nobody I've spoken to noticed, because the headline date everyone has in their calendar is December 2027.&lt;/p&gt;

&lt;p&gt;That's the date most of the CRA applies. But Article 14 — the reporting obligation — has been live since the 11th.&lt;/p&gt;

&lt;p&gt;Here's the short version:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a vulnerability in a product you've placed on the EU market is being &lt;strong&gt;actively exploited&lt;/strong&gt;, you owe an early warning to your coordinating national CSIRT and ENISA &lt;strong&gt;within 24 hours&lt;/strong&gt; of becoming aware of it. Then a fuller notification within 72 hours. Then a final report within 14 days of a fix being available.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Penalties top out at €15 million or 2.5% of worldwide annual turnover (Article 64(2)).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One relief that's worth knowing and often gets left out:&lt;/strong&gt; under Article 64(10)(a), those administrative fines do &lt;em&gt;not&lt;/em&gt; apply to microenterprises and small enterprises for missing the &lt;em&gt;24-hour&lt;/em&gt; early-warning deadline specifically. That relief covers the deadline only — not the duty to report, and not the 72-hour or final-report stages. If you're a small team, the headline fine number you keep seeing quoted is probably not the one that applies to you.&lt;/p&gt;

&lt;p&gt;And the part that catches people: under Article 69(3), this covers products &lt;strong&gt;already on the EU market&lt;/strong&gt;. Not just things you ship after the deadline. That thing you shipped in 2022 that's still being sold? In scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Products with digital elements" is broader than you think
&lt;/h2&gt;

&lt;p&gt;It's software, hardware, remote data-processing solutions, and components placed on the market separately. If you're outside the EU but sell into it through an importer, distributor, or authorised representative, the obligation can still reach you. Being in Ohio doesn't exempt you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The word that matters is "exploited"
&lt;/h2&gt;

&lt;p&gt;This is where I think most teams are going to get it wrong, in both directions.&lt;/p&gt;

&lt;p&gt;Article 14 is &lt;strong&gt;not&lt;/strong&gt; triggered by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a critical CVE in your dependency tree&lt;/li&gt;
&lt;li&gt;a CVSS 9.8&lt;/li&gt;
&lt;li&gt;a proof-of-concept on GitHub&lt;/li&gt;
&lt;li&gt;your scanner going red&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's triggered by evidence that the vulnerability is &lt;strong&gt;actually being exploited in the real world&lt;/strong&gt;, in your product. That's a much smaller set. Most CVEs never meet it.&lt;/p&gt;

&lt;p&gt;The Commission's July 2026 guidance also clarified something useful about third-party components: if an actively exploited vulnerability comes from a component you integrated, a report is required when that vulnerability is being actively exploited &lt;strong&gt;in your product&lt;/strong&gt;. If the vulnerable code isn't reachable in your build, an Article 14 report isn't required on that basis.&lt;/p&gt;

&lt;p&gt;So the question isn't "do I have CVEs." Everyone has CVEs. The question is: &lt;em&gt;is any of what I ship on the list of things being exploited right now?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A free tool for exactly that question
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://github.com/ntoledo319/cra-watch" rel="noopener noreferrer"&gt;&lt;code&gt;cra-watch&lt;/code&gt;&lt;/a&gt; to answer it. MIT licensed, Python standard library only, no account, no telemetry, no signup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/ntoledo319/cra-watch/main/cra_watch.py &lt;span class="nt"&gt;-o&lt;/span&gt; cra-watch
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x cra-watch
./cra-watch scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads your lockfiles, resolves them against &lt;a href="https://osv.dev" rel="noopener noreferrer"&gt;OSV.dev&lt;/a&gt;, and intersects the result with the &lt;a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog" rel="noopener noreferrer"&gt;CISA Known Exploited Vulnerabilities catalogue&lt;/a&gt; — the closest thing to a public register of what's genuinely being exploited in the wild.&lt;/p&gt;

&lt;p&gt;Clean run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Advisories  13 component(s) carry at least one known advisory
  KEV feed    1709 entries, catalogue 2026.09.11

  NO KEV MATCHES.

  Nothing in your dependency graph appears on the CISA Known Exploited
  Vulnerabilities catalogue. On this evidence there is no Article 14
  24-hour reporting trigger from a listed component today.

  You do still have advisories on 13 component(s).
  Those are ordinary patching work, not a reporting clock. Article 14
  is about ACTIVE EXPLOITATION, not severity.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what it does there: 13 components have advisories, and it explicitly tells you those are &lt;strong&gt;not&lt;/strong&gt; a reporting trigger. That distinction is the whole point. Your scanner's wall of red is patching work. This is a different question.&lt;/p&gt;

&lt;p&gt;The other answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  2 KEV MATCH(ES) - MANUAL DECISION REQUIRED NOW

  CVE-2021-44228  (GHSA-jfh8-c2jp-5v3q)
    component      org.apache.logging.log4j:log4j-core 2.14.1  [Maven]
    known as       Apache Log4j2 Remote Code Execution Vulnerability
    KEV listed     2021-12-10   ransomware use: Known

  If you confirm exploitation in your product, the clock is:

    Early warning     within 24h    2026-09-15 08:40:10 UTC
    Notification      within 72h    2026-09-17 08:40:10 UTC
    Final report      within 14d    2026-09-28 08:40:10 UTC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supports 12 lockfile formats (npm, yarn, pnpm, pip, poetry, Pipfile, go.sum, Cargo, Gemfile, composer, NuGet, gradle) plus CycloneDX and SPDX SBOMs.&lt;/p&gt;

&lt;p&gt;Exit codes are CI-friendly: &lt;code&gt;0&lt;/code&gt; clean, &lt;code&gt;1&lt;/code&gt; KEV match, &lt;code&gt;2&lt;/code&gt; nothing scannable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it on a schedule, not on push
&lt;/h2&gt;

&lt;p&gt;This is the bit I'd emphasise. The KEV catalogue gains entries continuously. A repo that was clean yesterday can become a reporting question today &lt;strong&gt;without a single line of your code changing&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cra-watch&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;curl -fsSL https://raw.githubusercontent.com/ntoledo319/cra-watch/main/cra_watch.py -o cra-watch.py&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python3 cra-watch.py scan&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A one-off audit answers a question about yesterday.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tool deliberately does not do
&lt;/h2&gt;

&lt;p&gt;I want to be straight about the limits, because compliance tooling that overclaims is worse than no tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A KEV listing is not proof of exploitation in your product.&lt;/strong&gt; KEV says a vulnerability is being exploited &lt;em&gt;somewhere in the world&lt;/em&gt;. Article 14 asks whether it's being exploited &lt;em&gt;in the thing you shipped&lt;/em&gt;. If the vulnerable code path is unreachable in your build, the answer may genuinely be no. The tool can't assess reachability. A person has to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KEV is not the legal definition of "actively exploited."&lt;/strong&gt; It's a US government catalogue. It lags, and it isn't exhaustive. Your own telemetry or a customer's incident report can start the clock before CISA lists anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clean output is not a compliance certificate.&lt;/strong&gt; It means two public data sources don't currently intersect for your dependency graph. That's useful and reproducible. It isn't immunity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It only sees declared dependencies.&lt;/strong&gt; No vendored code, no static linking, no container base images, no your-own-source analysis.&lt;/p&gt;

&lt;p&gt;Whatever the output, the thing to actually do is &lt;strong&gt;write down what you knew, when you knew it, and why you decided as you did.&lt;/strong&gt; That contemporaneous record is what defends the decision later — far more than any scanner output.&lt;/p&gt;

&lt;h2&gt;
  
  
  The harder half
&lt;/h2&gt;

&lt;p&gt;Finding the shortlist is the easy part, and it's the part software can do.&lt;/p&gt;

&lt;p&gt;The hard part is the decision under a 24-hour clock: who's accountable for pressing send, what an early warning says when you still know almost nothing (answer: very little — it's &lt;em&gt;designed&lt;/em&gt; to be thin, and you file it anyway), and what evidence defends a decision &lt;strong&gt;not&lt;/strong&gt; to report when someone asks eight months later.&lt;/p&gt;

&lt;p&gt;I also put together a &lt;a href="https://cra.toledotechnologies.com" rel="noopener noreferrer"&gt;CRA 24-Hour Reporting Kit&lt;/a&gt; covering that process side — decision tree, the three clocks worked in UTC, fill-in templates for all three filings, an evidence log, and a 30-minute tabletop exercise. That one's paid ($149), and it's the only thing I'm selling here. The scanner is free and always will be; it's not crippled, time-limited, or a trial.&lt;/p&gt;

&lt;p&gt;If you only take one thing from this post, take the scanner and the cron job. The deadline is real, it's already passed, and most teams haven't checked.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm not a lawyer and none of this is legal advice. Verify against Regulation (EU) 2024/2847 and guidance from your coordinating national CSIRT before filing anything. If I've got something wrong, tell me in the comments — I'd rather fix it than be confidently wrong in public.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>opensource</category>
      <category>compliance</category>
      <category>devops</category>
    </item>
    <item>
      <title>Turn Any Web Page into Markdown with NoHustle API</title>
      <dc:creator>Nicholas Toledo</dc:creator>
      <pubDate>Fri, 22 Aug 2025 04:57:01 +0000</pubDate>
      <link>https://dev.to/nicholas_toledo_5a6f9e576/turn-any-web-page-into-markdown-with-nohustle-api-3h1a</link>
      <guid>https://dev.to/nicholas_toledo_5a6f9e576/turn-any-web-page-into-markdown-with-nohustle-api-3h1a</guid>
      <description>&lt;p&gt;Scraping web content is tedious. NoHustle API converts any URL to clean Markdown in one GET request.&lt;/p&gt;

&lt;h2&gt;
  
  
  🚀 Zero Setup Required
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://nohustle-api.onrender.com/url2md?url=https://example.com/article"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  💪 What It Handles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript-rendered pages&lt;/strong&gt; - Waits for content to load&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean text extraction&lt;/strong&gt; - Removes ads, navigation, footers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proper formatting&lt;/strong&gt; - Headers, links, lists preserved&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large articles&lt;/strong&gt; - Handles long-form content reliably&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🔧 Integration Examples
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Python:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://nohustle-api.onrender.com/url2md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://news.ycombinator.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;markdown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;cURL in CI/CD:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Archive article as Markdown&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://nohustle-api.onrender.com/url2md?url=&lt;/span&gt;&lt;span class="nv"&gt;$ARTICLE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; archive/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d&lt;span class="si"&gt;)&lt;/span&gt;.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Great for content archiving, research tools, or feeding LLMs clean text.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 Try it Now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;🌐 &lt;a href="https://huggingface.co/spaces/ntoledo319/nohustle-removebg?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=nohustle_launch" rel="noopener noreferrer"&gt;Try in Browser →&lt;/a&gt;&lt;/strong&gt; &lt;em&gt;(Hugging Face Space)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📫 &lt;a href="https://www.postman.com/nohustle-api?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=nohustle_launch" rel="noopener noreferrer"&gt;Run in Postman →&lt;/a&gt;&lt;/strong&gt; &lt;em&gt;(One-click collection)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚡ &lt;a href="https://rapidapi.com/no-hustle-api-no-hustle-api-default/api/no-hustle-api?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=nohustle_launch" rel="noopener noreferrer"&gt;RapidAPI →&lt;/a&gt;&lt;/strong&gt; &lt;em&gt;(Managed hosting)&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More NoHustle endpoints:&lt;/strong&gt; &lt;a href="https://nohustle-api.onrender.com?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=nohustle_launch" rel="noopener noreferrer"&gt;https://nohustle-api.onrender.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>automation</category>
      <category>nocode</category>
      <category>python</category>
    </item>
    <item>
      <title>Remove Image Background via API (Free tier, no paid upstreams)</title>
      <dc:creator>Nicholas Toledo</dc:creator>
      <pubDate>Fri, 22 Aug 2025 04:57:01 +0000</pubDate>
      <link>https://dev.to/nicholas_toledo_5a6f9e576/remove-image-background-via-api-free-tier-no-paid-upstreams-3dec</link>
      <guid>https://dev.to/nicholas_toledo_5a6f9e576/remove-image-background-via-api-free-tier-no-paid-upstreams-3dec</guid>
      <description>&lt;p&gt;Need to remove backgrounds from images without paying for expensive APIs? NoHustle API does it for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎯 One POST, Clean Results
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="nv"&gt;image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@sample.jpg https://nohustle-api.onrender.com/remove-bg &lt;span class="nt"&gt;-o&lt;/span&gt; clean.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  ✨ What You Get
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PNG with transparency&lt;/strong&gt; - Perfect for overlays, logos, product shots&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No subscriptions&lt;/strong&gt; - Free tier covers most use cases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast processing&lt;/strong&gt; - Usually under 3 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No watermarks&lt;/strong&gt; - Clean output, ready to use&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🔧 Code Examples
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Python:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image.jpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://nohustle-api.onrender.com/remove-bg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clean.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;JavaScript:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fileInput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://nohustle-api.onrender.com/remove-bg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perfect for e-commerce, design workflows, or any app that needs clean product images.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 Try it Now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;🌐 &lt;a href="https://huggingface.co/spaces/ntoledo319/nohustle-removebg?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=nohustle_launch" rel="noopener noreferrer"&gt;Try in Browser →&lt;/a&gt;&lt;/strong&gt; &lt;em&gt;(Hugging Face Space)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;📫 &lt;a href="https://www.postman.com/nohustle-api?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=nohustle_launch" rel="noopener noreferrer"&gt;Run in Postman →&lt;/a&gt;&lt;/strong&gt; &lt;em&gt;(One-click collection)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚡ &lt;a href="https://rapidapi.com/no-hustle-api-no-hustle-api-default/api/no-hustle-api?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=nohustle_launch" rel="noopener noreferrer"&gt;RapidAPI →&lt;/a&gt;&lt;/strong&gt; &lt;em&gt;(Managed hosting)&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More NoHustle endpoints:&lt;/strong&gt; &lt;a href="https://nohustle-api.onrender.com?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=nohustle_launch" rel="noopener noreferrer"&gt;https://nohustle-api.onrender.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>automation</category>
      <category>nocode</category>
      <category>python</category>
    </item>
  </channel>
</rss>
