Vulnerability management SLAs: define, measure, and prove remediation time
For developers and engineering leads who need auditable vulnerability remediation SLAs without buying a new platform. You’ll set severity-to-deadline rules, calculate remediation time from scanner output and ticket timestamps, and generate evidence that stands up in an audit.
TL;DR — Define SLA deadlines from a vulnerability’s first-seen timestamp, not from when someone noticed it manually. The fastest path is: normalize scanner findings to JSON, map severity to due dates in code, compare against ticket closed timestamps, and store the raw inputs plus a generated CSV so you can prove every calculation later. Reading time: ~5 min
Goal
When you finish, you will have a repeatable way to calculate whether each vulnerability was remediated within SLA, plus a CSV/JSON evidence bundle showing first seen, severity, due date, remediation date, elapsed days, and pass/fail for each finding.
Prerequisites
- Access to your vulnerability scanner export in JSON or CSV format; if you only have SARIF, keep that file too
- Access to your ticketing system export with issue key, created timestamp, resolved/closed timestamp, and status
jq >= 1.6— check withjq --versionpython >= 3.11— check withpython3 --versioncsvkitoptional but useful for CSV inspection — install withpython3 -m pip install csvkit- UTC timestamps from both systems, or the ability to export timezone-aware ISO-8601 timestamps
- Your policy decision for SLA thresholds; example used below: Critical 7 days, High 14 days, Medium 30 days, Low 90 days
- A stable finding identifier strategy; use scanner-native finding ID if present, otherwise
asset + package + installed_version + vuln_id
Steps
Step 1: Write the SLA policy as code
Create a policy file so the rule is explicit and versioned.
{
"timezone": "UTC",
"sla_days": {
"critical": 7,
"high": 14,
"medium": 30,
"low": 90
},
"start_clock": "first_seen",
"stop_clock": "resolved",
"reopen_resets_clock": false,
"accepted_risk_statuses": ["risk_accepted", "compensating_control_approved"]
}
What you should see when this succeeds: a file named sla-policy.json in your repo, committed like any other compliance-controlled artifact.
Step 2: Normalize scanner findings to a single JSON shape
If your scanner already exports JSON, transform it to a flat structure with one row per finding. Example for a generic JSON array input at scanner-export.json:
jq '[.findings[] | {
finding_id: (.id // (.asset + ":" + .package + ":" + .installed_version + ":" + .vuln_id)),
asset: .asset,
vuln_id: (.cve // .vuln_id),
severity: (.severity | ascii_downcase),
first_seen: .first_seen,
status: (.status | ascii_downcase)
}]' scanner-export.json > findings.normalized.json
If your source is CSV, convert first:
in2csv scanner-export.csv | csvjson > scanner-export.json
What you should see when this succeeds: jq '.[0]' findings.normalized.json prints one object with finding_id, severity, and first_seen.
Step 3: Export ticket data with resolution timestamps
Export your ticket data from your tracker as CSV, then convert it to normalized JSON. The CSV must contain the columns finding_id, ticket_key, created, resolved, and status.
in2csv tickets-export.csv | csvjson > tickets-export.json
jq '[.[] | {
finding_id: .finding_id,
ticket_key: .ticket_key,
created: .created,
resolved: .resolved,
status: (.status | ascii_downcase)
}]' tickets-export.json > tickets.normalized.json
What you should see when this succeeds: jq '.[0]' tickets.normalized.json shows the ticket timestamps in ISO-8601 form, for example "2026-10-29T14:22:11Z".
Step 4: Join findings to tickets and calculate SLA due dates
Create calc_sla.py:
import json, csv
from datetime import datetime, timedelta, UTC
policy = json.load(open("sla-policy.json"))
findings = json.load(open("findings.normalized.json"))
tickets = {t["finding_id"]: t for t in json.load(open("tickets.normalized.json"))}
def parse_ts(s):
if not s:
return None
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(UTC)
rows = []
for f in findings:
sev = f["severity"]
if sev not in policy["sla_days"]:
continue
first_seen = parse_ts(f["first_seen"])
due = first_seen + timedelta(days=policy["sla_days"][sev])
t = tickets.get(f["finding_id"], {})
resolved = parse_ts(t.get("resolved"))
ticket_status = t.get("status", "untracked")
accepted = ticket_status in policy["accepted_risk_statuses"]
if accepted:
result = "exception"
elapsed_days = ""
elif resolved:
elapsed_days = (resolved - first_seen).days
result = "pass" if resolved <= due else "fail"
else:
elapsed_days = (datetime.now(UTC) - first_seen).days
result = "open_breach" if datetime.now(UTC) > due else "open_within_sla"
rows.append({
"finding_id": f["finding_id"],
"asset": f["asset"],
"vuln_id": f["vuln_id"],
"severity": sev,
"first_seen": first_seen.isoformat().replace("+00:00", "Z"),
"due_date": due.isoformat().replace("+00:00", "Z"),
"ticket_key": t.get("ticket_key", ""),
"resolved": resolved.isoformat().replace("+00:00", "Z") if resolved else "",
"elapsed_days": elapsed_days,
"result": result
})
with open("sla-evidence.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
json.dump(rows, open("sla-evidence.json", "w"), indent=2)
print(f"wrote {len(rows)} rows")
Run it:
python3 calc_sla.py
Expected output shape:
wrote 184 rows
What you should see when this succeeds: sla-evidence.csv and sla-evidence.json exist in the current directory.
Step 5: Produce auditor-friendly summaries
Generate counts by result and severity.
python3 - <<'PY'
import csv, collections
rows = list(csv.DictReader(open('sla-evidence.csv')))
by_result = collections.Counter(r['result'] for r in rows)
by_sev = collections.Counter((r['severity'], r['result']) for r in rows)
print('RESULT COUNTS')
for k,v in sorted(by_result.items()):
print(f'{k},{v}')
print('\nSEVERITY,RESULT,COUNT')
for (sev,res),v in sorted(by_sev.items()):
print(f'{sev},{res},{v}')
PY
Expected output shape:
RESULT COUNTS
exception,3
fail,11
open_breach,7
open_within_sla,19
pass,144
SEVERITY,RESULT,COUNT
critical,fail,2
critical,pass,18
high,fail,7
high,pass,52
medium,open_breach,6
What you should see when this succeeds: a summary you can paste directly into an audit response or monthly governance report.
Step 6: Preserve the evidence chain
Package the exact inputs and outputs together.
mkdir -p evidence-$(date +%F)
cp sla-policy.json findings.normalized.json tickets.normalized.json sla-evidence.csv sla-evidence.json evidence-$(date +%F)/
sha256sum evidence-$(date +%F)/* > evidence-$(date +%F)/SHA256SUMS
ls -1 evidence-$(date +%F)
Expected output shape:
SHA256SUMS
findings.normalized.json
sla-evidence.csv
sla-evidence.json
sla-policy.json
tickets.normalized.json
What you should see when this succeeds: one dated directory containing raw normalized inputs, calculated outputs, and checksums.
Verify it works
Run these checks:
csvlook sla-evidence.csv | sed -n '1,10p'
Expected shape:
| finding_id | asset | vuln_id | severity | first_seen | due_date | ticket_key | resolved | elapsed_days | result |
| -------------------- | ---------- | -------------- | -------- | -------------------- | -------------------- | ---------- | -------------------- | ------------ | ------ |
| host1:openssl:... | host1 | CVE-2026-12345 | critical | 2026-10-01T09:00:00Z | 2026-10-08T09:00:00Z | SEC-142 | 2026-10-05T16:44:00Z | 4 | pass |
Check that no resolved item is missing a ticket key:
python3 - <<'PY'
import csv, sys
bad = [r for r in csv.DictReader(open('sla-evidence.csv')) if r['resolved'] and not r['ticket_key']]
print(len(bad))
sys.exit(1 if bad else 0)
PY
Expected result: prints 0 and exits with code 0.
Check for timestamp parsing problems:
python3 calc_sla.py; echo $?
Expected result: exit code 0. If you see ValueError: Invalid isoformat string, your exports are not using ISO-8601 consistently.
Common pitfalls
Severity names do not match the policy
Mistake: your scanner exports sev_1, important, or uppercase values while sla-policy.json expects critical|high|medium|low.
Symptom: rows silently disappear because the script skips severities not in sla_days.
Fix: map them during normalization, for example "severity": ({"sev_1":"critical","sev_2":"high"}[.severity] // (.severity|ascii_downcase)) in the jq transform.
You started the SLA clock from ticket creation instead of first seen
Mistake: using tickets.created as the start date because it is easier to export.
Symptom: remediation looks artificially fast, and auditors ask why scanner evidence predates the SLA start.
Fix: set "start_clock": "first_seen" in sla-policy.json and calculate due dates from the scanner timestamp only.
Reopened findings get counted twice
Mistake: exporting one row per ticket transition or per scan occurrence.
Symptom: the same finding_id appears multiple times in sla-evidence.csv, often with conflicting results.
Fix: deduplicate before calculation; keep one finding row per stable finding_id and one ticket row with the latest resolved timestamp.
Timezones are mixed or missing
Mistake: one export uses 2026-10-01 09:00:00 and another uses 2026-10-01T09:00:00Z.
Symptom: ValueError: Invalid isoformat string or off-by-one-day SLA failures around midnight.
Fix: re-export timestamps in ISO-8601 with timezone, or convert them before processing so every timestamp ends in Z or an explicit offset like +00:00.
Risk acceptances are treated as passes
Mistake: marking accepted risk as pass.
Symptom: your summary overstates remediation performance and fails policy review.
Fix: keep accepted items as exception using accepted_risk_statuses; they are not remediated, they are approved exceptions.
Finding IDs are unstable across scans
Mistake: using a scanner row number or ephemeral UUID that changes every export.
Symptom: tickets do not join to findings, so many rows show untracked with blank ticket_key.
Fix: build finding_id from stable fields such as asset + package + installed_version + vuln_id, and use that same key in the ticket custom field or export.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI