DEV Community

Nicholas Toledo
Nicholas Toledo

Posted on

The EU Cyber Resilience Act and the new Product Liability Directive — what an engineer actually has to do

Two EU instruments now touch the code you ship. They ask different questions, and conflating them is where most of the confusion comes from.

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

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.

The date that is printed wrong almost everywhere

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 9 December 2026.

It was corrected. Corrigendum 2026/90364 (OJ L, 2026/90364, 7.5.2026) changes Article 2(1) to read "after 8 December 2026".

Three things follow, and the third catches people:

  1. The corrigendum touches the scope 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.
  2. EUR-Lex serves the original OJ text at the eli/dir/2024/2853/oj 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.
  3. Most secondary commentary — law-firm explainers, conference slides, the summary someone pasted into your Confluence — predates the corrigendum and still says 9 December.

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.

Two laws, two different questions

Reduce each to the question it actually asks.

CRA Article 14 asks: is something I ship being actively exploited right now?

That is an operational reporting obligation with a cascading clock. For an actively exploited vulnerability in a product with digital elements: early warning within 24 hours, vulnerability notification within 72 hours, final report within 14 days. Reports go to the coordinating national CSIRT and to ENISA; the Commission's CRA reporting page 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.

The words doing the work are actively exploited. 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.

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: is this being used?

The PLD asks: can I still supply security updates for what I shipped?

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.

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.

What you can actually check, with real commands

Everything here is a public endpoint: no key, no account.

1. CISA KEV: a free, dated exploitation signal

The Known Exploited Vulnerabilities catalogue is a single JSON file:

KEV=https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
curl -s "$KEV" | jq '{catalogVersion, count, first: .vulnerabilities[0].cveID}'
Enter fullscreen mode Exit fullscreen mode

The shape, with values elided:

{
  "catalogVersion": "2026.09.14",
  "count": 1710,
  "vulnerabilities": [
    {
      "cveID": "CVE-...",
      "vendorProject": "...",
      "product": "...",
      "dateAdded": "2021-11-03",
      "dueDate": "...",
      "knownRansomwareCampaignUse": "Known"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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

curl -s "$KEV" | jq '[.vulnerabilities[] | select(.knownRansomwareCampaignUse == "Known")] | length'
curl -s "$KEV" | jq '[.vulnerabilities[] | select(.dateAdded >= "2026-06-16")] | length'
Enter fullscreen mode Exit fullscreen mode

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

2. OSV.dev: what advisories touch a package version

curl -s -X POST https://api.osv.dev/v1/query \
  -H 'Content-Type: application/json' \
  -d '{"package":{"ecosystem":"npm","name":"lodash"},"version":"4.17.20"}' \
  | jq '.vulns[0] | {id, aliases, summary}'
Enter fullscreen mode Exit fullscreen mode

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

3. The gotcha that returns a clean result forever

For a lockfile you want /v1/querybatch, which takes many queries per request. Here is the trap:

# BROKEN. Runs fine. Finds nothing. Ever.
osv=$(curl -s -X POST https://api.osv.dev/v1/querybatch \
  -H 'Content-Type: application/json' \
  -d '{"queries":[{"package":{"ecosystem":"npm","name":"lodash"},"version":"4.17.20"}]}' \
  | jq -r '.results[].vulns[]?.id' | sort -u)

kev=$(curl -s "$KEV" | jq -r '.vulnerabilities[].cveID' | sort -u)

comm -12 <(echo "$osv") <(echo "$kev")   # empty, always
Enter fullscreen mode Exit fullscreen mode

querybatch does not return the same objects as query. It returns only:

{"results":[{"vulns":[{"id":"GHSA-xxxx-xxxx-xxxx","modified":"2026-...Z"}]}]}
Enter fullscreen mode Exit fullscreen mode

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

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.

The fix is a second call per advisory:

curl -s https://api.osv.dev/v1/vulns/GHSA-xxxx-xxxx-xxxx \
  | jq -r '.aliases[]? | select(startswith("CVE-"))'
Enter fullscreen mode Exit fullscreen mode

/v1/vulns/{id} returns the full record including aliases, which is where the CVE lives. Resolve, then intersect.

Two notes. modified is a cache key: cache /v1/vulns/{id} on (id, modified) 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.

4. endoflife.date: what can no longer receive security updates

This is the PLD-shaped question, because Recital 19 ties control to your ability to supply updates.

curl -s https://endoflife.date/api/v1/products | jq '.result | length'   # 473, no key needed

curl -s https://endoflife.date/api/v1/products/nodejs \
  | jq -r '.result.releases[] | select(.isEol == true) | "\(.name)  eol \(.eolFrom // "unknown")"'
Enter fullscreen mode Exit fullscreen mode

Releases carry isEol booleans and eolFrom 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.

5. A sketch that ties it together

Standard library only. A sketch, not a product: no retries, no backoff, no concurrency, no paginated OSV results, no SBOM parsing.

#!/usr/bin/env python3
"""Which of my dependencies map to a CVE that CISA lists as exploited."""
import json, sys, urllib.request

OSV_BATCH = "https://api.osv.dev/v1/querybatch"
OSV_VULN  = "https://api.osv.dev/v1/vulns/"
KEV = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"


def get(url, payload=None):
    data = json.dumps(payload).encode() if payload else None
    hdr = {"Content-Type": "application/json"} if payload else {}
    with urllib.request.urlopen(
            urllib.request.Request(url, data=data, headers=hdr), timeout=60) as r:
        return json.load(r)


def main(deps):                       # deps: [(ecosystem, name, version), ...]
    queries = [{"package": {"ecosystem": e, "name": n}, "version": v}
               for e, n, v in deps]
    batch = get(OSV_BATCH, {"queries": queries})

    # Step 1: querybatch gives {id, modified} only. No aliases. No CVEs.
    found = {}                        # osv id -> dep
    for dep, result in zip(deps, batch.get("results", [])):
        for vuln in result.get("vulns", []):
            found[vuln["id"]] = dep
    if not found:
        print("no advisories returned - check your ecosystem strings")
        return 0

    # Step 2: resolve each id to its CVE aliases. The step people skip.
    cve_of = {}                       # cve -> (osv id, dep)
    for osv_id, dep in found.items():
        for alias in get(OSV_VULN + osv_id).get("aliases", []):
            if alias.startswith("CVE-"):
                cve_of[alias] = (osv_id, dep)

    # Canary: no aliases resolved means the join below is meaningless.
    if not cve_of:
        print(f"FAIL: 0 CVE aliases from {len(found)} advisories", file=sys.stderr)
        return 2

    # Step 3: now, and only now, intersect with KEV.
    kev = {v["cveID"]: v for v in get(KEV)["vulnerabilities"]}
    hits = sorted(set(cve_of) & set(kev))
    for cve in hits:
        osv_id, (eco, name, version) = cve_of[cve]
        print(f"{cve}  {eco}:{name}@{version}  osv={osv_id}  "
              f"added={kev[cve]['dateAdded']}  "
              f"ransomware={kev[cve]['knownRansomwareCampaignUse']}")
    print(f"{len(found)} advisories -> {len(cve_of)} CVEs -> {len(hits)} on KEV")
    return 1 if hits else 0


if __name__ == "__main__":
    sys.exit(main([("npm", "lodash", "4.17.20"), ("PyPI", "django", "3.2.0")]))
Enter fullscreen mode Exit fullscreen mode

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.

What the law does not say

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.

The PLD does not oblige you to ship updates. Recital 51 says the directive imposes no obligation to provide updates. There is no statutory patch cadence in it. Recital 19 treats your ability 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.

Pure economic loss is excluded. 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.

The open-source carve-out is real but narrow. 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.

KEV is a US catalogue and the CRA does not point to it. Nothing in the CRA references CISA. KEV is public domain, dated, machine-readable and free, which makes it a good engineering 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.

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.

What to actually do this week

Five things, no budget or vendor required.

  1. Know what you ship. Generate an SBOM per release artifact and store it with the artifact. The SBOM you regenerate from main 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.
  2. Know what is on KEV. Run the intersection above against the dependency sets you shipped. Alert on non-empty, fail on zero-alias runs.
  3. Know what is past EOL. 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.
  4. Have a written disclosure path. A SECURITY.md and a /.well-known/security.txt 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.
  5. Keep a dated record of reporting decisions — including the decisions not to report.

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.

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.

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.

Closing

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.

I wrote two small open-source tools while working through the above — cra-watch and pld-watch — which do roughly what the sketch above does, with the caching and the canary in place.

Primary sources

If something here is wrong, say so in the comments and I will correct it.

Top comments (0)