Skip to content

Time-to-Fix Benchmarks ​

Get cross-distribution time-to-fix benchmarks showing how quickly different Linux distributions patch CVEs.

Endpoint ​

GET /api/v1/analytics/time-to-fix

Parameters ​

ParameterTypeRequiredDescription
fullstringNoSet to true to include per-CVE snapshot data (can be large)

Response ​

json
{
  "success": true,
  "data": {
    "aggregates": {
      "nixpkgs-unstable": {
        "label": "nixpkgs unstable",
        "family": "nix",
        "totalTracked": 307,
        "fixed": 262,
        "fixedCount": 262,
        "unfixed": 45,
        "unknown": 0,
        "fixRate": 85,
        "distinctPackages": 69,
        "largestShare": { "package": "siyuan", "cves": 86, "share": 0.28 },
        "comparableWith": ["nixos-26.05", "nixos-25.11", "debian_13"],
        "medianDays": null,
        "p90Days": null,
        "medianWithheld": "fix_date_unknown_share_too_high",
        "medianWithheldDetail": "27% of the fixed rows here have no recoverable fix date, above the 25% at which a survival curve stops being an estimate and becomes a guess about the rows it cannot see.",
        "population": { "atRisk": 307, "events": 180, "censored": 127, "excluded": 0, "fixDateUnknownShare": 0.274 },
        "channelOpenedAt": "2026-04-01T00:00:00.000Z",
        "maxFollowUpDays": 173,
        "fixedWithinDays": { "30": 0.41, "90": 0.68, "180": null },
        "survivalAtEnd": 0.22
      },
      "debian_13": {
        "label": "Debian 13 (trixie)",
        "family": "debian",
        "totalTracked": 97,
        "fixed": 9,
        "fixedCount": 9,
        "unfixed": 0,
        "unknown": 88,
        "fixRate": null,
        "fixRateWithheld": "Fix status here is derived by comparing the packaged version against the upstream fixed version. This distribution backports security fixes into frozen versions...",
        "medianDays": null,
        "medianWithheld": "..."
      }
    },
    "totalTracked": 303,
    "lastUpdated": "2026-09-21T09:14:22.101Z",
    "lastAttempted": "2026-09-21T09:14:22.101Z",
    "releaseDrift": [
      { "family": "debian", "tracked": "debian_13", "availableUpTo": "debian_14", "behind": 1 }
    ],
    "releaseDriftCheckedAt": "2026-09-21T09:14:22.101Z"
  },
  "timestamp": "2026-02-10T12:00:00.000Z"
}

Response Fields ​

Aggregates (per distro) ​

FieldTypeDescription
labelstringHuman-readable distribution name
familystringDistribution family (nix, debian, fedora, arch, alpine)
totalTrackednumberCVEs tracked for this distro
fixednumberCVEs already fixed in this distro
fixedCountnumberAlias for fixed
unfixednumberCVEs not yet fixed
unknownnumberCVEs whose fix status could not be determined, because the packaged and fixed versions cannot be ordered. Kept apart from unfixed: an unanswerable question is not a negative answer
fixRatenumber|nullPercentage of decided CVEs that are fixed (0-100). null where withheld
fixRateWithheldstringPresent instead of a rate where fix detection is unsound for this distribution. See below
distinctPackagesnumberHow many distinct packages the row rests on
largestShareobject|nullThe package dominating the row, as { package, cves, share }. A rate resting mostly on one package says more about that package than the distribution
comparableWithstring[]Distributions measured on at least one package in common. Where empty, the figures sit side by side and mean nothing about each other
medianDaysnumber|nullnull unless a nix channel's survival estimate clears every gate below. See medianWithheld
p90Daysnumber|null90th percentile days to fix. null while the median is withheld
medianWithheldstringFor a non-nix distribution, the full sentence explaining why no median is published. For a nix channel, a short machine-readable gate code (no_survival_data, fix_date_unknown_share_too_high, follow_up_shorter_than_horizon, horizon_not_reached) paired with medianWithheldDetail's full sentence
medianWithheldDetailstringNix channels only: the human-readable explanation for medianWithheld's gate code
populationobjectNix channels only: {atRisk, events, censored, excluded, fixDateUnknownShare} — the exact sample the survival estimate (or its withholding) was computed over
channelOpenedAtstring|nullNix channels only: when this channel's first tracked release began
maxFollowUpDaysnumberNix channels only: how many days of observation the estimate actually has
fixedWithinDaysobjectNix channels only: {30, 90, 180}, each the share fixed within that many days, or null if maxFollowUpDays hasn't reached that horizon yet (never extrapolated past what was actually observed)
survivalAtEndnumber|nullNix channels only: the Kaplan-Meier survival estimate at the end of the observed window

Why the median is absent ​

A median computed only over CVEs that were fixed discards the unfixed, which are disproportionately the slow ones, and a clock that runs to the moment this service first observed a fix rather than to the moment anyone shipped one makes every distribution look faster than it is. Neither error is acceptable to publish, so medianDays is null and medianWithheld says why, unless a nix channel's Kaplan-Meier survival estimate (population, channelOpenedAt, maxFollowUpDays, fixedWithinDays, survivalAtEnd) clears every gate in medianWithheld's possible codes, in which case medianDays carries the corrected figure instead.

Non-nix distributions have no survival estimate behind them at all, so medianDays stays null with medianWithheld holding the full explanation directly, rather than a gate code. Withheld rather than removed, so a caller is told the field exists and why it is empty: silently dropping it would leave a reader to conclude we hold no timing data.

Why some fix rates are absent ​

fixRate is null with a fixRateWithheld note for Debian, Ubuntu, Fedora and the other distributions that backport security fixes into frozen versions. Our detection compares the packaged version against the upstream fixed version, and a fix shipped as 1.19.2-2+deb13u1 for something fixed upstream in 1.21 is invisible to that comparison. A low rate computed this way measures packaging policy rather than responsiveness, so publishing it would invite exactly the wrong conclusion about somebody else's security team.

Tracked Distributions ​

NixOS channels are computed dynamically based on the current release schedule:

DistributionDescription
nixpkgs-unstableNixpkgs unstable channel
nixos-YY.MMLatest two NixOS stable releases
debian_13Debian Trixie
fedora_44Fedora 44
ubuntu_26_04Ubuntu 26.04 LTS
archArch Linux
alpine_3_24Alpine 3.24

The tracked release for each family is checked against what Repology actually carries, and releaseDrift in the response names any family that has moved past the release we track, with how far behind it is. A tracked release going stale is how a row quietly starts measuring something three versions old.

Example ​

bash
# Get aggregate stats only
curl https://api.vulnpatch.dev/api/v1/analytics/time-to-fix

# Include per-CVE snapshots
curl "https://api.vulnpatch.dev/api/v1/analytics/time-to-fix?full=true"

Code Examples ​

javascript
async function compareDistros() {
  const response = await fetch('https://api.vulnpatch.dev/api/v1/analytics/time-to-fix');
  const { data } = await response.json();

  for (const [distro, stats] of Object.entries(data.aggregates)) {
    // medianDays is null while it is withheld; say so rather than printing
    // "null days", and a null fixRate has its own reason attached.
    const median = stats.medianDays === null ? `no median (${stats.medianWithheld})` : `median ${stats.medianDays}d`;
    const rate = stats.fixRate === null ? `no fix rate (${stats.fixRateWithheld})` : `fix rate ${stats.fixRate}%`;
    console.log(`${stats.label}: ${median}, ${rate}`);
  }
}
python
import requests

response = requests.get("https://api.vulnpatch.dev/api/v1/analytics/time-to-fix")
data = response.json()["data"]

for distro, stats in data["aggregates"].items():
    median = f"median {stats['medianDays']}d" if stats.get('medianDays') is not None else f"no median ({stats.get('medianWithheld')})"
    rate = f"fix rate {stats['fixRate']}%" if stats.get('fixRate') is not None else f"no fix rate ({stats.get('fixRateWithheld')})"
    print(f"{stats['label']}: {median}, {rate}")

Use Cases ​

  • Distribution comparison: Compare patching speed across NixOS, Debian, Arch, etc.
  • SLA tracking: Monitor whether your distro meets vulnerability SLAs
  • Reporting: Generate time-to-fix trend reports for stakeholders

Caching ​

Data is computed every 30 minutes via cron.