merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
decnet/web/router/attackers/__init__.py
Normal file
0
decnet/web/router/attackers/__init__.py
Normal file
34
decnet/web/router/attackers/api_get_attacker_artifacts.py
Normal file
34
decnet/web/router/attackers/api_get_attacker_artifacts.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.dependencies import require_viewer, repo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attackers/{uuid}/artifacts",
|
||||
tags=["Attacker Profiles"],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "Attacker not found"},
|
||||
},
|
||||
)
|
||||
@_traced("api.get_attacker_artifacts")
|
||||
async def get_attacker_artifacts(
|
||||
uuid: str,
|
||||
user: dict = Depends(require_viewer),
|
||||
) -> dict[str, Any]:
|
||||
"""List captured file-drop artifacts for an attacker (newest first).
|
||||
|
||||
Each entry is a `file_captured` log row — the frontend renders the
|
||||
badge/drawer using the same `fields` payload as /logs.
|
||||
"""
|
||||
attacker = await repo.get_attacker_by_uuid(uuid)
|
||||
if not attacker:
|
||||
raise HTTPException(status_code=404, detail="Attacker not found")
|
||||
rows = await repo.get_attacker_artifacts(uuid)
|
||||
return {"total": len(rows), "data": rows}
|
||||
42
decnet/web/router/attackers/api_get_attacker_commands.py
Normal file
42
decnet/web/router/attackers/api_get_attacker_commands.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.dependencies import require_viewer, repo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attackers/{uuid}/commands",
|
||||
tags=["Attacker Profiles"],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "Attacker not found"},
|
||||
422: {"description": "Query parameter validation error (limit/offset out of range or invalid)"},
|
||||
},
|
||||
)
|
||||
@_traced("api.get_attacker_commands")
|
||||
async def get_attacker_commands(
|
||||
uuid: str,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0, le=2147483647),
|
||||
service: Optional[str] = None,
|
||||
user: dict = Depends(require_viewer),
|
||||
) -> dict[str, Any]:
|
||||
"""Retrieve paginated commands for an attacker profile."""
|
||||
attacker = await repo.get_attacker_by_uuid(uuid)
|
||||
if not attacker:
|
||||
raise HTTPException(status_code=404, detail="Attacker not found")
|
||||
|
||||
def _norm(v: Optional[str]) -> Optional[str]:
|
||||
if v in (None, "null", "NULL", "undefined", ""):
|
||||
return None
|
||||
return v
|
||||
|
||||
result = await repo.get_attacker_commands(
|
||||
uuid=uuid, limit=limit, offset=offset, service=_norm(service),
|
||||
)
|
||||
return {"total": result["total"], "limit": limit, "offset": offset, "data": result["data"]}
|
||||
44
decnet/web/router/attackers/api_get_attacker_detail.py
Normal file
44
decnet/web/router/attackers/api_get_attacker_detail.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from decnet.correlation.event_kinds import bucket_services
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.dependencies import require_viewer, repo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attackers/{uuid}",
|
||||
tags=["Attacker Profiles"],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "Attacker not found"},
|
||||
},
|
||||
)
|
||||
@_traced("api.get_attacker_detail")
|
||||
async def get_attacker_detail(
|
||||
uuid: str,
|
||||
user: dict = Depends(require_viewer),
|
||||
) -> dict[str, Any]:
|
||||
"""Retrieve a single attacker profile by UUID (with behavior block)."""
|
||||
attacker = await repo.get_attacker_by_uuid(uuid)
|
||||
if not attacker:
|
||||
raise HTTPException(status_code=404, detail="Attacker not found")
|
||||
attacker["behavior"] = await repo.get_attacker_behavior(uuid)
|
||||
# Scanned vs. interacted-with — computed per-request from the log
|
||||
# stream, not persisted. Cheap (DISTINCT bounded by service ×
|
||||
# event_type cardinality), and changes to the classifier take effect
|
||||
# immediately without a profiler re-tick.
|
||||
pairs = await repo.get_attacker_service_activity(uuid)
|
||||
attacker["service_activity"] = bucket_services(pairs)
|
||||
# Attribution leaks — XFF / Forwarded / X-Real-IP mismatches captured
|
||||
# by the HTTP bounty extractor. Cap the returned list at 10 so a
|
||||
# rotation attack (100s of forged XFF values) doesn't flood the UI;
|
||||
# `ip_leaks_total` carries the unbounded count so the UI can render
|
||||
# a ROTATION DETECTED badge when the count crosses a threshold.
|
||||
attacker["ip_leaks"] = await repo.get_attacker_ip_leaks(uuid, limit=10)
|
||||
attacker["ip_leaks_total"] = await repo.count_attacker_ip_leaks(uuid)
|
||||
return attacker
|
||||
38
decnet/web/router/attackers/api_get_attacker_intel.py
Normal file
38
decnet/web/router/attackers/api_get_attacker_intel.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""GET /api/v1/attackers/{uuid}/intel — latest threat-intel row for an attacker."""
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.dependencies import repo, require_viewer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attackers/{uuid}/intel",
|
||||
tags=["Attacker Profiles"],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "No intel cached for this attacker"},
|
||||
},
|
||||
)
|
||||
@_traced("api.get_attacker_intel")
|
||||
async def get_attacker_intel(
|
||||
uuid: str,
|
||||
user: dict = Depends(require_viewer),
|
||||
) -> dict[str, Any]:
|
||||
"""Return the most recent cached threat-intel verdict for an attacker.
|
||||
|
||||
The row is populated out-of-band by the ``decnet enrich`` worker
|
||||
(typically within seconds of first observation, sub-second when the
|
||||
bus is healthy). 404 means either the worker has not run yet or the
|
||||
UUID does not correspond to an attacker DECNET has seen.
|
||||
"""
|
||||
record = await repo.get_attacker_intel_by_uuid(uuid)
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="No intel cached for this attacker",
|
||||
)
|
||||
return record
|
||||
37
decnet/web/router/attackers/api_get_attacker_mail.py
Normal file
37
decnet/web/router/attackers/api_get_attacker_mail.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.dependencies import require_admin, repo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attackers/{uuid}/mail",
|
||||
tags=["Attacker Profiles"],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Admin access required"},
|
||||
404: {"description": "Attacker not found"},
|
||||
},
|
||||
)
|
||||
@_traced("api.get_attacker_mail")
|
||||
async def get_attacker_mail(
|
||||
uuid: str,
|
||||
admin: dict = Depends(require_admin),
|
||||
) -> dict[str, Any]:
|
||||
"""List stored messages this attacker relayed via the SMTP honeypots.
|
||||
|
||||
Each entry is a ``message_stored`` log row — headers + attachment
|
||||
manifest live in ``fields``; the raw .eml bytes are fetched via
|
||||
``/artifacts/{decky}/{stored_as}?service=smtp`` (also admin-gated).
|
||||
Admin-only because message bodies are attacker-controlled content
|
||||
and may include phishing kits / malware droppers.
|
||||
"""
|
||||
attacker = await repo.get_attacker_by_uuid(uuid)
|
||||
if not attacker:
|
||||
raise HTTPException(status_code=404, detail="Attacker not found")
|
||||
rows = await repo.get_attacker_stored_mail(uuid)
|
||||
return {"total": len(rows), "data": rows}
|
||||
36
decnet/web/router/attackers/api_get_attacker_smtp_targets.py
Normal file
36
decnet/web/router/attackers/api_get_attacker_smtp_targets.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.dependencies import require_viewer, repo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attackers/{uuid}/smtp-targets",
|
||||
tags=["Attacker Profiles"],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "Attacker not found"},
|
||||
},
|
||||
)
|
||||
@_traced("api.get_attacker_smtp_targets")
|
||||
async def get_attacker_smtp_targets(
|
||||
uuid: str,
|
||||
user: dict = Depends(require_viewer),
|
||||
) -> dict[str, Any]:
|
||||
"""List victim domains this attacker targeted via the SMTP honeypots.
|
||||
|
||||
Rows are ordered by most-recent activity. Each row is one
|
||||
(attacker, domain) pair with a running count + first/last seen — no
|
||||
local-parts (user names) are ever stored, so this is safe to show
|
||||
to any viewer role.
|
||||
"""
|
||||
attacker = await repo.get_attacker_by_uuid(uuid)
|
||||
if not attacker:
|
||||
raise HTTPException(status_code=404, detail="Attacker not found")
|
||||
rows = await repo.list_smtp_targets(uuid)
|
||||
return {"total": len(rows), "data": rows}
|
||||
34
decnet/web/router/attackers/api_get_attacker_transcripts.py
Normal file
34
decnet/web/router/attackers/api_get_attacker_transcripts.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.dependencies import require_viewer, repo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attackers/{uuid}/transcripts",
|
||||
tags=["Attacker Profiles"],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
404: {"description": "Attacker not found"},
|
||||
},
|
||||
)
|
||||
@_traced("api.get_attacker_transcripts")
|
||||
async def get_attacker_transcripts(
|
||||
uuid: str,
|
||||
user: dict = Depends(require_viewer),
|
||||
) -> dict[str, Any]:
|
||||
"""List PTY session recordings for an attacker (newest first).
|
||||
|
||||
Each entry is a `session_recorded` log row — the frontend lists them
|
||||
in the AttackerDetail Sessions tab and opens SessionDrawer on click.
|
||||
"""
|
||||
attacker = await repo.get_attacker_by_uuid(uuid)
|
||||
if not attacker:
|
||||
raise HTTPException(status_code=404, detail="Attacker not found")
|
||||
rows = await repo.get_attacker_transcripts(uuid)
|
||||
return {"total": len(rows), "data": rows}
|
||||
83
decnet/web/router/attackers/api_get_attackers.py
Normal file
83
decnet/web/router/attackers/api_get_attackers.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from decnet.telemetry import traced as _traced
|
||||
from decnet.web.dependencies import require_viewer, repo
|
||||
from decnet.web.db.models import AttackersResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Same pattern as /logs — cache the unfiltered total count; filtered
|
||||
# counts go straight to the DB.
|
||||
_TOTAL_TTL = 2.0
|
||||
_total_cache: tuple[Optional[int], float] = (None, 0.0)
|
||||
_total_lock: Optional[asyncio.Lock] = None
|
||||
|
||||
|
||||
def _reset_total_cache() -> None:
|
||||
global _total_cache, _total_lock
|
||||
_total_cache = (None, 0.0)
|
||||
_total_lock = None
|
||||
|
||||
|
||||
async def _get_total_attackers_cached() -> int:
|
||||
global _total_cache, _total_lock
|
||||
value, ts = _total_cache
|
||||
now = time.monotonic()
|
||||
if value is not None and now - ts < _TOTAL_TTL:
|
||||
return value
|
||||
if _total_lock is None:
|
||||
_total_lock = asyncio.Lock()
|
||||
async with _total_lock:
|
||||
value, ts = _total_cache
|
||||
now = time.monotonic()
|
||||
if value is not None and now - ts < _TOTAL_TTL:
|
||||
return value
|
||||
value = await repo.get_total_attackers()
|
||||
_total_cache = (value, time.monotonic())
|
||||
return value
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attackers",
|
||||
response_model=AttackersResponse,
|
||||
tags=["Attacker Profiles"],
|
||||
responses={
|
||||
401: {"description": "Could not validate credentials"},
|
||||
403: {"description": "Insufficient permissions"},
|
||||
422: {"description": "Validation error"},
|
||||
},
|
||||
)
|
||||
@_traced("api.get_attackers")
|
||||
async def get_attackers(
|
||||
limit: int = Query(50, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0, le=2147483647),
|
||||
search: Optional[str] = None,
|
||||
sort_by: str = Query("recent", pattern="^(recent|active|traversals)$"),
|
||||
service: Optional[str] = None,
|
||||
user: dict = Depends(require_viewer),
|
||||
) -> dict[str, Any]:
|
||||
"""Retrieve paginated attacker profiles."""
|
||||
def _norm(v: Optional[str]) -> Optional[str]:
|
||||
if v in (None, "null", "NULL", "undefined", ""):
|
||||
return None
|
||||
return v
|
||||
|
||||
s = _norm(search)
|
||||
svc = _norm(service)
|
||||
_data = await repo.get_attackers(limit=limit, offset=offset, search=s, sort_by=sort_by, service=svc)
|
||||
if s is None and svc is None:
|
||||
_total = await _get_total_attackers_cached()
|
||||
else:
|
||||
_total = await repo.get_total_attackers(search=s, service=svc)
|
||||
|
||||
# Bulk-join behavior rows for the IPs in this page to avoid N+1 queries.
|
||||
_ips = {row["ip"] for row in _data if row.get("ip")}
|
||||
_behaviors = await repo.get_behaviors_for_ips(_ips) if _ips else {}
|
||||
for row in _data:
|
||||
row["behavior"] = _behaviors.get(row.get("ip"))
|
||||
|
||||
return {"total": _total, "limit": limit, "offset": offset, "data": _data}
|
||||
Reference in New Issue
Block a user