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:
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
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}
|
||||
Reference in New Issue
Block a user