Skip to main content

art14

An SBOM goes in. Out comes the short list of vulnerabilities that need a reporting decision under Article 14 of Regulation (EU) 2024/2847, and a written reason for every one of them.

Article 14 does not turn on severity. What it attaches to is an actively exploited vulnerability: a CVSS 10.0 that nobody is exploiting is not that, and a moderate that is being exploited in the wild may be exactly that. This is the reading art14 is built on, and it is one to check against the regulation itself rather than take from a README. The mistake runs in both directions -- a queue ordered by severity score reports what it need not, and misses what it must.

The worked example below is a Java application with 36 components. OSV.dev matches 138 vulnerabilities against it. Seven of those are in the exploited-vulnerability catalogue, and those seven are the ones a person has to look at. Collapsing 138 to 7, and saying why for each, is the whole tool.

art14 is not a scanner. It never reads images, containers or filesystems. SBOM in, verdict out:

uvx art14 sbom.cdx.json
syft ghcr.io/acme/gateway:1.4 -o cyclonedx-json | uvx art14 -

The source is at github.com/ddodevski/cra-article14-triage, and the example SBOM every result on this page was produced from lives there under examples/ rather than in the installed package. Version 0.1.0, published because the tool works and not as the start of a programme: there is no roadmap, no issue triage and no support commitment behind it.

What it says

A run of art14 against the Log4j example SBOM: 36 components, 138 vulnerabilities matched, 136 CVE ids put to the KEV catalogue, 5 of them listed there, nothing confirmed reportable and 7 items left for a human to decide

art14 examples/log4j-app.cdx.json, with no configuration and no argument beyond the file:

art14 0.1.0 - CycloneDX 1.6 - gateway@3.2.0

input: 36 components - 0 without PURL (0%) - 0 without version
       matching quality: ok - see README
       1 vulnerability with no CVE id - not checkable against the KEV catalogue
result: 0 to report - 7 to assess - 131 not in the KEV catalogue

1 vulnerability carries no CVE id, so the KEV catalogue could not be asked
about it. It is not absent from the catalogue: the question was never put.
Treat it as unassessed for exploitation, exactly as you would an unmatched
component -- not as clear.

  components            36   (read from the SBOM)
  vulnerabilities      138   (records matched against OSV.dev)
  component-CVE pairs  140   (one record can affect several components;
                             several can name one CVE: 139 distinct pairs)
  components queried    36   (36 answered from cache)
  records fetched        0   (vulnerability records downloaded this run)
  CVEs checked         136   (distinct CVE ids put to the EUVD KEV catalogue)
  known exploited        5   (distinct CVE ids listed in the catalogue,
                             across 7 component-CVE pairs)
  not checkable          1   (records with no CVE id; no key to look up)

  REPORT                 0   (component-CVE pairs; the 24h clock is running)
  ASSESS                 7   (pairs in the KEV catalogue; needs a decision now)
  NO                   131   (pairs not in the KEV catalogue)
  unchecked              1   (pairs whose record carries no CVE id)
CVE            | component                     | D/T | bucket | listed by
---------------+-------------------------------+-----+--------+----------
CVE-2022-22965 | spring-boot-starter-web@2.5.6 | D   | ASSESS |      CISA
CVE-2022-22965 | spring-webmvc@5.3.12          | T   | ASSESS |      CISA
CVE-2022-22965 | spring-beans@5.3.12           | T   | ASSESS |      CISA
CVE-2021-45046 | log4j-core@2.14.1             | D   | ASSESS |      CISA
CVE-2021-44228 | log4j-core@2.14.1             | D   | ASSESS |      CISA
CVE-2025-24813 | tomcat-embed-core@9.0.54      | T   | ASSESS |      CISA
CVE-2023-44487 | tomcat-embed-core@9.0.54      | T   | ASSESS |      CISA
D = direct dependency, T = transitive (pulled in by another component)
+ 131 item(s) not in the KEV catalogue

7 item(s) need a decision. Run again with --brief for the full decision
brief on each: where it sits, what it is, who says it is exploited, and the
one question to answer about this product.

Exit code 1: seven items are open.

see README, here and in the blocks further down, points at Matching quality. Every output block on this page is a verbatim copy of a real run, so they all say what the terminal says.

The 131 in NO are not listed row by row, and never will be. A tool that prints the other 131 has handed the problem back unsolved.

gateway@3.2.0 is a constructed example and says so in its own metadata: no such application exists. Every component and version in it is real, which is the point -- the vulnerabilities are fetched from OSV.dev when you run it and are not carried in the file. The KEV hits it is written around were verified against the live catalogue on 2026-09-12; if an entry is withdrawn later your run will show fewer rows than this README does, and that is the catalogue talking rather than a bug.

Run it

git clone https://github.com/ddodevski/cra-article14-triage
cd cra-article14-triage

Then, from the root:

./art14.sh examples/log4j-app.cdx.json      # Linux, macOS
art14.cmd examples/log4j-app.cdx.json       # Windows

Nothing lands in your Python: the launcher finds or builds what it needs beside itself, and Installing it has the rest.

What it says once somebody has decided

The same SBOM run against a file of recorded decisions: 1 item to report, 1 left open on purpose, 136 not to report, five of them ruled out in configuration with the written rationale for each printed under the table

Run the same SBOM against a file of recorded decisions:

art14 examples/log4j-app.cdx.json --config examples/dispositions.toml

That file is TOML, and every entry needs a component, a CVE and a rationale:

[[report]]
component = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"
cve = "CVE-2021-44228"
rationale = """
Reachable and confirmed by test. GatewayRequestFilter logs the X-Forwarded-For
header at INFO on every inbound request, the value is attacker-controlled, and
the shipped configuration uses the default PatternLayout with message lookups
enabled (log4j2.formatMsgNoLookups is not set and the JRE is 8u292, below the
com.sun.jndi.ldap.object.trustURLCodebase default change). A JNDI callback was
observed against an internal listener on 2026-09-11. Mitigation in progress;
upgrade to 2.17.1 scheduled, but the product is shipping today.
Decided 2026-09-11 by the platform security team.
"""

[[no]]
component = "pkg:maven/org.apache.tomcat.embed/tomcat-embed-core@9.0.54"
cve = "CVE-2025-24813"
justification = "requires_configuration"
rationale = """
Not reachable. The partial-PUT path requires the default servlet to be running
with readonly=false and either file-based session persistence or a library
that deserialises uploaded content. gateway leaves the default servlet
read-only, has no PUT route, and uses Redis-backed sessions rather than the
FileStore. Confirmed against the packaged conf/web.xml and the session
configuration in application.yml.
Decided 2026-09-11 by the platform security team.
"""
result: 1 to report - 1 to assess - 136 not to report

  REPORT                 1   (component-CVE pairs; the 24h clock is running)
  ASSESS                 1   (pairs in the KEV catalogue; needs a decision now)
  NO                   136   (pairs not to report)
  of which               5   ruled out in configuration; the reasons are below
  unchecked              1   (pairs whose record carries no CVE id)
CVE            | component                | D/T | bucket | listed by
---------------+--------------------------+-----+--------+----------
CVE-2021-44228 | log4j-core@2.14.1        | D   | REPORT |      CISA
CVE-2023-44487 | tomcat-embed-core@9.0.54 | T   | ASSESS |      CISA
D = direct dependency, T = transitive (pulled in by another component)
+ 136 item(s) not to report

5 item(s) in the KEV catalogue were ruled out in examples/dispositions.toml.
They are in NO on the rationale recorded below, which is the manufacturer's
own determination and not one art14 made or checked. This is the material a
VEX statement and an audit trail are written from.
  - CVE-2022-22965 spring-boot-starter-web@2.5.6
      Precondition not met. Spring4Shell requires the application to be
      deployed as a WAR on a servlet container running on JDK 9 or later.
      gateway ships as an executable jar on the embedded Tomcat and runs on
      JDK 8u292; neither the packaging nor the runtime satisfies the
      exploit. Verified against the built artefact, not against the build
      file, because the two can disagree. Decided 2026-09-11 by the platform
      security team.
  [... four more, each with its own recorded reason ...]

Exit code 2: one item is in REPORT. One item is still open on purpose -- CVE-2023-44487 has not been looked at yet, and the configuration says nothing about it, so it stays visible.

When you know before the catalogue does

A catalogue lags the world it describes. If your own telemetry, an incident, or a vendor advisory tells you a CVE is being exploited before CISA or the EUVD list it, a [[report]] entry says so and the item goes to REPORT with everything else. Two optional fields go with it:

[[report]]
component = "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.12.5"
cve = "CVE-2020-36518"
basis = "operator evidence"
aware = 2026-09-12
rationale = """
Exploitation attempts against customer deployments since 2026-09-12:
nested-object payloads against the public /ingest endpoint. No catalogue
lists this CVE. Decided 2026-09-13 by the platform security team.
"""

basis names what the entry rests on when it is not the catalogue -- "operator evidence", "vendor advisory" or "incident", and nothing else. It is required for exactly that case, a [[report]] on a CVE no catalogue in the run lists, and the run stops with a message rather than promoting the item without it. The brief's Signal line then reads not in the KEV catalogue - reported on the manufacturer's own evidence where a catalogue hit would read cisa_kev - in catalogue since 2021-12-10, the listed by column shows -, and the JSON carries basis beside an empty sources. A reader can see which claims rest on the catalogue and which on the manufacturer's own knowledge, which is the difference the tool would otherwise have flattened.

aware is the day you became aware, written as a bare date. It is recorded and printed and nothing else: no deadline, no countdown, no elapsed time. Article 14's 24 hours run from that day, and art14 says so without ever counting from it -- the point is that the date sits in a field instead of only inside the rationale, where nothing could read it.

Everything that leaves ASSESS leaves it on somebody's written word, and that word is printed under the table rather than folded into a count. It is the material a CSIRT or a market surveillance authority asks for.

Two runs against a real image

The walkthrough above is a fixture. These two are not: alpine:3.10, an image that went end-of-life in 2021, put through two real scanners. They are here because they demonstrate opposite halves of the same principle, and because one of them is what a wrong answer looks like.

Both SBOMs are committed, so both runs reproduce without a scanner or a daemon: art14 examples/alpine-3.10-grype.cdx.json and art14 examples/alpine-3.10-syft.cdx.json. The output is the same whether the document arrives down the pipe or by path, line for line. One exception, in the second block: the cache count reads 0 answered from cache until you have run it once.

A source that answered

$ grype alpine:3.10 -o cyclonedx-json | art14 -
art14 0.1.0 - CycloneDX 1.7 - alpine@3.10

input: 15 package components - 1 without PURL (7%) - 0 without version
       76 entries in the document; 61 are not packages (file) and are not graded
       SBOM quality: degraded - no matching ran here, see README
result: 0 to report - 0 to assess - 125 not in the KEV catalogue

1 of 15 components (7%) have no PURL or no version. This SBOM arrived with
its own vulnerability list, so nothing here was matched by art14 and that
number is not a coverage figure for this run.

It is a statement about the inventory: whatever produced the file may not
have identified those components either, and nothing in the file says
whether it did. Treat them as unverified rather than clean.

  components            15   (of 76 in the SBOM; 61 are not packages)
  vulnerabilities      125   (records carried by the SBOM, not matched here)
  component-CVE pairs  125   (one record can affect several components)
  CVEs checked          65   (distinct CVE ids put to the EUVD KEV catalogue)
  known exploited        0   (distinct CVE ids listed in the catalogue)

  REPORT                 0   (component-CVE pairs; the 24h clock is running)
  ASSESS                 0   (pairs in the KEV catalogue; needs a decision now)
  NO                   125   (pairs not in the KEV catalogue)

Grype carried 125 vulnerabilities over 65 distinct CVEs. Every one of them is real, and not one of them is in the EUVD KEV catalogue: nothing here triggers an Article 14 reporting obligation. Grype grades the same image 8 critical and 71 high.

Exit code 0: the source answered, the catalogue was asked, and nothing came back that has to be reported. That is the one code that makes a claim, and this run is entitled to it.

Both tools are right. That gap is the entire premise: severity ranks work, exploitation evidence decides what has to be reported, and the second question is not answered by turning up the first.

A source that said nothing

$ syft alpine:3.10 -o cyclonedx-json | art14 -
art14 0.1.0 - CycloneDX 1.7 - alpine@3.10

input: 15 package components - 1 without PURL (7%) - 0 without version
       76 entries in the document; 61 are not packages (file) and are not graded
       matching quality: degraded - see README
       source coverage: none - nothing came back for any of the 14 queried
result: 0 to report - 0 to assess - 0 not in the KEV catalogue

1 of 15 components could not be matched (7%): no PURL or no version. Those
components are unassessed, not clean.

WARNING: nothing came back for any of the 14 components queried. Their PURLs
are well formed and every lookup completed, so this is not a defect in the
SBOM: it is a question that went unanswered. A source that holds nothing for
an ecosystem and an ecosystem with nothing to report are indistinguishable
from here, and only one of them is a clean result.

This run will not certify that there is nothing to report. Absence of the
signal is not absence of the thing. If this is an OS image, a scanner that
carries its own vulnerability list will answer where OSV did not: `grype
<image> -o cyclonedx-json | art14 -`.

  components            15   (of 76 in the SBOM; 61 are not packages)
  vulnerabilities        0   (records matched against OSV.dev)
  component-CVE pairs    0   (one record can affect several components)
  components queried    14   (14 answered from cache)
  osv coverage           0   (of 14 queried came back with records)
                             pkg:apk/alpine   14 queried, 0 answered
  records fetched        0   (vulnerability records downloaded this run)
  CVEs checked           0   (distinct CVE ids put to the EUVD KEV catalogue)
  known exploited        0   (distinct CVE ids listed in the catalogue)

  REPORT                 0   (component-CVE pairs; the 24h clock is running)
  ASSESS                 0   (pairs in the KEV catalogue; needs a decision now)
  NO                     0   (pairs not in the KEV catalogue)

Same image, same 15 packages, and an empty result -- but not a clean one. Syft emits no vulnerabilities of its own, so art14 asked OSV. All fourteen pkg:apk/alpine/... PURLs are well formed, every lookup completed, and not one record came back. OSV holds Alpine advisories; that PURL form is not what reaches them.

An earlier build exited 0 here. Nothing was wrong with the SBOM, so the input grade had nothing to say, and a question nobody answered was reported as a negative answer -- on an image the run above finds 65 CVEs in. That is the exact failure this tool exists to prevent, and it took a real image to find it: no fixture would have been written with an ecosystem the source is silent on.

So silence is now its own axis. The run says what came back, by ecosystem, and refuses to certify an inventory the source answered nothing about: exit code 1, the same code as an unreachable catalogue and an unmatchable SBOM, because it is the same thing. A question went unanswered.

Against your own SBOM it is art14 your-sbom.cdx.json. If you have an image and no SBOM, grype <image> -o cyclonedx-json | art14 - makes one on the way through.

The report

--report PATH writes one self-contained HTML file, laid out for A4, for the reader who decides and will never open a terminal.

Page one of the report printed to A4: the product gateway@3.2.0, a provenance block naming the SBOM file, the CycloneDX version, the run timestamp, the tool version and the age of each cache, and then the funnel -- 36 components, 28 with records, 136 CVE ids, 5 known exploited and 1 to report

That is page one of five, from the same run as the decision record above:

art14 examples/log4j-app.cdx.json \
      --config examples/dispositions.toml --report report.html

The verdict, the coverage gate, the decision briefs and the recorded NO rulings follow on the pages after it.

The file is written alongside whatever else the run prints, and it is standalone in the literal sense: the stylesheet is inline, the funnel is inline SVG, there is no JavaScript and nothing is fetched at render time or at view time, so it opens from a memory stick on a machine with no network and looks the same in three years. It is made to be printed and attached to an email, and it carries the provenance block, the funnel, the bucket counts, the coverage gate above the findings rather than below them, and the full decision brief for every REPORT and ASSESS item. The NO bucket is one line there too.

It is built from the same document --json writes and from nothing else, which makes it a worked example of that schema for anyone building their own view. There is no severity chart, no top-N table, no health score and no remediation advice; severity appears once per brief, as context.

The dispositions as VEX

The decision brief ends by telling you to write the rationale down, because that is what a VEX statement is written from. --vex writes it:

art14 examples/log4j-app.cdx.json \
      --config examples/dispositions.toml --vex gateway.vex.json

A CycloneDX VEX document with one statement per decision this run produced. Three states, and they are the spec's own words rather than art14's:

REPORT                   exploitable, and the component version marked
                         affected in affects[].versions[]
ASSESS, still open       in_triage
[[no]] in --config       not_affected

One statement out of that file, with the long strings cut:

{
  "id": "CVE-2022-22965",
  "analysis": {
    "state": "not_affected",
    "justification": "requires_environment",
    "detail": "Precondition not met. Spring4Shell requires the ..."
  },
  "affects": [{ "ref": "pkg:maven/org.springframework.boot/..." }],
  "properties": [
    { "name": "art14:confirmedBy", "value": "examples/dispositions.toml" }
  ]
}

justification is an optional field on a [[no]] entry, taking one value from CycloneDX's own list: code_not_present, code_not_reachable, requires_configuration, requires_dependency, requires_environment, protected_by_compiler, protected_at_runtime, protected_at_perimeter, protected_by_mitigating_control. It is never inferred. A rationale reads to a person as one of those, and having art14 decide which and then publish that decision under your name is the one thing this tool does nowhere else. An entry without the field still exports as not_affected, with the rationale in analysis.detail and no justification -- which is what a determination resting on its words alone honestly looks like.

The rationale goes in verbatim. The justification is a value a machine can sort on; the sentence is what a CSIRT or a market surveillance authority actually asks for, and the export does not replace one with the other.

What the document deliberately does not say. A VEX consumer reads silence as "fine", so this one states its own scope in metadata.properties rather than leaving that to be inferred. Pairs the catalogue did not list are not in it: "not currently known to be exploited" is not a determination about whether your product is affected, and art14 has no grounds to make one. Neither are items suppressed by --adopt-upstream-vex -- those are somebody else's claim, already in the source SBOM under its author's name, and restating them here would republish them under yours.

Open ASSESS items are in it, as in_triage. Leaving them out would let a consumer read "not mentioned" as "clean", which is the one thing this tool never does.

Nothing in the document is a remediation. analysis.response is never written, and that is also why this is CycloneDX and not CSAF: the CSAF VEX profile requires an action statement -- mitigation, no_fix_planned, none_available, vendor_fix or workaround -- for every product listed as affected, which art14 has no basis for. It also requires publisher identity and tracking metadata that art14 would have to invent. CycloneDX requires bomFormat and specVersion, and every other field written here is one this tool actually knows.

Give the source SBOM a serialNumber if you can. With one, each statement points at its component by BOM-Link (urn:cdx:<uuid>/<version>#<bom-ref>), which a consumer can resolve without being handed the SBOM; without one the statement carries the bare bom-ref and the document names the file path instead. The document is a pure function of the run's JSON: no uuid is minted and no clock is read while writing it, so the only field that moves between two runs over unchanged evidence is the run's own timestamp, and the file diffs cleanly when you commit it.

If the run could not establish that there was nothing further to report -- no catalogue, or an inventory too thin to match against -- the document says so in a property of its own and quotes the run's verdict. A short document from such a run is a coverage failure, and the file has to say that where a machine reading it will see it.

How it decides

Three stages, and only the third is interesting:

  1. Parse the CycloneDX document: components, versions, PURLs, and whether each component is a direct or a transitive dependency.
  2. Match components to vulnerabilities with OSV.dev. Skipped entirely when the SBOM already carries its own vulnerabilities array, as grype, Dependency-Track and cdxgen emit.
  3. Judge: every CVE is put to the EUVD catalogue of known exploited vulnerabilities, and the answer decides the bucket.
REPORT   the vulnerable functionality is present and reachable in this product
ASSESS   in the exploited-vulnerability catalogue, and nobody has decided yet
NO       not in the catalogue, or decided and ruled out

Nothing promotes itself to REPORT. A KEV entry means the CVE is exploited somewhere in the world; it says nothing about whether the vulnerable path is live in your product, and art14 will not guess. The only thing that moves an item into REPORT is an entry in --config naming the component and the CVE, with the reason you concluded it is reachable. There is no auto-discovery of that file either: the command line always records what a verdict rested on.

Being in a catalogue is not the trigger. Article 3(42) defines an actively exploited vulnerability as one for which there is reliable evidence that a malicious actor has exploited it in a system without permission of the system owner, and Recital 68 puts good-faith testing, investigation, correction and disclosure outside that. A catalogue is a record that somebody found such evidence. It is not the definition, and it is neither necessary nor sufficient: exploitation seen only against your own customers is in no catalogue at all, and a listed CVE in a component whose vulnerable path your product never reaches is a catalogue hit with nothing behind it. Which is why the third stage ends in a question rather than a verdict.

Severity is deliberately not in the table. The bucket is the verdict, and a severity word beside it invites the reader to treat the two as views of the same thing. Severity is in --brief and --json, where a reader has already stopped skimming.

--brief prints the full decision brief for every open item:

[ASSESS] CVE-2021-44228 - EUVD-2021-34768 - log4j-core@2.14.1

  Where      direct dependency
  What       CWE-20, CWE-400, CWE-502, CWE-917 - Remote code injection in
             Log4j
  Signal     cisa_kev - in catalogue since 2021-12-10
  Question   gateway@3.2.0 depends on log4j-core@2.14.1 directly. Does any
             code path in the product pass untrusted input to it, and is that
             input validated before it reaches the component?

  -> REPORT  if yes, or if it cannot be ruled out. Record it as a [[report]]
             entry for log4j-core@2.14.1, with the reason it is reachable.
  -> NO      if the component is present but the vulnerable path is never
             reached. Record it as a [[no]] entry for log4j-core@2.14.1, with
             the reason it is not: that is your VEX entry and your audit
             trail.

  context    critical - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H/E:H

That brief asks a question instead of starting a clock, which is the order the regulation puts them in. The 24 hours in Article 14(2) run from when the manufacturer became aware, and the Commission's guidance on the CRA (C(2026) 5252 of 27 July 2026, paragraph 213) reads becoming aware as the point at which, after an initial assessment, there is a reasonable degree of certainty that a vulnerability in the product is being actively exploited. A catalogue hit is the suspicious event that assessment starts from. The same guidance is explicit that nothing is retroactive (paragraph 217): exploitation a manufacturer already knew of before 11 September 2026 is not notifiable. It is also explicit that it binds nobody (paragraph 8) and that only the Court of Justice can settle the question -- the same instruction as the one at the top of this page, pointed at a different document.

Exit codes

2   at least one REPORT item
1   an open ASSESS item, an input that cannot rule out, or an error
0   nothing to report

In that order of precedence. A REPORT item always wins: poor SBOM coverage undermines negative claims, not positive ones.

Exit 0 asserts something, which is why it is the hardest one to get. It is emitted only when nothing is left open and the run can stand behind the sentence "there is nothing here to report". An SBOM that could not be matched, one that was never matched, or a run whose catalogue could not be fetched exits 1 with the reason on stdout.

The table above gives exit 1 three causes. For a machine reading the output there are two cases, and under --json stdout is what tells them apart. A run that got to an answer writes the whole document to stdout -- items left open, an input it could not match, a catalogue it could not fetch, a confirmation that matched nothing, each with its reason in the document. A run that died writes nothing at all to stdout and puts its diagnostics on stderr. There is no message to parse: JSON on stdout means the run reached a verdict, whether or not it would certify it, and an empty stdout means it never got that far.

One thing to hold on to when wiring this into CI: a bucket is a statement about the day it was made. The catalogue changes, and a CVE that is not in it today can be in it next week with no change to your SBOM at all. Today's NO is not a permanent NO, and a green pipeline is not a standing clearance.

Matching quality

The banner grades the input before any verdict is printed, and points here:

input: 36 components - 0 without PURL (0%) - 0 without version
       matching quality: ok - see README

It answers two questions at once, on one line, because two parallel warning paths can contradict each other and a reader only has to believe the reassuring one.

Did we look at all? Matching ran, or the SBOM arrived with vulnerabilities attached, and the catalogue could be consulted. A catalogue that could not be fetched is not an empty catalogue -- every CVE would come back "not listed" -- so a run without one fails closed rather than certifying an inventory as unexploited.

Could we have found anything if we had? That is the grade:

ok          every component carries a PURL and a version, and every lookup
            completed. An empty result means something.
degraded    some components could not be matched: no PURL, no version, or a
            lookup that did not complete. They are unassessed, not clean. The
            banner says how many; `--json` names every one of them and why.
unusable    more than half the inventory is in that position. The result table
            is withheld unless something is in REPORT or ASSESS, because a
            table listing only the well-formed minority reads as complete and
            is a false negative.

What gets graded

The grade is computed over the packages in the document, not over everything the document lists. syft alpine:3.10 -o cyclonedx-json emits 76 components for an image of 14 packages: one file entry per file it walked. Those entries are not packages, nothing can be matched against them, and counting them reports an 82% unmatchable rate against an inventory that is in fact fully identifiable -- and then withholds the result on the strength of it.

So component.type decides. Four types are not packages and are not graded: file, machine-learning-model, data and cryptographic-asset. Everything else is inventory, including a type this build has never seen. The rule runs that way round deliberately: excluding a real package under-reports in silence, while keeping something package-shaped that is not costs one unmatchable entry and a worse grade, which a reader can see.

Nothing is set aside quietly. Where the two numbers differ, the banner says so and names the rule, and the funnel underneath counts the same inventory:

input: 15 package components - 1 without PURL (7%) - 0 without version
       76 entries in the document; 61 are not packages (file) and are not graded
       SBOM quality: degraded - no matching ran here, see README

  components            15   (of 76 in the SBOM; 61 are not packages)

A document whose entries are all non-packages is not an SBOM with nothing to report. It is an input that cannot be assessed: it grades unusable, says so in those words, and does not exit 0.

Did the source answer? The third axis, and separate from the grade on purpose. A missing PURL is a defect in the SBOM and the fix is to produce a better one. A well formed PURL that OSV returns nothing for is a defect nowhere: the identifier is right, the lookup completed, and the source simply said nothing. Collapsing the second into degraded would name the wrong culprit, and it would let a run whose every answer was silence certify that there is nothing to report.

       source coverage: none - nothing came back for any of the 14 queried

  osv coverage           0   (of 14 queried came back with records)
                             pkg:apk/alpine   14 queried, 0 answered

Answer rates are grouped by PURL type, because a total hides the case that matters: pkg:apk answering nothing while pkg:maven answers is the same blind spot over a smaller part of the product. Any silent group is named whether or not it changes the exit code. If every group is silent, the run cannot support a negative claim and exits 1.

There is no list of ecosystems art14 believes OSV serves. That would be a second database, it would go stale in silence, and this tool owns none. What it reports is what happened on this run: this many asked, this many answered.

The grade is named after what it graded. On an SBOM that arrived with its own vulnerability list, no matching of ours ran, so the same number prints as SBOM quality instead: it still says how identifiable the inventory is, without claiming a coverage figure for a stage that never happened.

Neither axis can suppress a REPORT item.

Other ways to run it

--brief                 the full decision brief for every REPORT and ASSESS item
--json                  machine readable, for CI and downstream processing
--report PATH           an HTML report to print and file -- see The report
--vex PATH              this run's dispositions as a CycloneDX VEX document
--config PATH           the TOML file of recorded decisions
--adopt-upstream-vex    honour the SBOM's own not_affected claims
--offline               cache only, never open a connection

--json carries every field the brief carries, so a consumer never has to reproduce the wording and drift from it, plus a provenance block saying which sources were consulted, when, and whether from cache or from the network.

--adopt-upstream-vex is off by default. Adopting an SBOM's own not_affected claims is a statement that you trust whoever produced it, so it is a decision made on the command line, and every suppression it causes is printed with its justification and its author.

Image workflows are served by composition rather than by absorbing a scanner:

syft ghcr.io/acme/gateway:1.4 -o cyclonedx-json | art14 -
grype ghcr.io/acme/gateway:1.4 -o cyclonedx-json | art14 -

Installing it

The launchers put nothing in your Python. They try, in order, a .venv/ next to the script if an earlier run built one, an art14 you have already installed yourself, uv if it is on PATH, and failing all of those a fresh .venv/ built in place. Delete .venv/ to start over.

If you would rather install it:

pip install art14              # from PyPI
pip install -e .               # from a clone of this repository

python -m art14 examples/log4j-app.cdx.json works too, and is the fallback when a console script is awkward to reach.

Python 3.11 or later. The only dependencies are httpx and rich.

The first run needs the network: OSV.dev for matching, the EUVD KEV catalogue for the verdict. Both are public and neither needs a key. Both are cached on disk, so later runs and --offline work without one. The cache lives in ~/.cache/art14 (%LOCALAPPDATA%\art14\cache on Windows), and ART14_CACHE_DIR overrides it, which is what a CI cache restore points at.

Limitations

Stated openly, because a tool in this position that implies completeness is worse than no tool.

  • This does not replace a legal assessment of scope. art14 identifies candidates from technical evidence. Whether an obligation applies to your product, and what it requires of you, is not a question this tool answers.
  • art14 holds no list of the ecosystems OSV.dev answers on. A list like that is a second database and goes stale in silence. What gets reported is the answer rate this run came back with, per PURL type.
  • A source can be silent on an ecosystem, and that is not a clean result. art14 names the silence and will not certify a run nothing answered, but it cannot tell you what a source covers -- only what came back. Measured, with the output, in A source that said nothing.
  • The EUVD exploited list is close to CISA KEV plus a small EU margin. On 2026-09-12 the dump this tool reads held 1721 records, of which 1710 carry CISA's tag and 11 the EU's alone. CISA's own catalogue that day held 1709 entries, every one of them already in the dump. VulnCheck found the EU list a strict subset of CISA KEV in May 2025; the margin has appeared since, and it is small. The list is public and takes no key, which is why it is the source here. A CVE absent from it may still be exploited in the wild: VulnCheck KEV says it carries roughly 80% more CVEs exploited in the wild than any other public catalogue, and it is free to registered community members - but it takes an account and an API key, and this tool asks for neither by design. Making the exploitation source pluggable is the obvious extension, and it would need that key, which v0.1 does not ask for. The difference is a rate, not a backlog: VulnCheck counted 768 CVEs first publicly reported as exploited during 2024, against the 186 entries CISA KEV carries with a 2024 date. That is more exploitation research, not a queue waiting to drain into CISA. Absence of the signal is not absence of the thing.
  • Every result is a point-in-time snapshot. The catalogue changes daily.
  • Unmatched components are reported, never treated as clean. So are vulnerabilities with no CVE id: they are counted apart and never fall into NO, because the catalogue is keyed on CVE and the question was never put.
  • Affectedness is OSV's judgement, not art14's. Version range arithmetic happens upstream, and this tool does not second-guess it. Affected components an SBOM names but does not contain are reported rather than dropped.
  • CycloneDX only. 1.5, 1.6 and 1.7 are read and tested. A later 1.x is parsed with a stated caveat rather than refused, because a scanner piping into this tool upgrades on its own schedule. 2.x is refused. SPDX is not supported, and not because nobody got to it: it carries PURLs optionally through externalRefs and expresses structure as RELATIONSHIP_DEPENDS_ON rather than as a dependency graph, so the direct versus transitive distinction that every decision brief turns on would be separate logic with its own failure modes rather than a second parser. SPDX also dominates firmware and OS-image SBOMs, which is exactly where the Alpine run above shows OSV answering nothing, so support would most often produce a degraded grade rather than an answer. Worth doing when the matching side can serve those SBOMs; not worth doing first.
  • A [[report]] or [[no]] entry is somebody's word. art14 records it, names the file it came from, prints it in full, and does not check it. The same goes for an upstream not_affected claim adopted with --adopt-upstream-vex, which is additionally printed with its author. A [[report]] resting on basis rather than on a catalogue listing is unverified in exactly the same way, and is labelled as such everywhere it appears rather than being folded in beside a catalogue hit.

What this is not

Not a scanner, not an SCA platform, not a vulnerability database. It reads no images, no containers and no filesystems, and it keeps no state beyond an HTTP cache. Scanning is a solved problem with good tools; this one starts where they stop.

There are no notification templates here either. What to send, to whom, and when is not a thing to template out of a repository.

Other CRA tooling bundles the scanner, or files the report for you. This does neither on purpose: the scan belongs to the tools that already do it well, and the filing belongs to whoever has to sign it.

Tests

pip install -e ".[dev]"
pytest

The suite never opens a connection: the OSV and KEV fixtures are pinned.

Author

Davor Dodevski - LinkedIn

Twenty years in engineering and security, including end-to-end CVE triage ownership for Cisco Webex Meeting Server: thousands of vulnerabilities, SBOM-based dependency analysis, and the escalate-or-not decision this tool is built around.

Licence

MIT. See LICENSE.

Release files for art14 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for art14 0.1.0
File Size Uploaded
art14-0.1.0.tar.gz 203.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for art14 0.1.0
File Interpreter ABI Platform
art14-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 325.4 kB

Release files / art14-0.1.0.tar.gz

Download URL art14-0.1.0.tar.gz
Size 203.8 kB
Tags Source
SHA-256 checksum
How to use checksums
a31c6a94205830efcb42c5fa75a8c44fe705f5780a5b597a4b5bd2901e3a4289
BLAKE2b-256 checksum
How to use checksums
34d88bc1b121dbf110fcf229cb48cf891710f0524a4934af46b3d7518733e2ee
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.

Transparency log

Release files / art14-0.1.0-py3-none-any.whl

Download URL art14-0.1.0-py3-none-any.whl
Size 121.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ef4333bb14ad8486871d9716d42cd3ff4023d1a9a5e4aea7182e8373dc4b2d67
BLAKE2b-256 checksum
How to use checksums
51c83ae0b25a07bd43579005267dc76121c093b82914f8602621df7c08135f7c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page