From 8cbb7834efdb5a798586493159b5fb27cd79983e Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 22 Apr 2026 22:33:53 -0400 Subject: [PATCH] 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). --- decnet/web/db/repository.py | 5 + decnet/web/db/sqlmodel_repo.py | 24 ++ decnet/web/router/__init__.py | 4 + .../router/attackers/api_get_attacker_mail.py | 37 +++ .../api_get_attacker_smtp_targets.py | 36 +++ decnet_web/src/components/AttackerDetail.tsx | 190 ++++++++++++++- decnet_web/src/components/MailDrawer.tsx | 216 ++++++++++++++++++ tests/test_api_attackers.py | 105 +++++++++ tests/test_base_repo.py | 2 + 9 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 decnet/web/router/attackers/api_get_attacker_mail.py create mode 100644 decnet/web/router/attackers/api_get_attacker_smtp_targets.py create mode 100644 decnet_web/src/components/MailDrawer.tsx diff --git a/decnet/web/db/repository.py b/decnet/web/db/repository.py index f781d54d..771fc469 100644 --- a/decnet/web/db/repository.py +++ b/decnet/web/db/repository.py @@ -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]: """ diff --git a/decnet/web/db/sqlmodel_repo.py b/decnet/web/db/sqlmodel_repo.py index e709ec52..d52c858e 100644 --- a/decnet/web/db/sqlmodel_repo.py +++ b/decnet/web/db/sqlmodel_repo.py @@ -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. diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py index ab4fbb5c..91dba590 100644 --- a/decnet/web/router/__init__.py +++ b/decnet/web/router/__init__.py @@ -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) diff --git a/decnet/web/router/attackers/api_get_attacker_mail.py b/decnet/web/router/attackers/api_get_attacker_mail.py new file mode 100644 index 00000000..9a4a197f --- /dev/null +++ b/decnet/web/router/attackers/api_get_attacker_mail.py @@ -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} diff --git a/decnet/web/router/attackers/api_get_attacker_smtp_targets.py b/decnet/web/router/attackers/api_get_attacker_smtp_targets.py new file mode 100644 index 00000000..b48a57b3 --- /dev/null +++ b/decnet/web/router/attackers/api_get_attacker_smtp_targets.py @@ -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} diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index f0d9262c..221eb052 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Activity, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Terminal, Package, FileText } from 'lucide-react'; +import { Activity, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Terminal, Package, FileText, Mail, AtSign } from 'lucide-react'; import api from '../utils/api'; import ArtifactDrawer from './ArtifactDrawer'; +import MailDrawer from './MailDrawer'; import SessionDrawer from './SessionDrawer'; import EmptyState from './EmptyState/EmptyState'; import './Dashboard.css'; @@ -710,6 +711,8 @@ const AttackerDetail: React.FC = () => { fingerprints: true, artifacts: true, sessions: true, + smtpTargets: true, + mail: true, }); // Captured file-drop artifacts (ssh inotify farm) for this attacker. @@ -733,6 +736,28 @@ const AttackerDetail: React.FC = () => { }; const [sessions, setSessions] = useState([]); const [session, setSession] = useState<{ decky: string; sid: string; fields: Record } | null>(null); + + // SMTP victim-domain rollup (viewer-safe: domains only, no local parts). + type SmtpTargetRow = { + domain: string; + count: number; + first_seen: string; + last_seen: string; + }; + const [smtpTargets, setSmtpTargets] = useState([]); + + // Stored SMTP messages (admin-gated: full attacker-controlled bodies). + type MailLog = { + id: number; + timestamp: string; + decky: string; + service: string; + fields: string; + }; + const [mail, setMail] = useState([]); + const [mailForbidden, setMailForbidden] = useState(false); + const [mailItem, setMailItem] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); + const toggle = (key: string) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); // Commands pagination state @@ -799,6 +824,34 @@ const AttackerDetail: React.FC = () => { fetchArtifacts(); }, [id]); + useEffect(() => { + if (!id) return; + const fetchSmtpTargets = async () => { + try { + const res = await api.get(`/attackers/${id}/smtp-targets`); + setSmtpTargets(res.data.data ?? []); + } catch { + setSmtpTargets([]); + } + }; + fetchSmtpTargets(); + }, [id]); + + useEffect(() => { + if (!id) return; + const fetchMail = async () => { + try { + const res = await api.get(`/attackers/${id}/mail`); + setMail(res.data.data ?? []); + setMailForbidden(false); + } catch (err: any) { + setMail([]); + setMailForbidden(err?.response?.status === 403); + } + }; + fetchMail(); + }, [id]); + useEffect(() => { if (!id) return; const fetchSessions = async () => { @@ -1200,6 +1253,141 @@ const AttackerDetail: React.FC = () => { /> )} + {/* SMTP Victim Domains (viewer-safe rollup) */} +
SMTP VICTIM DOMAINS ({smtpTargets.length})} + open={openSections.smtpTargets} + onToggle={() => toggle('smtpTargets')} + > + {smtpTargets.length > 0 ? ( +
+ + + + + + + + + + + {smtpTargets.map((row) => ( + + + + + + + ))} + +
DOMAINCOUNTFIRST SEENLAST SEEN
+ {row.domain} + + {row.count} + + {new Date(row.first_seen).toLocaleString()} + + {new Date(row.last_seen).toLocaleString()} +
+
+ ) : ( + + )} +
+ + {/* Stored Mail (admin only — bodies are attacker-controlled) */} +
STORED MAIL ({mail.length})} + open={openSections.mail} + onToggle={() => toggle('mail')} + > + {mailForbidden ? ( + + ) : mail.length > 0 ? ( +
+ + + + + + + + + + + + + {mail.map((row) => { + let fields: Record = {}; + try { fields = JSON.parse(row.fields || '{}'); } catch {} + const storedAs = fields.stored_as ? String(fields.stored_as) : null; + return ( + + + + + + + + + ); + })} + +
TIMESTAMPDECKYSUBJECTFROMSIZE
+ {new Date(row.timestamp).toLocaleString()} + {row.decky} + {fields.subject || '—'} + + {fields.from_addr || fields.mail_from || '—'} + + {fields.size ? `${fields.size} B` : '—'} + + {storedAs && ( + + )} +
+
+ ) : ( + + )} +
+ + {mailItem && ( + setMailItem(null)} + /> + )} + {/* Recorded PTY Sessions (SSH / Telnet) */}
SESSION TRANSCRIPTS ({sessions.length})} diff --git a/decnet_web/src/components/MailDrawer.tsx b/decnet_web/src/components/MailDrawer.tsx new file mode 100644 index 00000000..132f6c8b --- /dev/null +++ b/decnet_web/src/components/MailDrawer.tsx @@ -0,0 +1,216 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { X, Download, AlertTriangle, Paperclip } from 'lucide-react'; +import api from '../utils/api'; +import { useEscapeKey } from '../hooks/useEscapeKey'; +import { useFocusTrap } from '../hooks/useFocusTrap'; + +interface MailDrawerProps { + decky: string; + storedAs: string; + fields: Record; + onClose: () => void; +} + +interface AttachmentManifest { + filename?: string | null; + content_type?: string | null; + size?: number | null; + sha256?: string | null; +} + +function parseAttachments(fields: Record): AttachmentManifest[] { + const raw = fields.attachments_json; + if (typeof raw !== 'string' || !raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch (err) { + console.error('mail: failed to parse attachments_json', err); + return []; + } +} + +const Row: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( +
+
{label}
+
{value ?? }
+
+); + +const MailDrawer: React.FC = ({ decky, storedAs, fields, onClose }) => { + const panelRef = useRef(null); + useEscapeKey(onClose, true); + useFocusTrap(panelRef, true); + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, []); + + const [downloading, setDownloading] = useState(false); + const [error, setError] = useState(null); + const attachments = parseAttachments(fields); + + const handleDownload = async () => { + setDownloading(true); + setError(null); + try { + const res = await api.get( + `/artifacts/${encodeURIComponent(decky)}/${encodeURIComponent(storedAs)}?service=smtp`, + { responseType: 'blob' }, + ); + const blobUrl = URL.createObjectURL(res.data); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = storedAs; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(blobUrl); + } catch (err: any) { + const status = err?.response?.status; + setError( + status === 403 ? 'Admin role required to download mail.' : + status === 404 ? 'Message not found on disk (may have been purged).' : + status === 400 ? 'Server rejected the request (invalid parameters).' : + 'Download failed — see console.' + ); + console.error('mail download failed', err); + } finally { + setDownloading(false); + } + }; + + const recipients = Array.isArray(fields.rcpts) + ? fields.rcpts.join(', ') + : (typeof fields.rcpts === 'string' ? fields.rcpts : null); + + return ( +
+
e.stopPropagation()} + style={{ + width: 'min(620px, 100%)', height: '100%', + backgroundColor: 'var(--bg-color, #0d1117)', + borderLeft: '1px solid var(--border-color, #30363d)', + padding: '24px', overflowY: 'auto', + color: 'var(--text-color)', + }} + > +
+
+
+ STORED MESSAGE · {decky} +
+
+ {fields.subject || storedAs} +
+
+ +
+ +
+ + Attacker-controlled content. Phishing kits / malware likely. +
+ + + {error && ( +
{error}
+ )} + +
+

+ HEADERS +

+ + + + + + +
+ +
+

+ BODY +

+ + + + +
+ + {attachments.length > 0 && ( +
+

+ ATTACHMENTS ({attachments.length}) +

+
+ {attachments.map((att, idx) => ( +
+
+ + + {att.filename || '(unnamed)'} + +
+
+ {att.content_type ?? '?'} · {att.size != null ? `${att.size} B` : '? B'} +
+ {att.sha256 && ( +
+ {att.sha256} +
+ )} +
+ ))} +
+
+ )} +
+
+ ); +}; + +export default MailDrawer; diff --git a/tests/test_api_attackers.py b/tests/test_api_attackers.py index 89df97f0..5c385d0a 100644 --- a/tests/test_api_attackers.py +++ b/tests/test_api_attackers.py @@ -393,6 +393,111 @@ class TestGetAttackerTranscripts: assert exc_info.value.status_code == 404 +# ─── GET /attackers/{uuid}/smtp-targets ────────────────────────────────────── + +class TestGetAttackerSmtpTargets: + @pytest.mark.asyncio + async def test_returns_smtp_targets(self): + from decnet.web.router.attackers.api_get_attacker_smtp_targets import ( + get_attacker_smtp_targets, + ) + + sample = _sample_attacker() + rows = [ + { + "id": 1, "attacker_uuid": "att-uuid-1", + "domain": "corp1.com", "count": 5, + "first_seen": "2026-04-18T02:22:56+00:00", + "last_seen": "2026-04-19T10:15:03+00:00", + }, + ] + with patch("decnet.web.router.attackers.api_get_attacker_smtp_targets.repo") as mock_repo: + mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample) + mock_repo.list_smtp_targets = AsyncMock(return_value=rows) + + result = await get_attacker_smtp_targets( + uuid="att-uuid-1", + user={"uuid": "test-user", "role": "viewer"}, + ) + + assert result["total"] == 1 + assert result["data"][0]["domain"] == "corp1.com" + mock_repo.list_smtp_targets.assert_awaited_once_with("att-uuid-1") + + @pytest.mark.asyncio + async def test_404_on_unknown_uuid(self): + from decnet.web.router.attackers.api_get_attacker_smtp_targets import ( + get_attacker_smtp_targets, + ) + + with patch("decnet.web.router.attackers.api_get_attacker_smtp_targets.repo") as mock_repo: + mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None) + + with pytest.raises(HTTPException) as exc_info: + await get_attacker_smtp_targets( + uuid="nonexistent", + user={"uuid": "test-user", "role": "viewer"}, + ) + + assert exc_info.value.status_code == 404 + + +# ─── GET /attackers/{uuid}/mail ────────────────────────────────────────────── + +class TestGetAttackerMail: + @pytest.mark.asyncio + async def test_returns_stored_mail(self): + from decnet.web.router.attackers.api_get_attacker_mail import get_attacker_mail + + sample = _sample_attacker() + rows = [ + { + "id": 1, + "timestamp": "2026-04-18T02:22:56+00:00", + "decky": "decky-01", "service": "smtp", + "event_type": "message_stored", + "attacker_ip": "1.2.3.4", + "raw_line": "", "msg": "", + "fields": json.dumps({ + "stored_as": "2026-04-18T02:22:56Z_abc123def456_ABC123.eml", + "sha256": "deadbeef" * 8, + "size": "1024", + "subject": "URGENT invoice", + "from_hdr": "spam@evil.com", + "rcpt_to": "", + "attachment_count": "1", + }), + }, + ] + with patch("decnet.web.router.attackers.api_get_attacker_mail.repo") as mock_repo: + mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample) + mock_repo.get_attacker_stored_mail = AsyncMock(return_value=rows) + + result = await get_attacker_mail( + uuid="att-uuid-1", + admin={"uuid": "test-admin", "role": "admin"}, + ) + + assert result["total"] == 1 + assert result["data"][0]["event_type"] == "message_stored" + mock_repo.get_attacker_stored_mail.assert_awaited_once_with("att-uuid-1") + + @pytest.mark.asyncio + async def test_404_on_unknown_uuid(self): + from decnet.web.router.attackers.api_get_attacker_mail import get_attacker_mail + + with patch("decnet.web.router.attackers.api_get_attacker_mail.repo") as mock_repo: + mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None) + + with pytest.raises(HTTPException) as exc_info: + await get_attacker_mail( + uuid="nonexistent", + admin={"uuid": "test-admin", "role": "admin"}, + ) + + assert exc_info.value.status_code == 404 + + # ─── Auth enforcement ──────────────────────────────────────────────────────── class TestAttackersAuth: diff --git a/tests/test_base_repo.py b/tests/test_base_repo.py index 900b910f..01f5fa1e 100644 --- a/tests/test_base_repo.py +++ b/tests/test_base_repo.py @@ -33,6 +33,7 @@ class DummyRepo(BaseRepository): async def get_session_profile(self, sid): await super().get_session_profile(sid) async def increment_smtp_target(self, u, d): await super().increment_smtp_target(u, d) async def list_smtp_targets(self, u): await super().list_smtp_targets(u) + async def get_attacker_stored_mail(self, u): await super().get_attacker_stored_mail(u) async def smtp_target_seen(self, d): await super().smtp_target_seen(d) async def get_attacker_by_uuid(self, u): await super().get_attacker_by_uuid(u) async def get_attackers(self, **kw): await super().get_attackers(**kw) @@ -77,6 +78,7 @@ async def test_base_repo_coverage(): await dr.get_session_profile("sid") await dr.increment_smtp_target("uuid", "corp.com") await dr.list_smtp_targets("uuid") + await dr.get_attacker_stored_mail("uuid") await dr.smtp_target_seen("corp.com") await dr.get_attacker_by_uuid("a") await dr.get_attackers()