Metric · Security

What is scan coverage, and how do you measure it in Metabase?

Scan coverage is the share of your asset inventory that was scanned — or protected by a healthy agent — within a defined window. It's the denominator check for every other vulnerability metric: an open criticals count only describes the assets you actually scan. Measure it in Metabase by joining scan telemetry from Tenable, CrowdStrike, SentinelOne, or Orca Security against an inventory reconciled from cloud APIs and your CMDB — not from the scanner itself.

TL;DRassets scanned in window ÷ total inventory, by asset group. The whole metric lives or dies on the denominator: an inventory sourced from the scanner itself trends toward 100% by construction.

What scan coverage measures

It measures how much of your estate your vulnerability data can even see. Zero findings on an asset group can mean it's clean or that it's never been scanned — coverage is what distinguishes the two. That makes it the honesty check that comes before remediation speed or SLA compliance: those metrics only apply to findings that exist, and findings only exist where scanning happens. The gap list — known to the cloud API, never seen by the scanner — is where breaches like to start.

What data does it need?

  • A reconciled assets table built from independent sources — cloud provider APIs, CMDB, agentless discovery — with status, asset_group, and discovery_source.
  • Scan and agent telemetry joined onto it as last_scanned_at(or last healthy agent check-in).
  • Lifecycle data so decommissioned assets leave the denominator instead of lingering as "unscanned."
  • Per-asset-class window definitions — servers, internet-facing hosts, and ephemeral infrastructure all need different ones.

SQL patterns

Coverage by asset group (last 30 days)PostgreSQL
-- assets: inventory reconciled from cloud APIs / CMDB,
-- not just what the scanner already knows about
SELECT
  a.asset_group,
  COUNT(*) AS inventory,
  COUNT(*) FILTER (
    WHERE a.last_scanned_at >= CURRENT_DATE - INTERVAL '30 days'
  ) AS scanned_30d,
  ROUND(
    100.0 * COUNT(*) FILTER (
      WHERE a.last_scanned_at >= CURRENT_DATE - INTERVAL '30 days'
    ) / NULLIF(COUNT(*), 0), 1
  ) AS coverage_pct
FROM assets a
WHERE a.status = 'active'
GROUP BY 1
ORDER BY coverage_pct;
Assets never scanned or stale over 30 days, with discovery sourcePostgreSQL
SELECT
  a.hostname,
  a.asset_group,
  a.discovery_source,   -- how we know it exists: cloud API, CMDB, scanner
  a.first_discovered_at::date AS discovered,
  a.last_scanned_at::date AS last_scanned
FROM assets a
WHERE a.status = 'active'
  AND (
    a.last_scanned_at IS NULL
    OR a.last_scanned_at < CURRENT_DATE - INTERVAL '30 days'
  )
ORDER BY a.last_scanned_at ASC NULLS FIRST;

Pitfalls

Taking the denominator from the scanner.→ A scanner can only report on assets it knows about, so scanner-sourced inventory inflates coverage toward 100% by construction. Reconcile the denominator from cloud APIs and the CMDB — the unknown-to-scanner gap is the point of the metric.
One window for every asset class.→ A 30-day window is reasonable for long-lived servers, generous for internet-facing hosts, and meaningless for containers that live for hours — measure ephemeral infra as scanned-before-deploy instead. Define windows per asset class.
Counting decommissioned assets as unscanned.→ Machines that left the fleet but not the CMDB pile up as fake coverage gaps until the real ones drown in noise. Scope to active assets and feed lifecycle data into the inventory.
Treating agent-installed as agent-healthy.→ An agent that hasn't checked in for weeks protects nothing while making the asset look covered. Count coverage on recent healthy check-ins, and keep the installed-but-silent list as its own query.

Where this metric applies

Metrics

Dashboards

FAQ

Where should the asset inventory come from?
Not from the scanner alone — that's the central trap. A scanner's own inventory only contains assets it already knows about, so scanner-inventory coverage trends toward 100% by construction while unknown assets stay invisible. Reconcile the denominator from independent sources: cloud provider APIs, your CMDB, and agentless discovery like Orca Security. The assets in the reconciled inventory that the scanner has never seen are the finding.
What scan window should we use?
Per asset class, not one global number. Thirty days is a common default for long-lived servers, but internet-facing assets usually warrant weekly or better — and ephemeral infrastructure needs a different question entirely: containers that live for hours are better measured as "share of images scanned before deploy" than against any wall-clock window. Encode the window per asset class rather than hard-coding one INTERVAL for everything.
Does an installed agent count as covered?
Only if it's healthy. Agents from CrowdStrike or SentinelOne can be installed but broken — outdated sensor, no recent check-in, reporting errors. Count an asset covered when the agent has checked in recently and reports healthy, not when an install record exists. The installed-but-silent list is its own high-value query: those machines look protected on paper and aren't.
How do you calculate scan coverage?
Divide assets scanned (or protected by a healthy agent) within the window by total assets in the reconciled inventory: scanned_in_window / inventory, grouped by asset group. Scope the denominator to active assets — decommissioned machines still in the CMDB shouldn't count as unscanned — and keep the never-scanned and stale lists as the operational follow-up.
How do you track scan coverage in Metabase?
Build a reconciled assets table from cloud APIs and your CMDB, then join scan and agent telemetry from Tenable, CrowdStrike, SentinelOne, or Orca Security to stamp last_scanned_at. Chart coverage by asset group, keep the never-scanned list with discovery source as the work queue, and read it next to open critical vulnerabilities on an attack surface coverage dashboard — findings only exist where scanning happens.