feat(bounty): wire artifact download into BountyInspector drawer

The Vault page already shows file drops and stored mail (e3ddeb0) but
the inspector drawer had no download button — only the live-feed
ArtifactDrawer/MailDrawer offered raw byte retrieval. Add a DOWNLOAD
RAW action to BountyInspector that fires when bounty_type=artifact,
hitting /artifacts/{decky}/{stored_as}?service=<svc> with the bounty's
own service field (ssh or smtp). Mirrors ArtifactDrawer's blob handling
and 400/403/404 error mapping.

Also widen the icon/label vocabulary: artifact bounties get FileText
(file drops) or Mail (message_stored) instead of the generic Package,
and the inspector header chip mirrors the change.
This commit is contained in:
2026-04-28 22:03:58 -04:00
parent e3ddeb0395
commit 04b0637c24

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React, { useState } from 'react';
import { X, Key, Package, Copy, Send, Ban } from '../icons'; import { X, Key, Package, Copy, Send, Ban, FileText, Mail, Download, AlertTriangle } from '../icons';
import { useToast } from './Toasts/useToast'; import { useToast } from './Toasts/useToast';
import api from '../utils/api';
interface BountyEntry { interface BountyEntry {
id: number; id: number;
@@ -21,8 +22,14 @@ interface Props {
const BountyInspector: React.FC<Props> = ({ bounty, onClose, onSelectAttacker }) => { const BountyInspector: React.FC<Props> = ({ bounty, onClose, onSelectAttacker }) => {
const { push } = useToast(); const { push } = useToast();
const isCred = bounty.bounty_type === 'credential'; const isCred = bounty.bounty_type === 'credential';
const Icon = isCred ? Key : Package; const isArt = bounty.bounty_type === 'artifact';
const p = bounty.payload || {}; const p = bounty.payload || {};
const isMail = isArt && p.kind === 'mail';
const Icon = isCred ? Key : isMail ? Mail : isArt ? FileText : Package;
const storedAs: string | undefined = isArt ? p.stored_as : undefined;
const [downloading, setDownloading] = useState(false);
const [dlError, setDlError] = useState<string | null>(null);
const copyJson = async () => { const copyJson = async () => {
try { try {
@@ -33,6 +40,37 @@ const BountyInspector: React.FC<Props> = ({ bounty, onClose, onSelectAttacker })
} }
}; };
const downloadArtifact = async () => {
if (!storedAs) return;
setDownloading(true);
setDlError(null);
try {
const res = await api.get(
`/artifacts/${encodeURIComponent(bounty.decky)}/${encodeURIComponent(storedAs)}?service=${encodeURIComponent(bounty.service)}`,
{ responseType: 'blob' },
);
const blobUrl = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = blobUrl;
a.download = storedAs;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (err: any) {
const status = err?.response?.status;
setDlError(
status === 403 ? 'Admin role required to download artifacts.' :
status === 404 ? 'Artifact not found on disk (may have been purged).' :
status === 400 ? 'Server rejected the request (invalid parameters).' :
'Download failed — see console.'
);
console.error('artifact download failed', err);
} finally {
setDownloading(false);
}
};
const stubMisp = () => push({ text: 'MISP NOT CONFIGURED', tone: 'violet', icon: 'info' }); const stubMisp = () => push({ text: 'MISP NOT CONFIGURED', tone: 'violet', icon: 'info' });
const stubBlocklist = () => push({ text: 'BLOCKLIST NOT WIRED', tone: 'violet', icon: 'info' }); const stubBlocklist = () => push({ text: 'BLOCKLIST NOT WIRED', tone: 'violet', icon: 'info' });
@@ -52,7 +90,10 @@ const BountyInspector: React.FC<Props> = ({ bounty, onClose, onSelectAttacker })
<div className="kvs"> <div className="kvs">
<div className="k">TYPE</div> <div className="k">TYPE</div>
<div className="v"> <div className="v">
<span className={`chip ${isCred ? 'matrix' : 'violet'}`}>{bounty.bounty_type.toUpperCase()}</span> <span className={`chip ${isCred ? 'matrix' : 'violet'}`}>
<Icon size={9} style={{ marginRight: 4 }} />
{bounty.bounty_type.toUpperCase()}{isMail ? ' · MAIL' : ''}
</span>
</div> </div>
<div className="k">TIMESTAMP</div> <div className="k">TIMESTAMP</div>
<div className="v">{new Date(bounty.timestamp).toLocaleString()}</div> <div className="v">{new Date(bounty.timestamp).toLocaleString()}</div>
@@ -72,7 +113,9 @@ const BountyInspector: React.FC<Props> = ({ bounty, onClose, onSelectAttacker })
</div> </div>
<div> <div>
<div className="type-label">{isCred ? 'CAPTURED CREDENTIAL' : 'CAPTURED PAYLOAD'}</div> <div className="type-label">
{isCred ? 'CAPTURED CREDENTIAL' : isMail ? 'CAPTURED MESSAGE' : isArt ? 'CAPTURED FILE' : 'CAPTURED PAYLOAD'}
</div>
{isCred ? ( {isCred ? (
<pre className="code-block"> <pre className="code-block">
<span className="ck">username:</span> <span className="cs">{p.username}</span>{'\n'} <span className="ck">username:</span> <span className="cs">{p.username}</span>{'\n'}
@@ -83,6 +126,34 @@ const BountyInspector: React.FC<Props> = ({ bounty, onClose, onSelectAttacker })
)} )}
</div> </div>
{isArt && storedAs && (
<div>
<div className="type-label">RAW BYTES</div>
<div
className="info-banner"
style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}
>
<AlertTriangle size={14} />
<span>Attacker-controlled content. Download at your own risk.</span>
</div>
<div className="bd-actions">
<button
className="btn"
onClick={downloadArtifact}
disabled={downloading}
style={{ cursor: downloading ? 'wait' : 'pointer', opacity: downloading ? 0.5 : 1 }}
>
<Download size={12} /> {downloading ? 'DOWNLOADING…' : 'DOWNLOAD RAW'}
</button>
</div>
{dlError && (
<div style={{ color: 'var(--alert)', fontSize: '0.75rem', marginTop: 8 }}>
{dlError}
</div>
)}
</div>
)}
<div> <div>
<div className="type-label">EXPORT</div> <div className="type-label">EXPORT</div>
<div className="bd-actions"> <div className="bd-actions">