feat(bounty): surface file drops and stored mail in the Vault
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.
This commit is contained in:
@@ -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) ──────────
|
# ─── IP-leak detection (XFF / Forwarded / X-Real-IP / CDN variants) ──────────
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Archive, Search, ChevronLeft, ChevronRight, Filter, Key, Package, ChevronRight as ChevR,
|
Archive, Search, ChevronLeft, ChevronRight, Filter, Key, Package, ChevronRight as ChevR,
|
||||||
Target,
|
Target, FileText, Mail,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import BountyInspector from './BountyInspector';
|
import BountyInspector from './BountyInspector';
|
||||||
@@ -95,14 +95,24 @@ const Bounty: React.FC = () => {
|
|||||||
const credCount = bounties.filter(b => b.bounty_type === 'credential').length;
|
const credCount = bounties.filter(b => b.bounty_type === 'credential').length;
|
||||||
const payCount = bounties.filter(b => b.bounty_type === 'payload').length;
|
const payCount = bounties.filter(b => b.bounty_type === 'payload').length;
|
||||||
const fpCount = bounties.filter(b => b.bounty_type === 'fingerprint').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][] = [
|
const SEGMENTS: [string, string][] = [
|
||||||
['', 'ALL'],
|
['', 'ALL'],
|
||||||
['credential', 'CREDENTIALS'],
|
['credential', 'CREDENTIALS'],
|
||||||
['payload', 'PAYLOADS'],
|
['payload', 'PAYLOADS'],
|
||||||
|
['artifact', 'ARTIFACTS'],
|
||||||
['fingerprint', 'FINGERPRINTS'],
|
['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 (
|
return (
|
||||||
<div className="bounty-root">
|
<div className="bounty-root">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -112,7 +122,7 @@ const Bounty: React.FC = () => {
|
|||||||
<h1>BOUNTY VAULT</h1>
|
<h1>BOUNTY VAULT</h1>
|
||||||
</div>
|
</div>
|
||||||
<span className="page-sub">
|
<span className="page-sub">
|
||||||
{total.toLocaleString()} ARTIFACTS · {credCount} CREDENTIALS · {payCount} PAYLOADS · {fpCount} FINGERPRINTS
|
{total.toLocaleString()} BOUNTIES · {credCount} CREDENTIALS · {payCount} PAYLOADS · {artCount} ARTIFACTS · {fpCount} FINGERPRINTS
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +188,9 @@ const Bounty: React.FC = () => {
|
|||||||
{bounties.length > 0 ? bounties.map(b => {
|
{bounties.length > 0 ? bounties.map(b => {
|
||||||
const isCred = b.bounty_type === 'credential';
|
const isCred = b.bounty_type === 'credential';
|
||||||
const isFp = b.bounty_type === 'fingerprint';
|
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 (
|
return (
|
||||||
<tr key={b.id} className="clickable" onClick={() => setSelected(b)}>
|
<tr key={b.id} className="clickable" onClick={() => setSelected(b)}>
|
||||||
<td className="dim" style={{ fontSize: '0.72rem', whiteSpace: 'nowrap' }}>
|
<td className="dim" style={{ fontSize: '0.72rem', whiteSpace: 'nowrap' }}>
|
||||||
@@ -211,6 +223,29 @@ const Bounty: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : isFp ? (
|
) : isFp ? (
|
||||||
<FingerprintPreview payload={b.payload} />
|
<FingerprintPreview payload={b.payload} />
|
||||||
|
) : isMail ? (
|
||||||
|
<span className="data-preview">
|
||||||
|
<span className="matrix-text">{b.payload?.subject || '(no subject)'}</span>
|
||||||
|
{b.payload?.mail_from && (
|
||||||
|
<span className="dim" style={{ marginLeft: 8 }}>from {b.payload.mail_from}</span>
|
||||||
|
)}
|
||||||
|
{b.payload?.attachment_count > 0 && (
|
||||||
|
<span className="dim" style={{ marginLeft: 8 }}>· {b.payload.attachment_count} attach</span>
|
||||||
|
)}
|
||||||
|
{b.payload?.size != null && (
|
||||||
|
<span className="dim" style={{ marginLeft: 8 }}>· {formatBytes(b.payload.size)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : isArt ? (
|
||||||
|
<span className="data-preview">
|
||||||
|
<span className="matrix-text">{b.payload?.orig_path || b.payload?.stored_as || '—'}</span>
|
||||||
|
{b.payload?.size != null && (
|
||||||
|
<span className="dim" style={{ marginLeft: 8 }}>{formatBytes(b.payload.size)}</span>
|
||||||
|
)}
|
||||||
|
{b.payload?.attribution && (
|
||||||
|
<span className="dim" style={{ marginLeft: 8 }}>· via {b.payload.attribution}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="data-preview">
|
<span className="data-preview">
|
||||||
{b.payload?.query || b.payload?.body || b.payload?.command || JSON.stringify(b.payload)}
|
{b.payload?.query || b.payload?.body || b.payload?.command || JSON.stringify(b.payload)}
|
||||||
|
|||||||
@@ -72,6 +72,88 @@ class TestExtractBounty:
|
|||||||
await _extract_bounty(mock_repo, {"fields": "not-a-dict"})
|
await _extract_bounty(mock_repo, {"fields": "not-a-dict"})
|
||||||
mock_repo.upsert_credential.assert_not_awaited()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_no_secret_b64_no_credential(self):
|
async def test_no_secret_b64_no_credential(self):
|
||||||
"""The native branch keys off `secret_b64`. Fields lacking it
|
"""The native branch keys off `secret_b64`. Fields lacking it
|
||||||
|
|||||||
Reference in New Issue
Block a user