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

/api/stats/vulnerability/counts

/api/euvd/stats/count

Lifecycle status

/api/stats/vulnerability/status

/api/euvd/stats/status

CVSS version distribution

/api/stats/cvss/versions

/api/euvd/stats/cvss/versions

Severity bands

/api/stats/cvss/scores

/api/euvd/stats/cvss/scores

Known-exploited inclusion

/api/stats/kev

/api/euvd/stats/kev

Time to exploit

/api/stats/time_to_exploit

/api/euvd/stats/time_to_exploit

Top assigners

/api/stats/assigners/ranking

/api/euvd/stats/assigners/ranking

Top vendors

/api/stats/vendors/ranking

/api/euvd/stats/vendors/ranking

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:

total_basis

counted from

meaning

state_published

index:<source>:state:published

currently published

date_published

index:<source>:published

carries a publication date

held

index:<source>

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.