Vulnerability management metrics that matter
Define and compute the metrics a VOC needs: time to remediate with percentiles, SLA compliance, coverage, backlog age, KEV exposure windows and reopen rate.
What a vulnerability program measures shapes what people do. Measure raw finding counts and teams will argue about scanner accuracy and close easy items first. Measure closed tickets and tickets will close without anything being fixed. Measure the right things — how fast risk is removed, how complete your visibility is, how long the organization stays exposed to vulnerabilities attackers actually use — and behaviour aligns with the goal.
This guide defines the metrics a Vulnerability Operations Center needs, shows how to compute the important ones with SQL against a simple findings table, and highlights the ways each can mislead. The companion guide, reporting vulnerability risk to leadership, covers how to present them.
Principles
- Prefer flow to stock. How fast findings move through the lifecycle says more than how many are sitting in it.
- Segment by tier. An average across P1 and P4 findings means nothing. Every metric should be reportable per priority tier, and usually per owning team and asset class.
- Include what's still open. Metrics computed only on closed findings are survivorship-biased: the hardest, oldest items are exactly the ones missing.
- Normalize by coverage. A count only makes sense next to how much of the estate you can see.
- Trend, don't snapshot. One month's number is noise; six months of the same definition is information.
The metric catalogue
| Metric | Definition | Why it matters | Main pitfall |
|---|---|---|---|
| Time to remediate (TTR) | Resolved date minus first detected date, per finding; reported as median, 90th percentile and mean | Core measure of how fast risk is removed | Ignores still-open findings; mean skewed by outliers |
| SLA compliance | Findings closed on or before due date ÷ findings closed in the period, plus open past-due count | Whether commitments are met | Closed-only version hides overdue backlog |
| Open past due | Open findings beyond their due date, by tier and age beyond due | The actual current SLA debt | Needs reliable due dates |
| Assessment coverage | In-scope assets with a successful assessment within policy ÷ in-scope assets | Visibility | Depends on inventory completeness |
| Authenticated coverage | As above, counting only authenticated scans or agent results | Quality of visibility | Silent credential failures |
| Backlog age | Distribution of age of open findings, by tier | Long-tail risk | Averages hide the tail |
| KEV exposure window | For KEV-listed findings: time from KEV addition (or detection, if later) to resolution | Exposure to vulnerabilities known to be exploited | Needs KEV date history |
| Detection lag | First detected date minus CVE publication date | Speed of discovery | Publication dates can be misleading for old CVEs |
| Reopen rate | Resolved findings later detected again ÷ resolved findings | Quality of fixes and of build pipelines | Needs stable finding keys |
| Exception load | Active exceptions by tier; share of open P1/P2 risk under exception | Hidden, accepted risk | Blanket exceptions distort it |
MTTR is the metric most programs start with. It is useful, but it should never be reported alone.
Why percentiles beat the mean
Time-to-remediate distributions are heavily skewed: most findings close within a normal patch cycle, and a small number stay open for months. The mean is sensitive to that tail, so it can move a lot because of a handful of legacy items, and it hides whether the typical experience is improving. The median shows the typical finding; the 90th percentile shows how bad the slow end is. Reporting both, per tier, gives a far more honest picture.
There is a subtler problem: TTR computed on closed findings ignores everything still open. If a team fixes all its easy findings quickly and leaves the hard ones, its TTR looks excellent. Two remedies:
- Always report open backlog age next to TTR.
- For mature programs, borrow from survival analysis: estimate the probability that a finding is still open after N days, treating open findings as censored observations. A Kaplan–Meier curve per tier answers "what share of P1 findings are fixed within 7, 14, 30 days?" without ignoring the open ones.
A minimal data model
The SQL below assumes a findings table produced by your remediation workflow:
CREATE TABLE findings (
finding_key text PRIMARY KEY, -- asset + vulnerability + component
asset_id text NOT NULL,
owner_team text,
tier text NOT NULL, -- P1..P4 at time of measurement
cve text,
in_kev boolean DEFAULT false,
kev_added date,
first_detected timestamptz NOT NULL,
due_at timestamptz NOT NULL,
resolved_at timestamptz, -- set only on verified evidence
status text NOT NULL -- open | resolved | risk_accepted | false_positive
);
Median and 90th percentile time to remediate, per tier
SELECT tier,
count(*) AS resolved,
percentile_cont(0.5) WITHIN GROUP (ORDER BY
extract(epoch FROM resolved_at - first_detected) / 86400) AS median_days,
percentile_cont(0.9) WITHIN GROUP (ORDER BY
extract(epoch FROM resolved_at - first_detected) / 86400) AS p90_days,
avg(extract(epoch FROM resolved_at - first_detected) / 86400) AS mean_days
FROM findings
WHERE status = 'resolved'
AND resolved_at >= date_trunc('quarter', now())
GROUP BY tier
ORDER BY tier;
SLA compliance, including the open backlog
WITH closed AS (
SELECT tier,
count(*) FILTER (WHERE resolved_at <= due_at) AS on_time,
count(*) AS total
FROM findings
WHERE status = 'resolved'
AND resolved_at >= now() - interval '90 days'
GROUP BY tier
), overdue AS (
SELECT tier, count(*) AS open_past_due
FROM findings
WHERE status = 'open' AND due_at < now()
GROUP BY tier
)
SELECT c.tier,
round(100.0 * c.on_time / nullif(c.total, 0), 1) AS pct_closed_on_time,
coalesce(o.open_past_due, 0) AS open_past_due
FROM closed c
LEFT JOIN overdue o USING (tier)
ORDER BY c.tier;
KEV exposure window
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY extract(epoch FROM
coalesce(resolved_at, now()) - greatest(first_detected, kev_added::timestamptz)) / 86400)
AS median_kev_exposure_days,
count(*) FILTER (WHERE status = 'open') AS kev_findings_still_open
FROM findings
WHERE in_kev;
Using coalesce(resolved_at, now()) deliberately includes open items at their current age, so the metric cannot improve by leaving KEV findings unresolved.
Coverage metrics
Coverage comes from the asset inventory, not the scanner. Break it down by asset class and business unit, and report authenticated coverage separately from any-coverage. When coverage improves, expect open finding counts to rise; annotate reports so nobody mistakes better visibility for worse security.
Goodhart's law in practice
"When a measure becomes a target, it ceases to be a good measure." Every metric above can be gamed, and knowing how is the best defence:
- TTR improves if hard findings are excepted instead of fixed → report exception load next to it.
- SLA compliance improves if tiers are downgraded → audit tier changes and restrict who can make them.
- Open counts fall if scanners lose access → always pair with coverage.
- Closure rates rise if findings are resolved by ticket state rather than evidence → only verified resolution counts.
- Reopen rate falls if new findings are created instead of reopening old ones → enforce stable finding keys.
A small set of paired metrics — speed with quality, remediation with exceptions, counts with coverage — is much harder to game than any single number.
A starter dashboard
For the team (weekly): open P1/P2 by owner with due dates; findings due in the next 7 days; new KEV additions affecting you; authentication failures by scan scope.
For management (monthly): median and 90th percentile TTR per tier; SLA compliance per tier and team; open past due; KEV exposure window; authenticated coverage; active exceptions.
For executives (quarterly): trends of the management set, plus narrative — see reporting to leadership.