Vulnerability Management

How to Build a Vulnerability SLA Dashboard

Track remediation SLAs across projects with a self-service dashboard that surfaces aging findings, breach risk, and team accountability — complete code inside.

Nayan Dey
Senior Security Engineer
5 min read

Without an SLA dashboard, "we take security seriously" is just words. A measurable dashboard — findings opened this month, findings closed in SLA, findings past SLA by owner — is what turns vulnerability management from a reactive firefighting loop into a program engineering and security leaders can actually run. This tutorial shows you how to define SLA tiers by severity, pull findings data from the Safeguard API, push it into a PostgreSQL datamart, and visualize it with a Grafana 10 dashboard that teams can slice by project, owner, and age. Prerequisites: a Safeguard API key, PostgreSQL 15+, Grafana 10, and 90 minutes. The dashboard scales to thousands of findings across dozens of teams.

What SLA tiers should I define?

Match SLA tiers to severity: critical fixed in 7 days, high in 30, medium in 90, low best-effort. Adjust for your risk tolerance and industry — fintech typically tightens critical to 48 hours, retail relaxes medium to 180 days.

| Severity | SLA (days) | Breach action | |---------|-----------|---------------| | Critical | 7 | Page on-call security | | High | 30 | Jira + weekly review | | Medium | 90 | Quarterly review | | Low | 365 | Backlog |

Write the SLA into policy signed by engineering and security leadership. A dashboard that tracks an unsigned SLA is a dashboard nobody follows.

How do I pull findings from the API?

Use the Safeguard REST API to list findings with the state=open and severity filters. Page through results and store raw JSON in a staging table.

curl -sH "Authorization: Bearer $SAFEGUARD_TOKEN" \
  "https://api.safeguard.dev/v1/findings?state=open&page_size=500" \
  | jq '.data[] | { id, severity, opened_at, project_id, cve }' \
  > findings.ndjson
wc -l findings.ndjson
# 2134 findings.ndjson

Run the pull every hour via cron or Airflow. A 1-hour lag is acceptable for an SLA dashboard; sub-minute latency is over-engineering.

How do I model the warehouse table?

Create one fact table with severity, project, owner, opened and closed timestamps, plus computed SLA deadline. Compute in_sla, breached, and aging_days in a view so the dashboard stays simple.

CREATE TABLE findings (
  id            TEXT PRIMARY KEY,
  project_id    TEXT NOT NULL,
  owner_team    TEXT,
  severity      TEXT NOT NULL,
  opened_at     TIMESTAMPTZ NOT NULL,
  closed_at     TIMESTAMPTZ,
  sla_days      INT NOT NULL,
  cve           TEXT
);
CREATE INDEX ON findings (owner_team, severity, opened_at);

CREATE VIEW finding_sla AS
SELECT *,
  (opened_at + (sla_days || ' days')::INTERVAL) AS sla_deadline,
  EXTRACT(EPOCH FROM (COALESCE(closed_at, NOW()) - opened_at)) / 86400 AS aging_days,
  (COALESCE(closed_at, NOW()) > opened_at + (sla_days || ' days')::INTERVAL) AS breached
FROM findings;

Partitioning by month keeps historical queries snappy once you cross 500k rows. For smaller orgs, a single un-partitioned table is fine.

How do I ingest into Postgres?

Use jq to shape the NDJSON into COPY-friendly TSV, then psql \COPY it into the table. This runs in seconds for tens of thousands of rows.

jq -r '[.id, .project_id, .owner_team, .severity,
       .opened_at, .closed_at,
       (if .severity == "critical" then 7
        elif .severity == "high" then 30
        elif .severity == "medium" then 90 else 365 end),
       .cve] | @tsv' findings.ndjson > findings.tsv

psql $DB_URL -c "\\COPY findings FROM 'findings.tsv' \
  WITH (FORMAT text, DELIMITER E'\t', NULL '')"

Use ON CONFLICT (id) DO UPDATE if you want idempotent reloads. Otherwise truncate the staging table first and copy fresh — easier to reason about for a daily refresh.

How do I build the Grafana panels?

Add four core panels: open findings by severity, SLA compliance percentage (closed-in-SLA / total-closed), breached findings by owner team, and oldest-5 open findings. Keep it one screen — scrolling dashboards are ignored.

-- Panel: SLA compliance percentage (90d)
SELECT
  severity,
  ROUND(100.0 * SUM(CASE WHEN NOT breached THEN 1 ELSE 0 END) /
    NULLIF(COUNT(*), 0), 1) AS in_sla_pct
FROM finding_sla
WHERE closed_at > NOW() - INTERVAL '90 days'
GROUP BY severity
ORDER BY severity;

Set thresholds on the stat panels: green > 95%, amber 85–95%, red < 85%. Use Grafana's alerting to notify the security channel when a team crosses into red for two consecutive days.

How do I drive accountability from the dashboard?

Share a weekly automated snapshot in engineering leadership Slack. Include open-critical count, breach count by team, and week-over-week delta. Make it visible and inescapable.

grafana-cli dashboard snapshot \
  --dashboard-uid vuln-sla \
  --output /tmp/snapshot.png
curl -F file=@/tmp/snapshot.png \
  -F "initial_comment=Weekly vuln SLA snapshot - $(date +%F)" \
  -H "Authorization: Bearer $SLACK_TOKEN" \
  https://slack.com/api/files.upload

The snapshot, not the dashboard URL, drives engagement. People look at an image in their feed; almost nobody clicks through to a BI tool on Monday morning.

How Safeguard Helps

Safeguard already ships the data model this dashboard needs — every finding carries severity, owner team, opened/closed timestamps, and reachability status out of the box. The REST API exposes SLA-aware endpoints so you do not compute deadlines yourself, and Griffin AI auto-assigns owner teams based on CODEOWNERS and recent commit authorship. Policy gates can tie SLA compliance to deploy readiness — a team in red does not promote to production until breach count drops. SBOM ingest and reachability analysis feed the same findings store the dashboard queries, so one source of truth powers exec reporting, developer Jira tickets, and audit exports. Skip the warehouse rebuild and point Grafana at Safeguard's warehouse connector directly.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.