From e3ddeb0395b2b944d17f6086996f47b285eda46e Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 28 Apr 2026 19:42:54 -0400 Subject: [PATCH] feat(bounty): surface file drops and stored mail in the Vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bounty Vault page only read from the Bounty table, but inotifywait-captured file drops (event_type=file_captured) and SMTP quarantined messages (event_type=message_stored) were only landing in the Logs table. AttackerDetail's tabs queried logs directly, so they showed up per-attacker but were invisible on the global Vault page. Mirror both events into Bounty as bounty_type=artifact with payload.kind ∈ {file, mail} so the existing dedup (bounty_type, attacker_ip, payload) collapses repeats by sha256. Add an ARTIFACTS segment to the Vault filter row, plus dedicated render branches: file drops show orig_path + size + writer attribution; mail shows subject + From + attachment count + size, with the Mail icon distinguishing them from FileText for file drops. Forward-only — existing logs stay where they are. A backfill pass would be straightforward (read Log WHERE event_type IN ('file_captured', 'message_stored') and feed each row through _extract_bounty) but is out of scope here. --- decnet/web/ingester.py | 46 ++++++++++++++++ decnet_web/src/components/Bounty.tsx | 41 +++++++++++++- tests/web/test_ingester.py | 82 ++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 3 deletions(-) diff --git a/decnet/web/ingester.py b/decnet/web/ingester.py index 0ddbdfa5..da686206 100644 --- a/decnet/web/ingester.py +++ b/decnet/web/ingester.py @@ -568,6 +568,52 @@ async def _extract_bounty( }, }) + # 12. Captured file drops + stored mail. The `file_captured` event + # comes from inotifywait quarantines on SSH deckies; `message_stored` + # comes from the SMTP template's DATA-commit handler. Both are + # already what AttackerDetail's artifacts/mail tabs read; mirroring + # them into the Bounty table makes the global Vault page show them + # alongside credentials and fingerprints. Dedup on (bounty_type, + # attacker_ip, payload); sha256 in the payload guarantees per-drop + # uniqueness so repeat captures don't multiply rows. + _evt = log_data.get("event_type") + if _evt == "file_captured" and _fields.get("stored_as"): + await repo.add_bounty({ + "decky": log_data.get("decky"), + "service": log_data.get("service"), + "attacker_ip": log_data.get("attacker_ip"), + "bounty_type": "artifact", + "payload": { + "kind": "file", + "stored_as": _fields.get("stored_as"), + "sha256": _fields.get("sha256"), + "size": _fields.get("size"), + "orig_path": _fields.get("orig_path"), + "attribution": _fields.get("attribution"), + "writer_comm": _fields.get("writer_comm"), + }, + }) + elif _evt == "message_stored" and _fields.get("stored_as"): + await repo.add_bounty({ + "decky": log_data.get("decky"), + "service": log_data.get("service"), + "attacker_ip": log_data.get("attacker_ip"), + "bounty_type": "artifact", + "payload": { + "kind": "mail", + "stored_as": _fields.get("stored_as"), + "sha256": _fields.get("sha256"), + "size": _fields.get("size"), + "subject": _fields.get("subject"), + "from_hdr": _fields.get("from_hdr"), + "to_hdr": _fields.get("to_hdr"), + "mail_from": _fields.get("mail_from"), + "rcpt_to": _fields.get("rcpt_to"), + "attachment_count": _fields.get("attachment_count"), + "content_type": _fields.get("content_type"), + }, + }) + # ─── IP-leak detection (XFF / Forwarded / X-Real-IP / CDN variants) ────────── diff --git a/decnet_web/src/components/Bounty.tsx b/decnet_web/src/components/Bounty.tsx index f347e482..15bf6767 100644 --- a/decnet_web/src/components/Bounty.tsx +++ b/decnet_web/src/components/Bounty.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { Archive, Search, ChevronLeft, ChevronRight, Filter, Key, Package, ChevronRight as ChevR, - Target, + Target, FileText, Mail, } from '../icons'; import api from '../utils/api'; import BountyInspector from './BountyInspector'; @@ -95,14 +95,24 @@ const Bounty: React.FC = () => { const credCount = bounties.filter(b => b.bounty_type === 'credential').length; const payCount = bounties.filter(b => b.bounty_type === 'payload').length; const fpCount = bounties.filter(b => b.bounty_type === 'fingerprint').length; + const artCount = bounties.filter(b => b.bounty_type === 'artifact').length; const SEGMENTS: [string, string][] = [ ['', 'ALL'], ['credential', 'CREDENTIALS'], ['payload', 'PAYLOADS'], + ['artifact', 'ARTIFACTS'], ['fingerprint', 'FINGERPRINTS'], ]; + const formatBytes = (n: any): string => { + const v = typeof n === 'string' ? parseInt(n, 10) : n; + if (!Number.isFinite(v) || v < 0) return ''; + if (v < 1024) return `${v} B`; + if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`; + return `${(v / (1024 * 1024)).toFixed(1)} MB`; + }; + return (
@@ -112,7 +122,7 @@ const Bounty: React.FC = () => {

BOUNTY VAULT

- {total.toLocaleString()} ARTIFACTS · {credCount} CREDENTIALS · {payCount} PAYLOADS · {fpCount} FINGERPRINTS + {total.toLocaleString()} BOUNTIES · {credCount} CREDENTIALS · {payCount} PAYLOADS · {artCount} ARTIFACTS · {fpCount} FINGERPRINTS
@@ -178,7 +188,9 @@ const Bounty: React.FC = () => { {bounties.length > 0 ? bounties.map(b => { const isCred = b.bounty_type === 'credential'; const isFp = b.bounty_type === 'fingerprint'; - const Icon = isCred ? Key : Package; + const isArt = b.bounty_type === 'artifact'; + const isMail = isArt && b.payload?.kind === 'mail'; + const Icon = isCred ? Key : isMail ? Mail : isArt ? FileText : Package; return ( setSelected(b)}> @@ -211,6 +223,29 @@ const Bounty: React.FC = () => { ) : isFp ? ( + ) : isMail ? ( + + {b.payload?.subject || '(no subject)'} + {b.payload?.mail_from && ( + from {b.payload.mail_from} + )} + {b.payload?.attachment_count > 0 && ( + · {b.payload.attachment_count} attach + )} + {b.payload?.size != null && ( + · {formatBytes(b.payload.size)} + )} + + ) : isArt ? ( + + {b.payload?.orig_path || b.payload?.stored_as || '—'} + {b.payload?.size != null && ( + {formatBytes(b.payload.size)} + )} + {b.payload?.attribution && ( + · via {b.payload.attribution} + )} + ) : ( {b.payload?.query || b.payload?.body || b.payload?.command || JSON.stringify(b.payload)} diff --git a/tests/web/test_ingester.py b/tests/web/test_ingester.py index ad231524..f27eb840 100644 --- a/tests/web/test_ingester.py +++ b/tests/web/test_ingester.py @@ -72,6 +72,88 @@ class TestExtractBounty: await _extract_bounty(mock_repo, {"fields": "not-a-dict"}) mock_repo.upsert_credential.assert_not_awaited() + @pytest.mark.asyncio + async def test_file_captured_emits_artifact_bounty(self): + """SSH inotifywait `file_captured` event becomes a Bounty row of + type=artifact so it shows on the global Vault page, not just on + the per-attacker artifacts tab.""" + from decnet.web.ingester import _extract_bounty + mock_repo = MagicMock() + mock_repo.add_bounty = AsyncMock() + mock_repo.upsert_credential = AsyncMock() + await _extract_bounty(mock_repo, { + "decky": "dmz-gateway", + "service": "ssh", + "attacker_ip": "31.56.209.39", + "event_type": "file_captured", + "fields": { + "stored_as": "2026-04-28T22:35:58Z_abc123def456_evil.sh", + "sha256": "deadbeef" * 8, + "size": "1234", + "orig_path": "/tmp/evil.sh", + "attribution": "ssh-session-pid-940", + "writer_comm": "bash", + }, + }) + mock_repo.add_bounty.assert_awaited_once() + bounty = mock_repo.add_bounty.call_args[0][0] + assert bounty["bounty_type"] == "artifact" + assert bounty["attacker_ip"] == "31.56.209.39" + assert bounty["payload"]["kind"] == "file" + assert bounty["payload"]["orig_path"] == "/tmp/evil.sh" + assert bounty["payload"]["sha256"] == "deadbeef" * 8 + + @pytest.mark.asyncio + async def test_file_captured_without_stored_as_skipped(self): + """A malformed file_captured row missing stored_as never lands in + Bounty — sha256/size alone aren't enough to retrieve the bytes.""" + from decnet.web.ingester import _extract_bounty + mock_repo = MagicMock() + mock_repo.add_bounty = AsyncMock() + mock_repo.upsert_credential = AsyncMock() + await _extract_bounty(mock_repo, { + "decky": "dmz-gateway", + "service": "ssh", + "attacker_ip": "1.2.3.4", + "event_type": "file_captured", + "fields": {"sha256": "abc", "size": "10"}, + }) + mock_repo.add_bounty.assert_not_awaited() + + @pytest.mark.asyncio + async def test_message_stored_emits_mail_artifact_bounty(self): + """SMTP `message_stored` event lands as bounty_type=artifact with + payload.kind=mail so the UI can render it with the Mail icon and + subject/from preview rather than the file-drop layout.""" + from decnet.web.ingester import _extract_bounty + mock_repo = MagicMock() + mock_repo.add_bounty = AsyncMock() + mock_repo.upsert_credential = AsyncMock() + await _extract_bounty(mock_repo, { + "decky": "mail-decky", + "service": "smtp", + "attacker_ip": "203.0.113.7", + "event_type": "message_stored", + "fields": { + "stored_as": "2026-04-28T12:00:00Z_abc123def456_msg.eml", + "sha256": "cafebabe" * 8, + "size": "8192", + "subject": "URGENT: invoice", + "from_hdr": "billing@spammer.example", + "to_hdr": "victim@target.tld", + "mail_from": "spammer@spammer.example", + "rcpt_to": "victim@target.tld", + "attachment_count": "1", + "content_type": "multipart/mixed", + }, + }) + mock_repo.add_bounty.assert_awaited_once() + bounty = mock_repo.add_bounty.call_args[0][0] + assert bounty["bounty_type"] == "artifact" + assert bounty["payload"]["kind"] == "mail" + assert bounty["payload"]["subject"] == "URGENT: invoice" + assert bounty["payload"]["mail_from"] == "spammer@spammer.example" + @pytest.mark.asyncio async def test_no_secret_b64_no_credential(self): """The native branch keys off `secret_b64`. Fields lacking it