Skip to content

Commit 8a388db

Browse files
Romil2112claude
andcommitted
Add SLA-breach KPI, pytest integration suite, and CI
- SLA: per-severity response targets (CRITICAL 15m, HIGH 1h, MEDIUM 4h, LOW 24h); /api/stats now returns breach count/rate/per-severity; dashboard shows a SLA Breach Rate stat card - tests/: 9 pytest integration tests (Flask test client + PostgreSQL) covering pages, alert queue, stats, SLA metrics, and classify validation/404 - .github/workflows/ci.yml: GitHub Actions with a Postgres service container - requirements-dev.txt Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 80cc92b commit 8a388db

8 files changed

Lines changed: 248 additions & 6 deletions

File tree

.github/workflows/ci.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
services:
14+
postgres:
15+
image: postgres:16
16+
env:
17+
POSTGRES_USER: postgres
18+
POSTGRES_PASSWORD: postgres
19+
POSTGRES_DB: soc_test
20+
options: >-
21+
--health-cmd pg_isready
22+
--health-interval 10s
23+
--health-timeout 5s
24+
--health-retries 5
25+
ports:
26+
- 5432:5432
27+
28+
env:
29+
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/soc_test
30+
31+
steps:
32+
- uses: actions/checkout@v4
33+
34+
- name: Set up Python 3.12
35+
uses: actions/setup-python@v5
36+
with:
37+
python-version: "3.12"
38+
39+
- name: Install dependencies
40+
run: pip install -r requirements-dev.txt
41+
42+
- name: Run tests
43+
run: pytest tests/ -v

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
Flask-based Security Operations Center analyst dashboard with real-time alert queue, MTTR tracking, and Chart.js visualizations.
44

5+
![CI](https://github.com/Romil2112/SOC-Dashboard/actions/workflows/ci.yml/badge.svg)
56
![Python](https://img.shields.io/badge/Python-3.12-3776AB?logo=python&logoColor=white)
67
![Flask](https://img.shields.io/badge/Flask-3.x-000000?logo=flask&logoColor=white)
78
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169E1?logo=postgresql&logoColor=white)
@@ -38,6 +39,7 @@ as a clear, hands-on teaching tool for the alert-triage workflow.
3839
- **One-click triage:** True Positive / False Positive / Escalate buttons per alert
3940
- **Analyst name saved to `localStorage`** — no login required for the demo
4041
- **MTTR tracking** per analyst per day, with a 7-day trend chart
42+
- **SLA breach tracking:** per-severity response targets (CRITICAL 15m → LOW 24h) with a live breach-rate KPI
4143
- **Color-coded performance table:** green &lt; 5 min, yellow 5–15 min, red &gt; 15 min
4244
- **Alerts by category** doughnut chart (brute force, malware, phishing, port scan, anomaly)
4345
- **Alerts by severity** bar chart (CRITICAL / HIGH / MEDIUM / LOW)
@@ -72,7 +74,8 @@ as a clear, hands-on teaching tool for the alert-triage workflow.
7274
| PostgreSQL | Schema design, JSONB-ready tables, timestamp-based MTTR aggregation |
7375
| JavaScript | Fetch API polling, localStorage, dynamic DOM updates, Chart.js integration |
7476
| Bootstrap 5 | Responsive dark-themed UI, badge system, card layout |
75-
| SOC Domain Knowledge | Alert triage workflow, MTTR KPI, severity classification, analyst performance tracking |
77+
| SOC Domain Knowledge | Alert triage workflow, MTTR + SLA-breach KPIs, severity classification, analyst performance tracking |
78+
| Testing / CI | pytest integration suite (Flask test client + PostgreSQL) run via GitHub Actions with a Postgres service container |
7679
| Docker | Multi-service Compose with health-checked PostgreSQL and volume mounts |
7780
| Agentic AI Development | Built end-to-end using Claude Code with structured prompt engineering |
7881

app.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""SOC Analyst Dashboard — Flask + psycopg2 (no ORM)."""
22
import os
3+
from datetime import datetime, timezone
34

45
import psycopg2
56
import psycopg2.extras
@@ -24,6 +25,42 @@
2425
"escalate": "escalated",
2526
}
2627

28+
# Per-severity response-time SLA targets (seconds). An alert breaches SLA when
29+
# its time-to-triage (or current age, if still open) exceeds the target.
30+
SLA_SECONDS = {
31+
"CRITICAL": 15 * 60, # 15 minutes
32+
"HIGH": 60 * 60, # 1 hour
33+
"MEDIUM": 4 * 60 * 60, # 4 hours
34+
"LOW": 24 * 60 * 60, # 24 hours
35+
}
36+
37+
38+
def compute_sla(rows):
39+
"""Given rows of {severity, resp, created_at}, return SLA breach metrics.
40+
41+
resp = recorded triage response time (seconds) or None if still open
42+
(in which case the alert's current age is used).
43+
"""
44+
now_ts = datetime.now(timezone.utc)
45+
considered = breaches = 0
46+
by_severity = {}
47+
for r in rows:
48+
target = SLA_SECONDS.get(r["severity"])
49+
if target is None:
50+
continue
51+
considered += 1
52+
elapsed = r["resp"] if r["resp"] is not None else (now_ts - r["created_at"]).total_seconds()
53+
if elapsed > target:
54+
breaches += 1
55+
by_severity[r["severity"]] = by_severity.get(r["severity"], 0) + 1
56+
rate = round(100 * breaches / considered, 1) if considered else 0.0
57+
return {
58+
"breaches": breaches,
59+
"considered": considered,
60+
"breach_rate": rate,
61+
"by_severity": by_severity,
62+
}
63+
2764

2865
def get_conn():
2966
"""Open a new connection with dict-style rows."""
@@ -187,6 +224,21 @@ def api_stats():
187224
)
188225
mttr_by_analyst = [serialize(r) for r in cur.fetchall()]
189226

227+
# SLA inputs: each alert's severity, age, and (earliest) triage response time.
228+
cur.execute(
229+
"""
230+
SELECT a.severity,
231+
a.created_at,
232+
(SELECT min(aa.response_time_seconds)
233+
FROM analyst_actions aa
234+
WHERE aa.alert_id = a.id) AS resp
235+
FROM alerts a
236+
"""
237+
)
238+
sla_rows = cur.fetchall()
239+
240+
sla = compute_sla(sla_rows)
241+
190242
return jsonify(
191243
{
192244
"total": total,
@@ -195,6 +247,7 @@ def api_stats():
195247
"by_category": by_category,
196248
"by_severity": by_severity,
197249
"mttr_by_analyst": mttr_by_analyst,
250+
"sla": sla,
198251
}
199252
)
200253

requirements-dev.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-r requirements.txt
2+
pytest>=7.0

static/dashboard.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ async function loadStats() {
125125

126126
setText("stat-closed-today", closedToday);
127127
setText("stat-mttr-today", mttrMin);
128+
setText("stat-sla-breach", stats.sla ? `${stats.sla.breach_rate}%` : "—");
128129

129130
updateCharts(stats);
130131
return stats;

templates/dashboard.html

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,47 @@
44

55
{% block content %}
66
<!-- Row 1: stat cards -->
7-
<div class="row g-3 mb-4">
8-
<div class="col-md-3">
7+
<div class="row row-cols-2 row-cols-md-5 g-3 mb-4">
8+
<div class="col">
99
<div class="card stat-card bg-dark border-secondary text-light h-100">
1010
<div class="card-body">
1111
<div class="text-secondary small text-uppercase">Total Alerts</div>
1212
<div class="display-6" id="stat-total"></div>
1313
</div>
1414
</div>
1515
</div>
16-
<div class="col-md-3">
16+
<div class="col">
1717
<div class="card stat-card bg-dark border-secondary text-light h-100">
1818
<div class="card-body">
1919
<div class="text-secondary small text-uppercase">Open</div>
2020
<div class="display-6 text-warning" id="stat-open"></div>
2121
</div>
2222
</div>
2323
</div>
24-
<div class="col-md-3">
24+
<div class="col">
2525
<div class="card stat-card bg-dark border-secondary text-light h-100">
2626
<div class="card-body">
2727
<div class="text-secondary small text-uppercase">Closed Today</div>
2828
<div class="display-6 text-info" id="stat-closed-today"></div>
2929
</div>
3030
</div>
3131
</div>
32-
<div class="col-md-3">
32+
<div class="col">
3333
<div class="card stat-card bg-dark border-secondary text-light h-100">
3434
<div class="card-body">
3535
<div class="text-secondary small text-uppercase">Avg MTTR Today (min)</div>
3636
<div class="display-6 text-success" id="stat-mttr-today"></div>
3737
</div>
3838
</div>
3939
</div>
40+
<div class="col">
41+
<div class="card stat-card bg-dark border-secondary text-light h-100">
42+
<div class="card-body">
43+
<div class="text-secondary small text-uppercase">SLA Breach Rate</div>
44+
<div class="display-6 text-danger" id="stat-sla-breach"></div>
45+
</div>
46+
</div>
47+
</div>
4048
</div>
4149

4250
<!-- Row 2: charts -->

tests/conftest.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Pytest fixtures: a fresh schema + deterministic alert data per test.
2+
3+
Requires a reachable PostgreSQL. Locally:
4+
docker run -d --name soc-pg -e POSTGRES_PASSWORD=postgres \
5+
-e POSTGRES_DB=soc_test -p 5433:5432 postgres:16
6+
export DATABASE_URL=postgresql://postgres:postgres@localhost:5433/soc_test
7+
CI sets DATABASE_URL to a postgres service container.
8+
"""
9+
import os
10+
import sys
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
ROOT = Path(__file__).resolve().parents[1]
16+
sys.path.insert(0, str(ROOT))
17+
18+
# Must be set before importing app (it reads DATABASE_URL at import time).
19+
os.environ.setdefault(
20+
"DATABASE_URL", "postgresql://postgres:postgres@localhost:5433/soc_test"
21+
)
22+
23+
import psycopg2 # noqa: E402
24+
25+
SCHEMA = (ROOT / "schema.sql").read_text()
26+
27+
# Deterministic fixtures. SLA targets: CRITICAL 900s, HIGH 3600s, LOW 86400s.
28+
# alert 1: CRITICAL, triaged in 100s -> within SLA
29+
# alert 2: CRITICAL, triaged in 2000s -> BREACH
30+
# alert 3: LOW, open, aged 2 days -> BREACH (overdue)
31+
# alert 4: HIGH, open, just created -> within SLA
32+
# => 2 breaches / 4 considered = 50%
33+
FIXTURES = """
34+
INSERT INTO alerts (id, title, category, severity, status, created_at) VALUES
35+
(1, 'crit fast', 'brute_force', 'CRITICAL', 'true_positive', now() - interval '1 hour'),
36+
(2, 'crit slow', 'malware', 'CRITICAL', 'true_positive', now() - interval '1 hour'),
37+
(3, 'low old open', 'anomaly', 'LOW', 'open', now() - interval '2 days'),
38+
(4, 'high new open', 'phishing', 'HIGH', 'open', now());
39+
INSERT INTO analyst_actions (alert_id, analyst_name, action, response_time_seconds) VALUES
40+
(1, 'alice', 'classify_tp', 100),
41+
(2, 'bob', 'classify_tp', 2000);
42+
"""
43+
44+
45+
@pytest.fixture()
46+
def client():
47+
import app as soc_app
48+
49+
conn = psycopg2.connect(os.environ["DATABASE_URL"])
50+
conn.autocommit = True
51+
with conn.cursor() as cur:
52+
cur.execute(SCHEMA)
53+
cur.execute(FIXTURES)
54+
conn.close()
55+
56+
soc_app.app.config.update(TESTING=True)
57+
return soc_app.app.test_client()

tests/test_app.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Integration tests for the SOC dashboard API (Flask test client + Postgres)."""
2+
import json
3+
4+
5+
def test_pages_render(client):
6+
assert client.get("/").status_code == 200
7+
assert client.get("/analyst").status_code == 200
8+
9+
10+
def test_open_alerts_only_and_severity_sorted(client):
11+
rows = client.get("/api/alerts").get_json()
12+
assert [r["id"] for r in rows] == [4, 3] # HIGH before LOW; only open
13+
assert all(r["status"] == "open" for r in rows)
14+
15+
16+
def test_all_alerts_returns_everything(client):
17+
rows = client.get("/api/alerts/all").get_json()
18+
assert len(rows) == 4
19+
20+
21+
def test_stats_counts(client):
22+
s = client.get("/api/stats").get_json()
23+
assert s["total"] == 4
24+
assert s["open"] == 2
25+
assert s["closed"] == 2
26+
assert s["by_severity"]["CRITICAL"] == 2
27+
28+
29+
def test_stats_sla_breach_metrics(client):
30+
sla = client.get("/api/stats").get_json()["sla"]
31+
assert sla["considered"] == 4
32+
assert sla["breaches"] == 2 # crit-slow + low-overdue
33+
assert sla["breach_rate"] == 50.0
34+
assert sla["by_severity"].get("CRITICAL") == 1
35+
assert sla["by_severity"].get("LOW") == 1
36+
37+
38+
def test_classify_updates_status_and_closes_alert(client):
39+
resp = client.post(
40+
"/api/alerts/4/classify",
41+
data=json.dumps({"analyst": "carol", "action": "classify_tp"}),
42+
content_type="application/json",
43+
)
44+
assert resp.status_code == 200
45+
assert resp.get_json()["status"] == "true_positive"
46+
# alert 4 should no longer appear in the open queue
47+
open_ids = [r["id"] for r in client.get("/api/alerts").get_json()]
48+
assert 4 not in open_ids
49+
50+
51+
def test_classify_requires_analyst(client):
52+
resp = client.post(
53+
"/api/alerts/4/classify",
54+
data=json.dumps({"action": "classify_tp"}),
55+
content_type="application/json",
56+
)
57+
assert resp.status_code == 400
58+
59+
60+
def test_classify_rejects_unknown_action(client):
61+
resp = client.post(
62+
"/api/alerts/4/classify",
63+
data=json.dumps({"analyst": "carol", "action": "nope"}),
64+
content_type="application/json",
65+
)
66+
assert resp.status_code == 400
67+
68+
69+
def test_classify_unknown_alert_404(client):
70+
resp = client.post(
71+
"/api/alerts/9999/classify",
72+
data=json.dumps({"analyst": "carol", "action": "classify_tp"}),
73+
content_type="application/json",
74+
)
75+
assert resp.status_code == 404

0 commit comments

Comments
 (0)