Files
agentic-soc-platform/backend/apps/dashboard/views.py

546 lines
19 KiB
Python

import logging
import re
from collections import Counter
from datetime import timedelta
from django.db import connection
from django.db.models import Case as DbCase, Count, DateTimeField, FloatField, Min, Q, Sum, Value, When
from django.db.models.functions import Coalesce, TruncDay, TruncHour
from django.utils import timezone
from django_redis.exceptions import ConnectionInterrupted
from redis.exceptions import RedisError
from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.alerts.models import Alert, AlertStatus, AlertTactic
from apps.artifacts.models import Artifact
from apps.cases.models import Case, CaseStatus
from apps.enrichments.models import Enrichment
from apps.knowledge.models import Knowledge, KnowledgeSource
from apps.playbooks.models import Playbook, PlaybookJobStatus
from .cache import get_cached_dashboard_overview
logger = logging.getLogger(__name__)
WINDOW_DELTAS = {
"24h": timedelta(hours=24),
"7d": timedelta(days=7),
"30d": timedelta(days=30),
}
OPEN_CASE_STATUSES = (CaseStatus.NEW, CaseStatus.IN_PROGRESS, CaseStatus.ON_HOLD)
ACTIVE_ALERT_STATUSES = (AlertStatus.NEW, AlertStatus.IN_PROGRESS)
ACTIVE_PLAYBOOK_STATUSES = (PlaybookJobStatus.RUNNING, PlaybookJobStatus.FAILED)
IMPORTANT_LEVELS = ("Critical", "High")
SEVERITY_ORDER = ("Critical", "High", "Medium", "Low", "Informational", "Info", "Unknown")
CASE_STATUS_ORDER = ("New", "In Progress", "On Hold", "Resolved", "Closed")
PLAYBOOK_STATUS_ORDER = ("Success", "Failed", "Pending", "Running")
SEVERITY_WEIGHTS = {
"Critical": 10,
"High": 6,
"Medium": 3,
"Low": 1,
"Informational": 0.5,
"Info": 0.5,
}
KEYWORD_STOP_WORDS = {
"and",
"for",
"from",
"the",
"with",
"after",
"before",
"followed",
"indicators",
"pattern",
"alert",
"case",
"mock",
"unknown",
}
KEYWORD_AGGREGATION_LIMIT = 120
def iso_datetime(value):
return value.isoformat() if value else None
def percentage(numerator, denominator):
if denominator == 0:
return None
return round((numerator / denominator) * 100, 1)
def severity_weight(value):
return SEVERITY_WEIGHTS.get(value or "", 0)
def mean_duration_result(row):
seconds, sample_count = row or (None, 0)
return {
"seconds": int(seconds) if seconds is not None and sample_count else None,
"sample_count": sample_count or 0,
}
def alert_event_queryset(start):
return Alert.objects.annotate(
event_time=Coalesce(
"last_seen_time",
"first_seen_time",
"created_at",
output_field=DateTimeField(),
)
).filter(event_time__gte=start)
def case_workload_queryset(start):
return Case.objects.filter(
Q(status__in=OPEN_CASE_STATUSES)
| Q(created_at__gte=start)
| Q(updated_at__gte=start)
| Q(acknowledged_time__gte=start)
| Q(closed_time__gte=start)
).distinct()
def ordered_distribution(queryset, field, labels):
rows = queryset.order_by().values(field).annotate(value=Count("id"))
counts = {
row[field] or "Unknown": row["value"]
for row in rows
}
return [{"label": label, "value": counts.get(label, 0)} for label in labels]
def top_distribution(queryset, field, limit=8):
return [
{"label": row[field], "value": row["value"]}
for row in queryset.exclude(**{field: ""})
.values(field)
.annotate(value=Count("id"))
.order_by("-value", field)[:limit]
]
def add_keyword(counter, value, weight=1, split=False):
if value in (None, ""):
return
if isinstance(value, (list, tuple, set)):
for item in value:
add_keyword(counter, item, weight=weight, split=split)
return
text = re.sub(r"\s+", " ", str(value).strip())
if not text:
return
if split:
for token in re.findall(r"[A-Za-z][A-Za-z0-9+._-]{2,}", text):
normalized = token.strip("._-").lower()
if len(normalized) >= 3 and normalized not in KEYWORD_STOP_WORDS:
counter[normalized] += weight
return
normalized = text.lower()
if len(normalized) >= 3 and normalized not in KEYWORD_STOP_WORDS:
counter[text] += weight
def keyword_weight(value):
return max(1, int(severity_weight(value) or 1))
def category_keyword_weight(value):
return max(1, keyword_weight(value) // 2)
def weighted_severity_sum(queryset, multiplier=1):
severity_score = DbCase(
*[
When(severity=severity, then=Value(float(weight) * multiplier))
for severity, weight in SEVERITY_WEIGHTS.items()
],
default=Value(0.0),
output_field=FloatField(),
)
return queryset.aggregate(total=Sum(severity_score))["total"] or 0
def add_grouped_keywords(counter, queryset, field, weight_function):
for row in queryset.exclude(**{field: ""}).values(field, "severity").annotate(count=Count("id")).order_by():
add_keyword(counter, row[field], weight=row["count"] * weight_function(row["severity"]))
def add_title_tokens(counter, queryset, field):
sql, params = queryset.order_by().values(field).query.sql_with_params()
stop_words = list(KEYWORD_STOP_WORDS)
with connection.cursor() as cursor:
cursor.execute(
f"""
SELECT token, COUNT(*) AS value
FROM (
SELECT lower(trim(both '._-' FROM raw_token.value)) AS token
FROM ({sql}) AS source
CROSS JOIN LATERAL regexp_split_to_table(source.{field}, '[^A-Za-z0-9+._-]+') AS raw_token(value)
) AS tokens
WHERE length(token) >= 3
AND token ~ '^[a-z][a-z0-9+._-]*$'
AND NOT (token = ANY(%s))
GROUP BY token
ORDER BY value DESC, token
LIMIT %s
""",
[*params, stop_words, KEYWORD_AGGREGATION_LIMIT],
)
for token, value in cursor.fetchall():
counter[token] += value
def add_json_array_keywords(counter, queryset, field):
sql, params = queryset.order_by().values(field, "severity").query.sql_with_params()
severity_cases = " ".join(
"WHEN severity = %s THEN %s"
for _severity, _weight in SEVERITY_WEIGHTS.items()
)
severity_params = [
item
for severity, weight in SEVERITY_WEIGHTS.items()
for item in (severity, keyword_weight(severity))
]
with connection.cursor() as cursor:
cursor.execute(
f"""
SELECT value, SUM(weight) AS score
FROM (
SELECT
jsonb_array_elements_text(source.{field}) AS value,
CASE {severity_cases} ELSE 1 END AS weight
FROM ({sql}) AS source
) AS tokens
WHERE value <> ''
GROUP BY value
ORDER BY score DESC, value
LIMIT %s
""",
[*severity_params, *params, KEYWORD_AGGREGATION_LIMIT],
)
for value, score in cursor.fetchall():
add_keyword(counter, value, weight=score)
def build_threat_keywords(window_cases, window_alerts):
counter = Counter()
add_title_tokens(counter, window_alerts, "title")
add_json_array_keywords(counter, window_alerts, "labels")
add_grouped_keywords(counter, window_alerts, "tactic", keyword_weight)
add_grouped_keywords(counter, window_alerts, "technique", keyword_weight)
add_grouped_keywords(counter, window_alerts, "product_category", category_keyword_weight)
add_grouped_keywords(counter, window_alerts, "product_name", lambda _severity: 1)
add_title_tokens(counter, window_cases, "title")
add_json_array_keywords(counter, window_cases, "tags")
add_grouped_keywords(counter, window_cases, "category", category_keyword_weight)
return [
{"text": text, "value": value}
for text, value in counter.most_common(36)
]
def build_mitre_severity_heatmap(window_alerts):
tactics = [tactic.value for tactic in AlertTactic]
severity_labels = ("Critical", "High", "Medium", "Low", "Informational", "Unknown")
rows = window_alerts.filter(tactic__in=tactics).order_by().values(
"tactic",
"severity",
).annotate(value=Count("id"))
counts = {
(row["tactic"], row["severity"] or "Unknown"): row["value"]
for row in rows
}
return [
{
"tactic": tactic,
"severity": severity,
"value": counts.get((tactic, severity), 0),
}
for severity in severity_labels
for tactic in tactics
]
def build_alert_trend(window, start, generated_at):
trunc = TruncHour if window == "24h" else TruncDay
event_queryset = alert_event_queryset(start)
rows = event_queryset.annotate(
bucket=trunc("event_time")
).values("bucket").annotate(value=Count("id")).order_by("bucket")
if window == "24h":
bucket_count = 24
step = timedelta(hours=1)
first_bucket = generated_at.replace(minute=0, second=0, microsecond=0) - step * (bucket_count - 1)
label_format = "%H:%M"
else:
bucket_count = 7 if window == "7d" else 30
step = timedelta(days=1)
first_bucket = generated_at.replace(hour=0, minute=0, second=0, microsecond=0) - step * (bucket_count - 1)
label_format = "%m-%d"
counts = {row["bucket"].strftime(label_format): row["value"] for row in rows}
trend = []
for index in range(bucket_count):
bucket = first_bucket + step * index
label = bucket.strftime(label_format)
trend.append({
"time": iso_datetime(bucket),
"label": label,
"value": counts.get(label, 0),
})
return trend
def build_mean_times(start):
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT ROUND(EXTRACT(EPOCH FROM AVG(created_at - first_alert_seen_time)))::int, COUNT(*)::int
FROM (
SELECT cases.id, cases.created_at, MIN(alerts.first_seen_time) AS first_alert_seen_time
FROM cases
LEFT JOIN alerts ON alerts.case_id = cases.id
WHERE cases.created_at >= %s
GROUP BY cases.id, cases.created_at
) AS detected_cases
WHERE first_alert_seen_time IS NOT NULL
AND created_at >= first_alert_seen_time
""",
[start],
)
mttd = mean_duration_result(cursor.fetchone())
cursor.execute(
"""
SELECT ROUND(EXTRACT(EPOCH FROM AVG(acknowledged_time - created_at)))::int, COUNT(*)::int
FROM cases
WHERE acknowledged_time >= %s
AND acknowledged_time IS NOT NULL
AND acknowledged_time >= created_at
""",
[start],
)
mtta = mean_duration_result(cursor.fetchone())
cursor.execute(
"""
SELECT ROUND(EXTRACT(EPOCH FROM AVG(closed_time - acknowledged_time)))::int, COUNT(*)::int
FROM cases
WHERE closed_time >= %s
AND closed_time IS NOT NULL
AND acknowledged_time IS NOT NULL
AND closed_time >= acknowledged_time
""",
[start],
)
mttr = mean_duration_result(cursor.fetchone())
return {
"mttd": mttd,
"mtta": mtta,
"mttr": mttr,
}
def build_active_risk_index(window_cases, window_alerts, window_playbooks):
case_score = weighted_severity_sum(window_cases.filter(status__in=OPEN_CASE_STATUSES), multiplier=2)
alert_score = weighted_severity_sum(window_alerts.filter(status__in=ACTIVE_ALERT_STATUSES))
playbook_score = (
window_playbooks.filter(job_status=PlaybookJobStatus.FAILED).count() * 4
+ window_playbooks.filter(job_status=PlaybookJobStatus.RUNNING).count()
)
return min(100, round(case_score + alert_score + playbook_score))
def build_top_risk_artifacts(window_alerts):
severity_score = DbCase(
*[
When(alert__severity=severity, then=Value(float(weight)))
for severity, weight in SEVERITY_WEIGHTS.items()
],
default=Value(0.0),
output_field=FloatField(),
)
rows = (
Alert.artifacts.through.objects
.filter(alert_id__in=window_alerts.order_by().values("id"))
.values(
"artifact_id",
"artifact__name",
"artifact__type",
"artifact__role",
"artifact__value",
)
.annotate(
risk_score=Sum(severity_score),
alert_count=Count("alert_id"),
)
.order_by("-risk_score", "-alert_count", "-artifact__value")[:8]
)
return [
{
"id": str(row["artifact_id"]),
"name": row["artifact__name"],
"type": row["artifact__type"],
"role": row["artifact__role"],
"value": row["artifact__value"],
"risk_score": round(row["risk_score"] or 0, 1),
"alert_count": row["alert_count"],
}
for row in rows
]
def build_recent_highlights(window_cases, window_alerts):
highlights = []
for case in window_cases.filter(
Q(severity__in=IMPORTANT_LEVELS) | Q(priority__in=IMPORTANT_LEVELS)
).order_by("-created_at")[:8]:
highlights.append({
"id": str(case.id),
"kind": "case",
"readable_id": case.case_id,
"title": case.title,
"severity": case.severity,
"status": case.status,
"timestamp": case.created_at,
"subtitle": case.category or "Case",
})
for alert in window_alerts.filter(
Q(severity__in=IMPORTANT_LEVELS) | Q(risk_level__in=IMPORTANT_LEVELS)
).order_by("-event_time")[:8]:
highlights.append({
"id": str(alert.id),
"kind": "alert",
"readable_id": alert.alert_id,
"title": alert.title,
"severity": alert.severity,
"status": alert.status,
"timestamp": alert.event_time,
"subtitle": alert.tactic or alert.product_category or alert.product_name or "Alert",
})
highlights.sort(key=lambda item: item["timestamp"], reverse=True)
return [
{
**item,
"timestamp": iso_datetime(item["timestamp"]),
}
for item in highlights[:8]
]
def build_dashboard_overview(window):
generated_at = timezone.now()
start = generated_at - WINDOW_DELTAS[window]
window_cases = Case.objects.filter(created_at__gte=start)
workload_cases = case_workload_queryset(start)
open_cases = Case.objects.filter(status__in=OPEN_CASE_STATUSES)
window_alerts = alert_event_queryset(start)
window_playbooks = Playbook.objects.filter(created_at__gte=start)
playbook_status_counts = {
row["job_status"] or "Unknown": row["value"]
for row in window_playbooks.values("job_status").annotate(value=Count("id"))
}
successful_playbooks = playbook_status_counts.get(PlaybookJobStatus.SUCCESS, 0)
failed_playbooks = playbook_status_counts.get(PlaybookJobStatus.FAILED, 0)
completed_playbooks = successful_playbooks + failed_playbooks
total_cases = window_cases.count()
cases_with_enrichments = window_cases.filter(enrichments__isnull=False).distinct().count()
cases_with_playbooks = window_cases.filter(playbooks__isnull=False).distinct().count()
summary = {
"active_risk_index": build_active_risk_index(open_cases, window_alerts, window_playbooks),
"total_cases": total_cases,
"total_alerts": window_alerts.count(),
"total_artifacts": Artifact.objects.filter(alerts__in=window_alerts).distinct().count(),
"total_enrichments": Enrichment.objects.filter(created_at__gte=start).count(),
"total_knowledge": Knowledge.objects.filter(created_at__gte=start).count(),
"open_cases": open_cases.count(),
"open_critical_cases": open_cases.filter(severity="Critical").count(),
"critical_high_alerts": window_alerts.filter(severity__in=IMPORTANT_LEVELS).count(),
"running_playbooks": playbook_status_counts.get(PlaybookJobStatus.RUNNING, 0),
"failed_playbooks": failed_playbooks,
"automation_success_rate": percentage(successful_playbooks, completed_playbooks),
}
coverage = {
"enrichment_coverage": percentage(cases_with_enrichments, total_cases),
"playbook_coverage": percentage(cases_with_playbooks, total_cases),
"knowledge_records": Knowledge.objects.filter(created_at__gte=start, source=KnowledgeSource.CASE).count(),
"artifact_records": summary["total_artifacts"],
"enrichment_records": summary["total_enrichments"],
}
automation = [
{"label": label, "value": playbook_status_counts.get(label, 0)}
for label in PLAYBOOK_STATUS_ORDER
]
return {
"window": window,
"window_start": iso_datetime(start),
"generated_at": iso_datetime(generated_at),
"summary": summary,
"mean_times": build_mean_times(start),
"alert_trend": build_alert_trend(window, start, generated_at),
"severity_distribution": ordered_distribution(window_alerts, "severity", SEVERITY_ORDER),
"case_status_mix": ordered_distribution(workload_cases, "status", CASE_STATUS_ORDER),
"product_category_distribution": top_distribution(window_alerts, "product_category"),
"mitre_tactics": top_distribution(window_alerts, "tactic"),
"mitre_severity_heatmap": build_mitre_severity_heatmap(window_alerts),
"threat_keywords": build_threat_keywords(workload_cases, window_alerts),
"automation": automation,
"coverage": coverage,
"top_risk_artifacts": build_top_risk_artifacts(window_alerts),
"recent_highlights": build_recent_highlights(workload_cases, window_alerts),
}
class DashboardOverviewView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
window = request.query_params.get("window", "7d")
if window not in WINDOW_DELTAS:
return Response(
{"window": [f"Unsupported window. Use one of: {', '.join(WINDOW_DELTAS)}."]},
status=status.HTTP_400_BAD_REQUEST,
)
try:
overview = get_cached_dashboard_overview(window)
except (ConnectionInterrupted, RedisError, KeyError, TypeError, ValueError):
logger.exception("Dashboard cache read failed: window=%s", window)
return Response(
{"detail": "Dashboard cache is temporarily unavailable."},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
if overview is None:
return Response(
{"detail": "Dashboard cache is not ready. Wait for the background refresh worker."},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return Response(overview)