# 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](#relation-to-vtem)) 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](statistics.md#time-to-exploit) 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:

```json
{
  "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.
