Vulnerability timeline durations#

The Timeline tab on a vulnerability page combines dated fields from the vulnerability record and linked records, sightings under its identifiers and aliases, and KEV entries listing the identifier or an alternate identifier. The duration cards use this complete evidence set. Filtering, ordering or paging the event list does not change the calculations.

The page and GET /api/vulnerability/<id>/timeline share the server-side builder in website/lib/vulnerability_timeline.py. The JSON response keeps metadata and data, and adds metrics. No background reindex or schema migration is needed. Both endpoints cache responses for 60 seconds; open intervals are measured at the response’s metrics.as_of, and refresh when the timeline is loaded again after the cache expires.

Calculation mechanism#

Select dated anchors from the evidence available on this instance. The names follow the Vulnerability Timeline and Exposure Metrics framework (VTEM, see below) where the evidence has an equivalent:

Symbol

Anchor / metrics.anchors key

Selection

V

Discovery / discovery

Earliest recorded discovery.

R

Report / report

Earliest report of the vulnerability to the vendor or to a coordinator (VTEM’s “vendor informed”).

A

Acknowledgement / acknowledgement

Earliest acknowledgement of the report.

O

Starting date / origin

Earliest of V, R and D.

D

Public disclosure / disclosure

Earliest public disclosure or publication of the vulnerability or an advisory.

X

Public exploit / exploit_publication

Earliest published proof of concept (VTEM’s “exploit published”).

E

Exploitation / exploitation

Earliest exploitation evidence: a recorded observation, an exploited sighting, an eligible KEV first-seen date or KEV report.

P

Patch / patch

Earliest patch evidence: a recorded fix available to users or a patched sighting.

N

Calculation time

Current UTC time, exported as metrics.as_of.

“Earliest” is chosen across every applicable record, source and evidence basis. Each input is an upper bound on the moment it documents: a KEV listing, a sighting report or even a vendor’s stated fix date can only be at or after the actual first exploitation or patch. The earliest input is therefore the tightest bound, whichever basis it has; a later observation never overrides an earlier report. The origin follows the same rule over discovery, report and disclosure: a public disclosure also bounds when the vulnerability was known, so a discovery or report date after it (a distributor recording when it learned of a public vulnerability) is not the start of the lifecycle. At the same instant an observation (recorded_event, kev_first_seen) is preferred to a report; remaining ties are resolved by source and field path. Selected anchors retain the full event, including source, record, field, original date, precision, evidence basis and sighting/KEV UUID, so a figure can always be traced to the record that produced it.

Metric / JSON key

Formula

Interpretation

Vendor response time / vendor_response_time

A − R

VTEM: how long the vendor took to acknowledge the report.

Time to patch / time_to_patch

P − R

VTEM: from the vendor (or a coordinator) being informed to patch evidence.

Total time to patch / total_time_to_patch

P − V

VTEM: from discovery to patch evidence.

Disclosure to patch / disclosure_to_patch

P − D

From public disclosure to patch evidence: the classic “days of risk”. Negative when the fix shipped with or before the disclosure.

Time unpatched / time_unpatched

max(0, (P if known, otherwise N) − O)

Recorded waiting interval to a patch, or an open interval when no patch is recorded.

Time to exploit / time_to_exploit

E − O

Time from the earliest discovery, report or disclosure to the first recorded exploitation; the card says when disclosure is the starting date.

Disclosure to exploit / disclosure_to_exploit

E − D

Public warning time before exploitation. A negative value means exploitation preceded disclosure.

Exploitation to patch / exploitation_to_patch

(P if known, otherwise N) − E

Positive: patch evidence followed exploitation. Negative: patch evidence came first, giving defenders that much lead time.

Disclosure to public exploit / disclosure_to_exploit_publication

X − D

From public disclosure to the first published proof of concept.

Public exploit to patch / exploit_publication_to_patch

(P if known, otherwise N) − X

The gap between exploit and patch availability (Frei et al.). Negative: the patch came first.

Each metric has exactly one start and one end; only the origin-based ones (time_to_exploit, time_unpatched) can start at disclosure, and say so. time_to_patch, total_time_to_patch and disclosure_to_patch are deliberately separate rather than one metric with fallbacks: they answer different questions (vendor speed once informed, the whole lifecycle, public exposure), and mixing them would make the figures incomparable.

exploitation_to_patch directly answers whether exploitation came before patching and by how much. When V is the origin, total_time_to_patch − time_to_exploit equals exploitation_to_patch for a known P. Its positive portion is the observed exploitation-before-patch window. A negative result is retained rather than converted to an absolute duration that would hide event order. A published proof of concept is not exploitation: X and E are separate anchors, and neither substitutes for the other.

When P is missing, the patch metrics are unavailable; the open intervals have ongoing: true and end at N. No recorded patch does not establish that no patch exists. Likewise, no exploitation evidence means its metrics are unavailable, not that exploitation never occurred. Missing required anchors produce {"available": false, "missing": ["start", "end"]} (or just the missing endpoint); numeric values are omitted, rather than fabricated as zero.

Which evidence qualifies#

The builder adds metric_role and metric_basis only to recognised calculation inputs. Free-text labels never determine an event’s meaning. Unrecognised dates remain visible in the event list.

  • A CVE 5 timeline entry is shown as “Timeline”, with its text. It has no type, so it is never a calculation input.

  • A timestamp at exactly midnight UTC is shown as “Date only”, like a bare date.

Input

Roles / evidence basis

Typed timeline event discovered

discovery, recorded_event

Typed timeline event reported, received or affected-party-notified

report, recorded_event

Typed timeline event acknowledged

acknowledgement, recorded_event

Typed timeline event public-disclosure or advisory-published

disclosure, recorded_event

Typed timeline event exploit-observed

exploitation, recorded_event

Typed timeline event fix-available

patch, recorded_event

dateDiscovered, discovered_at, discovery_date

discovery, recorded_event

dateReported, reported_at

report, recorded_event

dateAcknowledged, acknowledged_at

acknowledgement, recorded_event

datePublic, disclosed_at, disclosure_date, datePublished, published, published_at, initial_release_date

disclosure, recorded_event

dateExploited, exploited_at

exploitation, recorded_event

dateFixed, fixed_at, patched_at

patch, recorded_event

CSAF vulnerability release_date

disclosure, recorded_event

CSAF remediation date with category: vendor_fix

patch, recorded_event

Sighting type: exploited

exploitation, sighting_report

Sighting type: patched

patch_observed, sighting_report

Sighting type: published-proof-of-concept

exploit_publication, sighting_report

Eligible KEV timestamps.first_seen_at, from first-hand evidence or earlier than asserted_at

exploitation, kev_first_seen

Eligible KEV timestamps.first_seen_at otherwise, timestamps.asserted_at, timestamps.recorded_at, or dateAdded

exploitation_report, kev_report

KEV evidence details.patchedSince (ENISA CNW)

patch, kev_report

Typed events must carry their type and timestamp in a GCVE BCP-05-X-03 x_timeline.events array, at the record root or in a CNA x_gcve entry’s extensions. An events array anywhere else (an ADP container, a reference, a tool) does not qualify. Known date-field names qualify at the record root, cveMetadata, containers.cna, document.tracking, and matching CSAF vulnerabilities entries. Dates nested in references, provider metadata, tools or regulatory notifications do not qualify.

A linked record is a calculation input only when it states that it describes the requested vulnerability: its id is the requested id or an alias, or the requested id appears in its own id, aliases, upstream, cves, cveMetadata.cveId, or a CSAF vulnerabilities[].cve. Correlations link records that describe other vulnerabilities too (GCVE x_gcve relationships link both ways, whatever their type), so their dates are shown on the timeline but never become this vulnerability’s disclosure, exploitation or patch. Ids a record only mentions (references, OSV related) do not make it eligible.

For a CSAF advisory covering several CVEs, per-vulnerability discovery, release and remediation dates qualify only where cve matches the requested identifier or an alias. Advisory publication is still disclosure evidence. When the requested record is itself an advisory about a single vulnerability, that vulnerability’s dates and the records of its CVE are the requested ones. An advisory about several vulnerabilities, viewed directly, has no single subject and only its document-level dates qualify.

KEV evidence qualifies only when the entry is in the current KEV set (status.exploited is true, as everywhere else on the instance) and its status reason asserts exploitation: withdrawn, suspected and disputed do not. confirmed, historical and unknown do, as does an entry without a status reason; the selected anchor keeps the status so a study can restrict itself to confirmed. Other KEV entries remain visible but do not establish an exploitation anchor.

first_seen_at is an observation (kev_first_seen) when the entry’s evidence is first-hand, the catalog having seen the activity itself (incident response, telemetry, honeypot or sinkhole, as the KEV scorecard defines it), or when it is earlier than the entry’s asserted_at, the catalog stating a date before it listed the vulnerability. Otherwise it is the listing date that importers copy into it, and it is a report (kev_report), like the assertion, record and catalog addition dates. A report date can be later than the actual first exploitation.

A catalog that lists at least 25 vulnerabilities on one UTC day (dated by first seen, else asserted) is adding a backlog, not reporting new activity: CISA KEV listed 287 on 2021-11-03, its first day, and an import of a feed’s history does the same. Exploitation inputs dated on such a day carry batch_listing: true, and so does every duration with such an endpoint. The exploitation began before that date, possibly years before, so an exploitation-to-patch interval starting there is an upper bound on the patch delay, and a negative one mostly says that the vulnerability was patched before the catalog existed. The batch days are computed from the KEV table and cached for an hour.

An entry’s recorded_at, when it is the exploitation date selected (its catalog dates are later, or not listing dates; see below), is the moment this instance imported it. A day on which at least 25 exploited entries of one catalog were recorded is an import of that catalog’s backlog, so a recorded_at on such a day also carries batch_listing: true. A listing date that merely falls on an import day is not flagged for that.

A catalog can also fill a listing date with the vulnerability’s publication time: Previdian’s added_date for many entries it backfilled is the CVE record’s datePublished truncated to the second (an entry “added” 2022-12-12T17:54:47Z for a record published at 17:54:47.850). Such a date says nothing about when the catalog listed the vulnerability, and taken as a listing it would make the vulnerability “exploited” before its disclosure and could displace a honeypot’s real first detection by years. A KEV exploitation date within one second of a sub-day disclosure instant of the vulnerability’s records (bare dates and midnight timestamps are not compared, same-day listings being common) is therefore shown with publication_copy: true and is not a calculation input. The entry’s other dates still count: its recorded_at says the vulnerability was exploited by the time it was recorded.

A catalog’s patchedSince (ENISA CNW) states that a fix is available since that date; it is patch evidence with the kev_report basis. These per-vulnerability metrics distinguish activity and reports; the separate aggregate time-to-exploit statistic measures publication to a catalog listing using its own documented fallbacks.

An exploited sighting’s creation_timestamp records the reported observation, which may lag actual activity. A patched sighting describes the reporting user’s system and is not a vendor release date or proof that every affected system has been patched. The cards label both bases. An explicit fix date denotes availability, not deployment across all users or all affected products. The earliest patch does not prove complete remediation of every affected version.

The patch anchor is the earliest fix recorded on this instance, which is often a distributor’s: a Red Hat, SUSE or Microsoft CSAF vendor_fix date for a rebuilt package. The upstream project’s own fix usually has no dated record here (OSV and PyPI advisories give fixed versions, not dates), so for a library the patch date can be months after the upstream fix, and disclosure_to_patch then measures the wait for the first recorded downstream fix rather than the upstream vendor’s response. Events from a related record (a CSAF advisory, a national database entry) carry linked_record: true, and a card whose patch date comes from one says so.

A CSAF discovery_date is often the date the publisher (a distributor) learned of the vulnerability, not its discovery: it can follow the public disclosure, the exploitation and even the publisher’s own fix. When the selected discovery date is certainly later than the disclosure, total_time_to_patch carries discovery_after_disclosure: true and its card says so; the origin is not affected, being the earliest of the three.

An advisory about several CVEs can gain one in a later revision (CERT-FR “Ajout des identifiants CVE …”). Its initial_release_date then predates the disclosure of the added CVE, so for that CVE the builder uses instead the earliest revision whose description (CERT-FR revisions[].description, CSAF document.tracking.revision_history[].summary) says it adds the CVE id: “Added CVE-…”, “Ajout des identifiants CVE-…”, “Prise en compte de la CVE-…”, “CVE-…, CVE-… ergänzt” or “… toegevoegd”, or names the ids as new vulnerabilities (“twee nieuwe kwetsbaarheden gepubliceerd (CVE-… en CVE-…)”, “new vulnerabilities CVE-…”, in English, French, German or Dutch, with at most a few words in between and none about something else such as a PoC or an exploit). A revision that says something about a CVE the advisory already covered (“PoC für CVE-… aufgenommen”, “CVE-… wird ausgenutzt”, “Updated first fixed release … for CVE-…”) does not count, and wording the builder does not recognise leaves the release date in place. That event carries added_in_revision: true, and its card notes that the vulnerability may have been public earlier: the advisory’s revision follows the vendor’s. A CVE added in the first revision, or in an advisory about it alone, keeps the release date.

A published proof-of-concept sighting is the first public claim of a PoC, typically a channel reposting a new repository that names the CVE. Nothing checks that the code works or is not a decoy, and the sighting is dated when it was reported, which can be well after the code was published.

EPSS predictions, CVE reservation, record updates, KEV remediation deadlines, fix-developed, mitigation-available, planned disclosure and negative sightings do not supply these anchors. In particular, a published proof of concept supplies the public exploit anchor only: it does not establish observed exploitation, and mitigation availability alone does not establish a patch. A CSAF threats[] entry of category exploit_status is not used either: its meaning is in free-text details (“Exploited in the wild”, “No known exploit”), and free text never decides an event’s role.

Relation to VTEM#

The Vulnerability Timeline and Exposure Metrics framework (VTEM) was presented by Jake Kouns (Risk Based Security) and Kymberlee Price (Bugcrowd) at Black Hat USA 2015, in Stranger Danger! What Is The Risk From 3rd Party Libraries?. It defines nine dates of a vulnerability’s lifecycle and metrics derived from them, to evaluate vendors and products and to understand an organisation’s exposure. Its dates were curated by hand in a commercial database; here they are read from published, machine-readable evidence, chiefly GCVE BCP-05-X-03 timelines, CSAF advisories, KEV catalogs and sightings. The implementation follows VTEM’s names where the evidence has an equivalent and does not claim to be a complete or exact implementation:

VTEM date

Here

Vulnerability introduced

Not available (affected version ranges carry versions, not dates).

Discovered

discovery

Vendor informed (and the proposed intermediary date)

report: earliest of reported, received, affected-party-notified.

Vendor acknowledged

acknowledgement

Vendor solution

patch

Disclosure

disclosure

Third-party solution

Not used (mitigation-available does not establish a patch).

Exploit published

exploit_publication (published proof-of-concept sightings).

Solution implemented (by an organisation)

Not calculated; a patched sighting describes the reporter’s own deployment and serves only as an upper bound on patch availability.

VTEM’s vendor response time, time to patch and total time to patch map to vendor_response_time, time_to_patch and total_time_to_patch. Its organisation-specific time of exposure and total time of exposure need the date a fix was deployed in that organisation, which public evidence does not hold. Observed exploitation (exploitation, from KEV catalogs and sightings) and the metrics built on it go beyond VTEM, whose exploit date is the publication of an exploit.

Time units and precision#

Dates are parsed as ISO 8601, or the unambiguous year-first 2026/04/22 some catalogs use, and converted to UTC; day-first forms are rejected. Offset-free date-times are treated as UTC, invalid dates are ignored, and timestamp precision stops at milliseconds, as in the browser. Future dates remain on the timeline but are excluded from the calculation’s observed anchors.

For a duration Δ in seconds, hours = Δ / 3600 and days = Δ / 86400. The API returns fractional units without rounding. The page shows whole days and remaining decimal hours, rounded to two decimal places; a nonzero duration below 0.01 hours is shown as <0.01 hours.

A bare date is normalised to midnight UTC solely to calculate a nominal duration (seconds, hours, days). It stands for any instant of that UTC day, so every duration also carries the interval its evidence allows: min_seconds and max_seconds, equal to seconds when both endpoints are exact. If either endpoint has day precision, approximate: true and the page shows ≈ with an explanation.

A calculation input at exactly 00:00:00.000 UTC is also treated as day precision, although the event keeps date_only: false as its source wrote it. Daily feeds (Shadowserver reports, for instance) and CSAF dates written as T00:00:00Z are days in timestamp form: an exploitation sighting and a vendor fix both at midnight on the same day are an unknown order within that day, not a 0-hour interval. A real event at exact midnight is a 1-in-86,400 case, so the cost of the rule is a slightly wider interval for it. The calculation clock (as_of) is not an input and keeps its precision.

order_known is false when that interval contains zero, that is when the precision of the dates does not establish which event came first: a fix dated 2026-10-05 and exploitation first seen at 2026-10-05T10:00:00Z give a nominal −10 hours, but a range of −10 to +14 hours. end_before_start is set only when the end certainly precedes the start. When the order is unknown, the page shows the range (“−1 day to +1 day”) and “Order unknown” instead of a nominal value and “Patch evidence precedes exploitation”. Aggregate studies should treat such pairs as unordered (or use the bounds) rather than as negative intervals, which would otherwise bias exploitation-to-patch distributions toward patch-first.

Worked examples#

Discovery on October 1 at 09:00 UTC, disclosure on October 3 at 09:00 UTC, exploitation on October 3 at 15:00 UTC, and patch availability on October 5 at 09:00 UTC give:

Metric

Result

Time to exploit

2 days 6 hours (54 hours)

Disclosure to exploit

6 hours

Total time to patch / time unpatched

4 days (96 hours)

Disclosure to patch

2 days (48 hours)

Time to patch, vendor response time

Not available: no report to the vendor is recorded

Exploitation to patch

1 day 18 hours (42 hours of exploitation before patch availability)

If the patch instead became available on October 2 at 09:00 UTC, exploitation_to_patch would be −30 hours: patch availability preceded the observed exploitation by 1 day 6 hours. If no patch date exists and the calculation runs on October 6 at 09:00 UTC, time unpatched is 5 days and exploitation to patch is 2 days 18 hours, both labelled “No patch recorded” and ongoing: true.

API shape#

metrics.anchors contains the selected origin, discovery, report, acknowledgement, disclosure, exploit_publication, exploitation and patch events (or null). Each available metric has this shape; start and end contain the full source event, abbreviated here for readability:

{
  "available": true,
  "seconds": 21600.0,
  "hours": 6.0,
  "days": 0.25,
  "min_seconds": 21600.0,
  "max_seconds": 21600.0,
  "approximate": false,
  "ongoing": false,
  "order_known": true,
  "end_before_start": false,
  "start": {"timestamp": "2026-10-03T09:00:00Z", "metric_role": "disclosure"},
  "end": {"timestamp": "2026-10-03T15:00:00Z", "metric_role": "exploitation"}
}

For open intervals the end is a synthetic event with label: As of, source: Calculation clock and timestamp equal to metrics.as_of. end_before_start retains event order even where time_unpatched clamps a negative interval (and its bounds) to zero.