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
|