Statistics#
Vulnerability-Lookup answers eight questions about a corpus: how many records it holds per period, how they split by lifecycle state, how severity and CVSS version are distributed, how much of it is known to be exploited, how long that took, and which assigners and vendors it is dominated by.
Every one of them is asked per source, so ?source=cvelistv5 and ?source=euvd
are the same question about two corpora rather than two different figures. Six of
them accept any source the instance holds records of; the two rankings accept a fixed
list of four, given with each of them below. All eight can be narrowed to one period,
so a page offering a year selector can apply it to every figure it shows.
This page is about what the answers mean. The request and response shapes are in the
interactive Swagger UI at /api/ of any instance, the keys behind them are in
Architecture, and the passes that prepare them are in
Prepared statistics figures.
One implementation, two namespaces#
Figure |
Any source |
EUVD |
|---|---|---|
Records per period |
|
|
Lifecycle status |
|
|
CVSS version distribution |
|
|
Severity bands |
|
|
Known-exploited inclusion |
|
|
Time to exploit |
|
|
Top assigners |
|
|
Top vendors |
|
|
The generic namespace takes ?source= and defaults to cvelistv5. The EUVD namespace
pins source=euvd, takes no source parameter, and implements nothing of its own: it
calls the same readers, behind the same cache window (STATS_CACHE_TIMEOUT, one hour
by default). It exists so a page has one stable set of URLs to hold. Because there is
one implementation of each figure, the two surfaces cannot report a number
differently.
/api/stats/vulnerability/count is the older single-period form and returns a bare
number for one year or month. Prefer /counts, which returns the whole series in one
request: twenty years of a chart is otherwise twenty round trips against counters that
are a single MGET.
Reading an answer#
available: false is not zero. A figure whose preparation has never run says so
instead of reporting empty buckets. The distinction is the point: a severity chart of
five empty bars claims the source holds nothing severe, while “no data” is the truth
about an instance that has not run the pass. Most sources prepare only some of the
eight, so this is the normal answer for a figure a given source does not maintain.
The two rankings are the exception. They predate this convention and answer an unprepared leaderboard with an empty list, so an empty ranking is not “no vendors”: it usually means the pass has not run for that source or that period.
Most figures are as old as the last pass, not as old as the request. Nothing here
walks the corpus while a visitor waits. Lifecycle status is read live from the
indexes. The known-exploited cardinalities are live for EUVD, which maintains its
membership sets during ingestion, and as old as the last euvd --stats-scan for every
other source. The counters, both CVSS distributions, the two rankings and the
time-to-exploit summary come from what a pass wrote.
time_to_exploit carries a computed_at timestamp for exactly this reason.
A year means a publication date, never the year embedded in an identifier. The two differ for anything published across a year boundary.
Every figure takes a period, so one selected year can hold across a whole page.
Seven take period=YYYY (or granularity=month&period=YYYY where a series is
returned); known-exploited inclusion takes granularity=year|month instead, because
restricting a membership count to a year is not meaningful – see that figure below.
A period sent where it cannot be honoured is refused with a 400 rather than ignored:
answering for the whole corpus under a 200 is the one response a caller cannot tell
apart from an answer.
The one figure not bucketed by publication date is known-exploited inclusion per
period, which buckets on the date a catalogue listed the record. It says so in the
body, as basis.
The eight figures#
Records per period#
GET /api/stats/vulnerability/counts?source=<name>[&granularity=month&period=YYYY]
Records of the source per publication year, or per month within one year. The years covered are derived from the source’s own published index, so a source holding only recent records does not report two decades of zeros.
state=reserved reads the reservation counters instead, keyed by reservation date.
The year axis still comes from the published index, so the two series of one source
line up.
period selects the year to break into months and is refused with a 400 when sent
without granularity=month, rather than ignored: a caller who sent one asked for
something, and the whole corpus returned as though it were one year is the single
answer they cannot tell apart from a correct one.
Lifecycle status#
GET /api/stats/vulnerability/status?source=<name>[&period=YYYY]
How many records the source holds in total, how many carry a publication date, and how
many carry a reservation date. With period, the same two counts for one calendar
year, taken with a ZCOUNT over the scores of the very indexes the whole-source figure
takes the cardinality of, so a year and the whole source cannot disagree.
The states are not a partition and do not sum to the total. A record can carry both dates, and a source may hold records carrying neither. Charting them as slices of one pie is the easier presentation and the wrong one.
Two senses of “published”, and the response carries both. published and reserved
count records carrying a date of that kind. That is what every other figure here is
grouped by, and what makes a per-year count a historical statement: a CVE published in
2017 and withdrawn in 2020 belongs to 2017’s publication count and keeps its publication
date forever.
The state object reports the current lifecycle value instead, read from the indexes
the feeders maintain as records change:
{
"source": "cvelistv5",
"total": 400321,
"published": 394048,
"reserved": 400321,
"state": { "published": 381920, "reserved": 936, "rejected": 17471 }
}
The two differ by the withdrawals, roughly 12,000 records on the CVE list. Ask for
state.published when the question is “what does this source publish today”, and for
published when it is “how many were published”.
state is absent, rather than three zeros, for a source that keeps no lifecycle index,
the same distinction the prepared figures draw with available: false. EUVD is such a
source today.
reserved is “carries a reservation date”, not “awaiting publication”. On the CVE
list every record carries a dateReserved, published ones included, so reserved
equals total there and the pair reads alarmingly if taken as a lifecycle split. The
number awaiting publication is state.reserved.
total and state are absent with a period. The base index is scored by a
record’s last update, so counting a year of it would answer “records touched in 2026”
under the label the rest of the page uses for “records of 2026”. The honest total for a
year is the union of the two date indexes, which is a corpus walk rather than a count,
so it is not answered rather than answered wrongly. state is a current value, which a
past year does not have.
CVSS version distribution#
GET /api/stats/cvss/versions?source=<name>[&period=YYYY]
Which CVSS version the source’s records are scored under, bucketed as 4.0, 3.1,
3.0, 2.0, other and unscored, so the axis is the same six values whatever the
corpus holds.
Severity bands#
GET /api/stats/cvss/scores?source=<name>[&period=YYYY]
The CVSS v3.1 qualitative ratings (none, low, medium, high, critical) applied
to whichever version a record is scored under, plus an explicit unscored bucket. A
v4.0 and a v3.1 record with the same base score land in the same band, which is what a
severity mix means.
unscored is a real bucket rather than an omission: how much of a corpus carries no
usable metric is part of the answer, and on the CVE list it is the largest bucket.
The band comes from the record’s own metrics with the highest CVSS version winning,
which is the score its detail page displays. It is deliberately not derived from
index:<source>:cvss, which carries the maximum across a record’s metrics and is
absent for sources whose pass does not write it. A band computed the other way would
put a record in a severity its own page contradicts.
Both distributions cover published records only, and exclude withdrawn ones. A
reserved record has no score yet; a withdrawn one has had its metrics stripped by the CVE
Programme and can never carry one, so counting either would report a lifecycle state as a
severity. On the CVE list that is roughly 12,000 records, 6.6% of the unscored bucket.
One consequence: the per-year band counters no longer sum to
published:count:<source>:<year>, by design. The counters count what was published; the
distributions count what is published and can carry a severity.
Known-exploited inclusion#
GET /api/stats/kev?source=<name>
GET /api/stats/kev?source=<name>&granularity=year
GET /api/stats/kev?source=<name>&granularity=month&period=YYYY
Without granularity, how many of the source’s records appear in a known-exploited
catalogue, as a count, a ratio of the corpus, and a count per configured catalogue.
With one, how many records each catalogue listed per period. The two forms are told
apart by basis, which is membership for the first and listed_at for the second.
There is no period=YYYY form of the membership count, and that is deliberate: the
membership sets are sets, carrying no date, so “how much of this source was exploited
in 2025” can only be answered as “how much did each catalogue list in 2025”, which is
the period form. A bare period is therefore refused.
The per-catalogue counts overlap and do not partition exploited. A record listed
by two catalogues is counted by both, because that is what each catalogue asserts about
it, so charting them as slices of the total overshoots. They are independent
memberships.
Every configured catalogue is listed, including those matching no record: a catalogue
missing from the response would read as “not configured” rather than “nothing of ours
is in it”. unattributed counts exploited records whose catalogue this instance cannot
name, and a non-zero value means the local catalogue reference data is absent or stale.
A source can be scoped to some catalogues. STATS_KEV_CATALOGS in
config/website.py maps a source to the catalogue short names its figure covers. For
such a source scoped is true, catalogs lists exactly those catalogues, and
exploited is the union of their membership sets: still not the sum of the split, since
a record two of them list is one exploited record. A record listed only by a catalogue
outside the scope is not part of the figure. unattributed keeps its own meaning there,
counting exploited records that belong to no catalogue this instance can name, so
missing or stale reference data still shows as a number and a warning rather than as a
figure that quietly shrinks. EUVD is scoped by
default to the two catalogues it publishes, CISA KEV and EU KEV ({"euvd": ["CISA", "ENISA"]}); setting the option replaces that default, and every source it leaves out is
reported over all catalogues.
The same scope governs the two dated figures, the per-period series below and time
to exploit, and there it is applied when the scan runs rather than when the figure is
read: “the first listing among CISA and EU KEV” cannot be recovered from “the first
listing among all five”. A change of scope therefore reaches the membership counts at
once and the dated figures at the next euvd --stats-scan.
EUVD maintains its membership sets as a by-product of ingestion. Every other source
gets them rebuilt by euvd --stats-scan --stats-source <name>, which matches KEV
entries against the records the source holds, so the figure is meaningful for CVE-keyed
sources.
The denominator is the narrowest population the source can describe, because a share
measured against a wider one is a share of records that cannot be exploited as described.
Three tiers, and total_basis in the response names the one used:
|
counted from |
meaning |
|---|---|---|
|
|
currently published |
|
|
carries a publication date |
|
|
every record held |
The numerator is narrowed to the same population, so exploited is always a subset of
total and the ratio cannot exceed 1. Without that a withdrawn CVE still listed in a
catalogue counts in one and not the other, and a small source can report 4 of 3.
Per period#
The buckets are the date a catalogue listed the record, not the record’s publication
date. A chart of known-exploited growth is about when each catalogue acted, and a
catalogue routinely lists a CVE published years earlier; bucketing by publication year
would scatter one year of catalogue activity across two decades of bars. The date is
VL’s single “exploited since” rule: timestamps.first_seen_at, then recorded_at, then
the required status_updated_at. Not asserted_at, which records when an origin made
its claim rather than when the vulnerability was first seen exploited, and which would
give one record two “exploited since” dates – the same rule already orders the KEV
timeline, the lead-time views, EUVD’s exploitedSince and time to exploit.
catalogs becomes {catalogue: {period: count}} and keeps the overlap caveat
exactly: a record two catalogues list is counted in both series. totals counts each
record once, in the period the first catalogue listed it, so it reads as “became known
exploited” and summing it over the periods gives the exploited population back instead
of overshooting it. unattributed_periods carries the same series for the records whose
catalogue this instance cannot name, under its own name so that unattributed stays a
count in the membership form and every field keeps one type in the published schema.
Every series is reported over one axis – a contiguous range of years ending at the current one, or the twelve months of the requested year – so the catalogues and the totals can be plotted together. A year with no listings is a real zero in a growth series, and a configured catalogue that listed nothing is a row of zeros rather than an omission.
A year is the sum of its months because each record lands in exactly one month of each
series, which is why only the months are stored. available: false means the pass has
never run for this source: the membership sets and these buckets are written by
different passes, so a source can have one and not the other.
Time to exploit#
GET /api/stats/time_to_exploit?source=<name>[&period=YYYY | &granularity=year]
Days between a record being published and its first appearance in a known-exploited
catalogue, as a mean, a median, a 90th percentile, and the extremes, each to one decimal
place. With period, the same summary over the records the source published that
year, read from its own stored snapshot rather than derived: a median and a p90 cannot
be recovered from the whole-source figures afterwards. With granularity=year, every
publication year the source spans in one response, under years, each with its own
summary and count; a year whose records were never exploited is {"count": 0} rather
than missing, so a chart draws a zero rather than a gap. The two parameters are not
combined.
Earliest listing, among the catalogues the figure covers. Where several catalogues
list a vulnerability the interval runs to the first of them. For a scoped source (see
A source can be scoped above) only its catalogues are read, so the interval never ends
at a listing the panel says it does not count; the response then carries scoped: true
and catalogs, recorded by the scan that measured it rather than read from the current
configuration.
The denominator is on the wire, because it is not the corpus. The figure rests on
records that were ever exploited, a small fraction of any source, so the response
carries count beside cves_exploited and entries_scanned, and reports the median
next to the mean: one 2007 CVE listed in 2024 moves a mean by thousands of days and
leaves a median untouched.
Intervals can legitimately be negative, when a catalogue listed a vulnerability before
the record was published. They stay in the distribution and are counted separately as
exploited_before_publication rather than clamped away, so min_days below zero is an
expected reading and not a defect. before_publication_mean_days is their own mean,
present only when there are some, so whether they belong in a headline figure can be
judged from their number and how far ahead of publication they sit.
unmatched_no_record and unmatched_no_publication_date report entries the scan could
not date, rather than dropping them. They are corpus-wide and are absent from a period
summary rather than repeated into it: a CVE that resolves to no record has no
publication year, so a per-year copy would be the whole source’s shortfall presented as
that year’s.
A period the scan covered but in which nothing was exploited answers available: true
with count: 0, not available: false. The pass ran; “none of that year’s records were
ever listed” is an answer, and hiding it would be the opposite of what the available
flag is for.
Top assigners#
GET /api/stats/assigners/ranking?source=<name>&limit=10[&period=YYYY]
The assigners (CNAs) that account for most of the source, whole or for one publication period. Capped at ten.
source is one of cvelistv5, certfr_avis, certfr_alerte and euvd, and defaults
to cvelistv5. Any other value is refused with a 400, including sources the other six
figures accept.
For EUVD the assigner is taken from the linked CVE, never from the EUVD record’s own assigner field, which names the organisation that allocated the identifier and is the same for every record an instance mints.
Top vendors#
GET /api/stats/vendors/ranking?source=<name>&limit=10[&period=YYYY]
The vendors most affected, whole or for one publication period. Capped at ten. Vendors, like the assigner and the CVSS metrics, come from the linked CVE for sources whose records are aliases rather than advisories of their own.
source takes the same four values as the assigner ranking.
Two pairs that are easy to confuse#
Known exploited is not the sightings ratio. /api/stats/kev counts membership of a
known-exploited catalogue. /api/stats/sighting/exploitation_ratio reports the share of
records with an exploitation sighting, which is a community observation. Both figures
are legitimate and neither may be labelled as the other.
An EUVD corpus is not a second population. An EUVD record is normally an alias of a CVE, so the EUVD figures largely count the same vulnerabilities under a different identity. The per-source keys exist precisely so the two are never added together.
Preparing the figures#
Two idempotent commands, documented in full under Prepared statistics figures:
# counts per period, the two rankings and both CVSS distributions
$ poetry run index_vulnerabilities --source cvelistv5
# time to exploit (whole and per publication year), the per-period known-exploited
# counters, and the known-exploited sets for non-EUVD sources
$ poetry run euvd --stats-scan --stats-source cvelistv5
Run the reindex at least once before trusting any counter on a new, restored or upgraded instance. VL’s derived indexes are rebuilt from the stored records, and a stale index makes a status figure report a fraction of reality while looking entirely normal.
The assigner scorecard – how well each CNA and GNA fills the records it publishes – is a prepared figure too, with its own page: see Assigner scorecard. So is the catalogue scorecard, which ranks the known-exploited-vulnerability catalogues by how well they back, situate, lead and maintain their assertions.
The small, stable figures are also exported from /metrics when that endpoint is
enabled, where they acquire a history: see Metrics. The two rankings are
deliberately excluded from the scrape, since a leaderboard mints a new series every
time its membership changes.