From 04b0637c2458690e039f738819e595edf91c7e7b Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 28 Apr 2026 22:03:58 -0400 Subject: [PATCH] feat(bounty): wire artifact download into BountyInspector drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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. --- decnet_web/src/components/BountyInspector.tsx | 81 +++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/decnet_web/src/components/BountyInspector.tsx b/decnet_web/src/components/BountyInspector.tsx index 3bd9d12a..6eb1f79c 100644 --- a/decnet_web/src/components/BountyInspector.tsx +++ b/decnet_web/src/components/BountyInspector.tsx @@ -1,6 +1,7 @@ -import React from 'react'; -import { X, Key, Package, Copy, Send, Ban } from '../icons'; +import React, { useState } from 'react'; +import { X, Key, Package, Copy, Send, Ban, FileText, Mail, Download, AlertTriangle } from '../icons'; import { useToast } from './Toasts/useToast'; +import api from '../utils/api'; interface BountyEntry { id: number; @@ -21,8 +22,14 @@ interface Props { const BountyInspector: React.FC = ({ bounty, onClose, onSelectAttacker }) => { const { push } = useToast(); const isCred = bounty.bounty_type === 'credential'; - const Icon = isCred ? Key : Package; + const isArt = bounty.bounty_type === 'artifact'; 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(null); const copyJson = async () => { try { @@ -33,6 +40,37 @@ const BountyInspector: React.FC = ({ 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 stubBlocklist = () => push({ text: 'BLOCKLIST NOT WIRED', tone: 'violet', icon: 'info' }); @@ -52,7 +90,10 @@ const BountyInspector: React.FC = ({ bounty, onClose, onSelectAttacker })
TYPE
- {bounty.bounty_type.toUpperCase()} + + + {bounty.bounty_type.toUpperCase()}{isMail ? ' · MAIL' : ''} +
TIMESTAMP
{new Date(bounty.timestamp).toLocaleString()}
@@ -72,7 +113,9 @@ const BountyInspector: React.FC = ({ bounty, onClose, onSelectAttacker })
-
{isCred ? 'CAPTURED CREDENTIAL' : 'CAPTURED PAYLOAD'}
+
+ {isCred ? 'CAPTURED CREDENTIAL' : isMail ? 'CAPTURED MESSAGE' : isArt ? 'CAPTURED FILE' : 'CAPTURED PAYLOAD'} +
{isCred ? (
                 username: {p.username}{'\n'}
@@ -83,6 +126,34 @@ const BountyInspector: React.FC = ({ bounty, onClose, onSelectAttacker })
             )}
           
+ {isArt && storedAs && ( +
+
RAW BYTES
+
+ + Attacker-controlled content. Download at your own risk. +
+
+ +
+ {dlError && ( +
+ {dlError} +
+ )} +
+ )} +
EXPORT