feat(smtp): capture full messages + attachments to disk

SMTP template now writes each accepted DATA body as a .eml file into a
bind-mounted per-decky quarantine dir and emits a `message_stored` log
with sha256, size, decoded headers, and an attachment manifest
(filename + sha256 + size + content-type). Attachment hashing uses the
*decoded* payload so operators can match against VT / MalwareBazaar
directly. Body accumulator is capped at SMTP_MAX_BODY_BYTES (default
10 MB, matching the EHLO SIZE advert) so a streaming client can't OOM
the container.

The existing /api/v1/artifacts/{decky}/{stored_as} endpoint now takes
an optional ?service= query param (defaults to ssh for back-compat)
and can serve .eml files out of the smtp subdir. Forensic metadata
rides the normal log pipeline, same as SSH file_captured.
This commit is contained in:
2026-04-22 22:17:50 -04:00
parent d47a84c90b
commit c50448995b
7 changed files with 430 additions and 13 deletions

View File

@@ -125,3 +125,32 @@ async def test_content_disposition_is_attachment(client: httpx.AsyncClient, auth
assert res.status_code == 200
cd = res.headers.get("content-disposition", "")
assert "attachment" in cd.lower()
async def test_smtp_service_serves_from_smtp_subdir(
client: httpx.AsyncClient, auth_token: str, tmp_path, monkeypatch,
):
"""?service=smtp routes to {root}/{decky}/smtp/ instead of .../ssh/."""
root = tmp_path / "artifacts-smtp"
(root / _DECKY / "smtp").mkdir(parents=True)
eml = "2026-04-18T02:22:56Z_abc123def456_msg.eml"
(root / _DECKY / "smtp" / eml).write_bytes(b"From: a\r\n\r\nhi")
from decnet.web.router.artifacts import api_get_artifact
monkeypatch.setattr(api_get_artifact, "ARTIFACTS_ROOT", root)
res = await client.get(
f"/api/v1/artifacts/{_DECKY}/{eml}?service=smtp",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert res.status_code == 200
assert res.content == b"From: a\r\n\r\nhi"
async def test_unknown_service_rejected(
client: httpx.AsyncClient, auth_token: str, artifacts_root,
):
res = await client.get(
f"/api/v1/artifacts/{_DECKY}/{_VALID_STORED_AS}?service=rdp",
headers={"Authorization": f"Bearer {auth_token}"},
)
# Regex matches (lowercase alpha) but _ALLOWED_SERVICES rejects → 400.
assert res.status_code == 400

View File

@@ -458,3 +458,201 @@ def test_auth_plain_continuation_harvester(harvester_mod):
_send(proto, creds)
replies = _replies(written)
assert any(r.startswith("535") for r in replies)
# ── FULL-MESSAGE CAPTURE (quarantine + message_stored event) ─────────────────
def _load_smtp_with_quarantine(quarantine_dir: str, max_body_bytes: int | None = None):
"""Reload the SMTP template with a quarantine dir + optional body cap.
Same mechanics as _load_smtp but threads extra env through so the module's
capture-path code is exercised end-to-end (file write + parse).
"""
env = {
"SMTP_OPEN_RELAY": "1",
"NODE_NAME": "testhost",
"SMTP_RCPT_DROP_RATE": "0",
"SMTP_QUARANTINE_DIR": quarantine_dir,
}
if max_body_bytes is not None:
env["SMTP_MAX_BODY_BYTES"] = str(max_body_bytes)
for key in ("smtp_server", "syslog_bridge", "instance_seed"):
sys.modules.pop(key, None)
sys.modules["syslog_bridge"] = _make_fake_syslog_bridge()
spec = importlib.util.spec_from_file_location(
"smtp_server", "decnet/templates/smtp/server.py"
)
mod = importlib.util.module_from_spec(spec)
with patch.dict("os.environ", env, clear=False):
from .conftest import load_real_instance_seed
sys.modules["instance_seed"] = load_real_instance_seed()
spec.loader.exec_module(mod)
return mod
def _logged_events(mod) -> list[tuple[str, dict]]:
"""Return every (event_type, fields) tuple syslog_bridge was called with."""
calls = mod.syslog_line.call_args_list
events: list[tuple[str, dict]] = []
for call in calls:
args, kwargs = call
# syslog_line(service, hostname, event_type, severity=..., **fields)
event_type = args[2] if len(args) > 2 else kwargs.get("event_type", "")
# Strip positional service/hostname/event_type/severity when present.
fields = dict(kwargs)
fields.pop("severity", None)
events.append((event_type, fields))
return events
class TestMessageCapture:
def test_message_stored_event_written(self, tmp_path):
mod = _load_smtp_with_quarantine(str(tmp_path))
proto, _, _ = _make_protocol(mod)
_send(
proto,
"EHLO x.com",
"MAIL FROM:<spam@evil.com>",
"RCPT TO:<victim@target.com>",
"DATA",
"Subject: hello",
"From: spam@evil.com",
"",
"body line",
".",
)
events = _logged_events(mod)
stored = [f for t, f in events if t == "message_stored"]
assert len(stored) == 1, f"expected 1 message_stored event, got {events}"
rec = stored[0]
assert rec["subject"] == "hello"
assert rec["from_hdr"] == "spam@evil.com"
assert rec["mail_from"] == "<spam@evil.com>"
assert rec["rcpt_to"] == "<victim@target.com>"
assert rec["attachment_count"] == 0
# Filename matches artifact endpoint's regex.
import re as _re
assert _re.fullmatch(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z_[a-f0-9]{12}_[A-Za-z0-9._-]{1,255}",
rec["stored_as"],
)
def test_message_file_written_to_quarantine(self, tmp_path):
mod = _load_smtp_with_quarantine(str(tmp_path))
proto, _, _ = _make_protocol(mod)
_send(
proto,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<c@d.com>",
"DATA",
"Subject: test",
"",
"payload bytes",
".",
)
files = list(tmp_path.iterdir())
assert len(files) == 1
contents = files[0].read_bytes()
assert b"Subject: test" in contents
assert b"payload bytes" in contents
assert files[0].name.endswith(".eml")
def test_attachment_manifest_captured(self, tmp_path):
"""A multipart message with an attachment must report filename + sha256."""
import hashlib as _hashlib
mod = _load_smtp_with_quarantine(str(tmp_path))
proto, _, _ = _make_protocol(mod)
boundary = "----ABC"
payload = b"MZ\x90\x00fake-exe-bytes"
import base64 as _b64
payload_b64 = _b64.b64encode(payload).decode()
_send(
proto,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<c@d.com>",
"DATA",
"Subject: malware",
f"Content-Type: multipart/mixed; boundary={boundary}",
"MIME-Version: 1.0",
"",
f"--{boundary}",
'Content-Type: text/plain',
"",
"see attached",
f"--{boundary}",
'Content-Type: application/octet-stream; name="payload.exe"',
'Content-Disposition: attachment; filename="payload.exe"',
"Content-Transfer-Encoding: base64",
"",
payload_b64,
f"--{boundary}--",
".",
)
events = _logged_events(mod)
stored = [f for t, f in events if t == "message_stored"]
assert len(stored) == 1
rec = stored[0]
assert rec["attachment_count"] == 1
import json as _json
manifest = _json.loads(rec["attachments_json"])
assert len(manifest) == 1
assert manifest[0]["filename"] == "payload.exe"
assert manifest[0]["sha256"] == _hashlib.sha256(payload).hexdigest()
assert manifest[0]["size"] == len(payload)
def test_capture_disabled_when_dir_unset(self, tmp_path, relay_mod):
"""With SMTP_QUARANTINE_DIR unset, message_accepted fires but no
message_stored event and no files are written."""
proto, _, _ = _make_protocol(relay_mod)
_send(
proto,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<c@d.com>",
"DATA",
"Subject: no-capture",
"",
"body",
".",
)
events = _logged_events(relay_mod)
assert any(t == "message_accepted" for t, _ in events)
assert not any(t == "message_stored" for t, _ in events)
def test_body_size_cap_truncates(self, tmp_path):
"""Body beyond SMTP_MAX_BODY_BYTES is dropped but the session still
terminates and truncated=1 is flagged."""
mod = _load_smtp_with_quarantine(str(tmp_path), max_body_bytes=64)
proto, _, _ = _make_protocol(mod)
big_line = "A" * 500
_send(
proto,
"EHLO x.com",
"MAIL FROM:<a@b.com>",
"RCPT TO:<c@d.com>",
"DATA",
"Subject: big",
"",
big_line,
big_line,
".",
)
events = _logged_events(mod)
stored = [f for t, f in events if t == "message_stored"]
accepted = [f for t, f in events if t == "message_accepted"]
assert accepted and accepted[0]["truncated"] == 1
# File still written with whatever we managed to buffer.
assert len(list(tmp_path.iterdir())) == 1
assert stored and stored[0]["truncated"] == 1
def test_rset_resets_body_state(self, tmp_path):
"""RSET must clear data_bytes + truncated flag so a fresh transaction
is not accounted against the prior one."""
mod = _load_smtp_with_quarantine(str(tmp_path))
proto, _, _ = _make_protocol(mod)
_send(proto, "EHLO x.com", "MAIL FROM:<a@b.com>", "RCPT TO:<c@d.com>", "RSET")
assert proto._data_bytes == 0
assert proto._data_truncated is False

View File

@@ -26,3 +26,17 @@ def test_smtp_relay_dockerfile_context():
ctx = svc.dockerfile_context()
assert ctx.name == "smtp"
assert ctx.is_dir()
def test_smtp_relay_quarantine_bind_mount():
"""Full-message capture: each decky gets its own host quarantine dir
bind-mounted into the container, and the in-container path is exposed
via SMTP_QUARANTINE_DIR so the server can write .eml files."""
svc = SMTPRelayService()
fragment = svc.compose_fragment("test-decky")
volumes = fragment["volumes"]
assert len(volumes) == 1
host, container, mode = volumes[0].split(":")
assert host.endswith("/test-decky/smtp")
assert container == fragment["environment"]["SMTP_QUARANTINE_DIR"]
assert mode == "rw"