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

@@ -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": "<a@corp.com>",
"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:

View File

@@ -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()