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
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
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.
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:
- Parse the CycloneDX document: components, versions, PURLs, and whether each component is a direct or a transitive dependency.
- Match components to vulnerabilities with OSV.dev. Skipped entirely when
the SBOM already carries its own
vulnerabilitiesarray, as grype, Dependency-Track and cdxgen emit. - 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
externalRefsand expresses structure asRELATIONSHIP_DEPENDS_ONrather 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 upstreamnot_affectedclaim adopted with--adopt-upstream-vex, which is additionally printed with its author. A[[report]]resting onbasisrather 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)
| File | Size | Uploaded | |
|---|---|---|---|
| art14-0.1.0.tar.gz | 203.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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