mirror of
https://github.com/FunnyWolf/agentic-soc-platform.git
synced 2026-08-22 13:12:56 +02:00
546 lines
19 KiB
Python
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)
|