Files

303 lines
11 KiB
Python

#!/usr/bin/env python3
"""Vulnerability SLA Breach Alerting System.
Tracks vulnerability remediation timelines, detects SLA breaches,
and dispatches notifications through multiple channels.
"""
import argparse
import csv
import json
import os
import smtplib
import sqlite3
from datetime import datetime, timedelta, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from pathlib import Path
import requests
import yaml
DB_PATH = os.environ.get("SLA_DB_PATH", "vulnerability_sla.db")
SLA_TIERS = {
"critical": {"cvss_min": 9.0, "cvss_max": 10.0, "days": 2},
"high": {"cvss_min": 7.0, "cvss_max": 8.9, "days": 15},
"medium": {"cvss_min": 4.0, "cvss_max": 6.9, "days": 60},
"low": {"cvss_min": 0.1, "cvss_max": 3.9, "days": 90},
}
def init_db(db_path=DB_PATH):
"""Initialize SQLite database for SLA tracking."""
conn = sqlite3.connect(db_path)
conn.execute("""
CREATE TABLE IF NOT EXISTS vulnerability_sla (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id TEXT NOT NULL,
finding_id TEXT NOT NULL,
asset_hostname TEXT,
severity TEXT NOT NULL,
cvss_score REAL,
discovered_at TEXT NOT NULL,
sla_deadline TEXT NOT NULL,
remediated_at TEXT,
status TEXT DEFAULT 'open',
owner_email TEXT,
escalation_level INTEGER DEFAULT 0,
last_alert_sent TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
return conn
def get_severity_tier(cvss_score):
"""Map CVSS score to severity tier."""
for tier_name, tier in SLA_TIERS.items():
if tier["cvss_min"] <= cvss_score <= tier["cvss_max"]:
return tier_name, tier["days"]
return "low", 90
def import_findings(conn, csv_path):
"""Import vulnerability findings from CSV and assign SLA deadlines."""
imported = 0
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
cve_id = row.get("cve_id", "").strip()
cvss = float(row.get("cvss_score", 0))
if not cve_id or cvss == 0:
continue
discovered = row.get("discovered_at", datetime.now(timezone.utc).isoformat())
discovered_dt = datetime.fromisoformat(discovered.replace("Z", "+00:00"))
severity, sla_days = get_severity_tier(cvss)
deadline = discovered_dt + timedelta(days=sla_days)
conn.execute(
"""INSERT INTO vulnerability_sla
(cve_id, finding_id, asset_hostname, severity, cvss_score,
discovered_at, sla_deadline, owner_email, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open')""",
(
cve_id,
row.get("finding_id", f"{cve_id}_{row.get('host', 'unknown')}"),
row.get("host", "unknown"),
severity,
cvss,
discovered_dt.isoformat(),
deadline.isoformat(),
row.get("owner_email", ""),
),
)
imported += 1
conn.commit()
print(f"[+] Imported {imported} findings with SLA deadlines")
return imported
def check_sla_breaches(conn):
"""Check all open findings for SLA status and return categorized results."""
now = datetime.now(timezone.utc)
cursor = conn.execute(
"SELECT * FROM vulnerability_sla WHERE status = 'open'"
)
columns = [d[0] for d in cursor.description]
breached = []
approaching = []
within_sla = []
for row in cursor.fetchall():
record = dict(zip(columns, row))
deadline = datetime.fromisoformat(record["sla_deadline"])
discovered = datetime.fromisoformat(record["discovered_at"])
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=timezone.utc)
if discovered.tzinfo is None:
discovered = discovered.replace(tzinfo=timezone.utc)
if now > deadline:
overdue_days = (now - deadline).days
record["sla_status"] = f"breached_{overdue_days}d_overdue"
record["overdue_days"] = overdue_days
breached.append(record)
else:
total_window = (deadline - discovered).total_seconds()
elapsed = (now - discovered).total_seconds()
pct = (elapsed / total_window * 100) if total_window > 0 else 0
if pct >= 80:
record["sla_status"] = "approaching_breach"
record["pct_elapsed"] = round(pct, 1)
approaching.append(record)
else:
record["sla_status"] = "within_sla"
record["pct_elapsed"] = round(pct, 1)
within_sla.append(record)
return {"breached": breached, "approaching": approaching, "within_sla": within_sla}
def send_slack_notification(webhook_url, findings, alert_type):
"""Send SLA alert to Slack channel."""
if not webhook_url or not findings:
return
color_map = {"breached": "#FF0000", "approaching": "#FFA500", "within_sla": "#36A64F"}
for finding in findings[:10]:
payload = {
"attachments": [
{
"color": color_map.get(alert_type, "#808080"),
"title": f"SLA {alert_type.upper()}: {finding['cve_id']}",
"fields": [
{"title": "Severity", "value": finding["severity"], "short": True},
{"title": "CVSS", "value": str(finding["cvss_score"]), "short": True},
{"title": "Asset", "value": finding["asset_hostname"], "short": True},
{"title": "Deadline", "value": finding["sla_deadline"][:16], "short": True},
{"title": "Owner", "value": finding.get("owner_email", "Unassigned"), "short": True},
{"title": "Status", "value": finding["sla_status"], "short": True},
],
}
]
}
try:
requests.post(webhook_url, json=payload, timeout=10)
except requests.RequestException as e:
print(f"[-] Slack notification failed: {e}")
def send_email_notification(smtp_config, findings, alert_type):
"""Send SLA alert via email."""
if not smtp_config or not findings:
return
recipients = set()
for f in findings:
if f.get("owner_email"):
recipients.add(f["owner_email"])
if not recipients:
return
body_lines = [f"Vulnerability SLA {alert_type.upper()} Report", "=" * 50, ""]
for f in findings:
body_lines.extend([
f"CVE: {f['cve_id']}",
f"Severity: {f['severity']} (CVSS {f['cvss_score']})",
f"Asset: {f['asset_hostname']}",
f"Deadline: {f['sla_deadline'][:16]}",
f"Status: {f['sla_status']}",
"-" * 40,
])
msg = MIMEMultipart()
msg["Subject"] = f"[VULN SLA {alert_type.upper()}] {len(findings)} findings require attention"
msg["From"] = smtp_config.get("from_address", "vuln-alerts@company.com")
msg["To"] = ", ".join(recipients)
msg.attach(MIMEText("\n".join(body_lines), "plain"))
try:
with smtplib.SMTP(smtp_config["host"], smtp_config.get("port", 587)) as server:
server.starttls()
if smtp_config.get("username"):
server.login(smtp_config["username"], smtp_config["password"])
server.send_message(msg)
print(f"[+] Email sent to {len(recipients)} recipients")
except Exception as e:
print(f"[-] Email notification failed: {e}")
def generate_compliance_report(conn, output_path, period_days=30):
"""Generate SLA compliance report."""
cutoff = (datetime.now(timezone.utc) - timedelta(days=period_days)).isoformat()
cursor = conn.execute(
"""SELECT severity,
COUNT(*) as total,
SUM(CASE WHEN remediated_at IS NOT NULL
AND remediated_at <= sla_deadline THEN 1 ELSE 0 END) as within_sla,
SUM(CASE WHEN remediated_at IS NOT NULL
AND remediated_at > sla_deadline THEN 1 ELSE 0 END) as breached_remediated,
SUM(CASE WHEN status = 'open'
AND datetime('now') > sla_deadline THEN 1 ELSE 0 END) as currently_overdue
FROM vulnerability_sla
WHERE discovered_at >= ?
GROUP BY severity
ORDER BY CASE severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4 END""",
(cutoff,),
)
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"period_days": period_days,
"tiers": [],
}
total_findings = 0
total_compliant = 0
for row in cursor.fetchall():
severity, total, within_sla, breached_remediated, currently_overdue = row
compliance_rate = (within_sla / total * 100) if total > 0 else 0
report["tiers"].append({
"severity": severity,
"total": total,
"within_sla": within_sla,
"breached_remediated": breached_remediated,
"currently_overdue": currently_overdue,
"compliance_rate": round(compliance_rate, 1),
})
total_findings += total
total_compliant += within_sla
report["overall_compliance"] = round(
(total_compliant / total_findings * 100) if total_findings > 0 else 0, 1
)
report["total_findings"] = total_findings
with open(output_path, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
print(f"[+] Compliance report written to {output_path}")
print(f" Overall SLA Compliance: {report['overall_compliance']}%")
return report
def main():
parser = argparse.ArgumentParser(description="Vulnerability SLA Breach Alerting System")
parser.add_argument("--import-findings", help="Import findings from CSV")
parser.add_argument("--check-sla", action="store_true", help="Check for SLA breaches")
parser.add_argument("--report", action="store_true", help="Generate compliance report")
parser.add_argument("--output", default="sla_compliance_report.json", help="Report output path")
parser.add_argument("--period", type=int, default=30, help="Report period in days")
parser.add_argument("--slack-webhook", help="Slack webhook URL for notifications")
parser.add_argument("--db", default=DB_PATH, help="Database path")
args = parser.parse_args()
conn = init_db(args.db)
if args.import_findings:
import_findings(conn, args.import_findings)
if args.check_sla:
results = check_sla_breaches(conn)
print(f"\n[*] SLA Check Results:")
print(f" Breached: {len(results['breached'])}")
print(f" Approaching: {len(results['approaching'])}")
print(f" Within SLA: {len(results['within_sla'])}")
if args.slack_webhook:
if results["breached"]:
send_slack_notification(args.slack_webhook, results["breached"], "breached")
if results["approaching"]:
send_slack_notification(args.slack_webhook, results["approaching"], "approaching")
if args.report:
generate_compliance_report(conn, args.output, args.period)
conn.close()
if __name__ == "__main__":
main()