feat(web): SMTP victim-domain + stored-mail panels on attacker detail

Adds GET /attackers/{uuid}/smtp-targets (viewer) and GET /attackers/{uuid}/mail
(admin) endpoints, plus two new sections on the attacker detail page:
VICTIM DOMAINS rollup (aggregate-only, federation-gossip-safe) and STORED MAIL
with a drawer that decodes headers, lists attachments, and downloads the raw
.eml via the existing artifact endpoint (?service=smtp).
This commit is contained in:
2026-04-22 22:33:53 -04:00
parent d43303251d
commit 8cbb7834ef
9 changed files with 618 additions and 1 deletions

View File

@@ -187,6 +187,11 @@ class BaseRepository(ABC):
"""Return SmtpTarget rows for an attacker, ordered by most-recent first."""
pass
@abstractmethod
async def get_attacker_stored_mail(self, uuid: str) -> list[Any]:
"""Return `message_stored` log rows for an attacker, newest first."""
pass
@abstractmethod
async def smtp_target_seen(self, domain: str) -> dict[str, Any]:
"""

View File

@@ -898,6 +898,30 @@ class SQLModelRepository(BaseRepository):
)
return [r.model_dump(mode="json") for r in rows.scalars().all()]
async def get_attacker_stored_mail(self, uuid: str) -> list[dict[str, Any]]:
"""Return `message_stored` logs for an attacker, newest first.
Mirrors :meth:`get_attacker_artifacts` — the SMTP template emits one
`message_stored` row per accepted DATA body, with headers + sha256 +
attachment manifest already decoded into ``fields`` by the ingester.
Capped at 200 rows to match the artifact/transcript query shape.
"""
async with self._session() as session:
ip_res = await session.execute(
select(Attacker.ip).where(Attacker.uuid == uuid)
)
ip = ip_res.scalar_one_or_none()
if not ip:
return []
rows = await session.execute(
select(Log)
.where(Log.attacker_ip == ip)
.where(Log.event_type == "message_stored")
.order_by(desc(Log.timestamp))
.limit(200)
)
return [r.model_dump(mode="json") for r in rows.scalars().all()]
async def get_session_log(self, sid: str) -> Optional[dict[str, Any]]:
"""Look up the `session_recorded` Log row that owns a given sid.

View File

@@ -16,6 +16,8 @@ from .attackers.api_get_attacker_detail import router as attacker_detail_router
from .attackers.api_get_attacker_commands import router as attacker_commands_router
from .attackers.api_get_attacker_artifacts import router as attacker_artifacts_router
from .attackers.api_get_attacker_transcripts import router as attacker_transcripts_router
from .attackers.api_get_attacker_smtp_targets import router as attacker_smtp_targets_router
from .attackers.api_get_attacker_mail import router as attacker_mail_router
from .transcripts import transcripts_router
from .config.api_get_config import router as config_get_router
from .config.api_update_config import router as config_update_router
@@ -68,6 +70,8 @@ api_router.include_router(attacker_detail_router)
api_router.include_router(attacker_commands_router)
api_router.include_router(attacker_artifacts_router)
api_router.include_router(attacker_transcripts_router)
api_router.include_router(attacker_smtp_targets_router)
api_router.include_router(attacker_mail_router)
# Observability
api_router.include_router(stats_router)

View 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}

View 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}