feat(stix): STIX→MISP download export (per-attacker + fleet)

Adds GET /api/v1/attackers/{uuid}/export/misp and
GET /api/v1/attackers/export/misp backed by misp_export.py, which
converts existing STIX bundles to MISP events via misp-stix
ExternalSTIX2toMISPParser. Fleet endpoint emits {response:[...]}
collection (one event per attacker). Frontend: STIX/MISP buttons on
AttackerDetail header and Attackers list. 13 new tests green.
This commit is contained in:
2026-05-09 08:04:25 -04:00
parent 8990d9321d
commit 1200ac9132
9 changed files with 661 additions and 17 deletions

View File

@@ -14,19 +14,32 @@ interface Props {
export const AttackerHeader: React.FC<Props> = ({ attacker }) => {
const navigate = useNavigate();
const handleStixDownload = async () => {
const _download = async (endpoint: string, filename: string) => {
try {
const res = await api.get(`/attackers/${attacker.uuid}/export/stix`, { responseType: 'blob' });
const res = await api.get(endpoint, { responseType: 'blob' });
const href = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = href;
a.download = `decnet-attacker-${attacker.uuid.slice(0, 8)}.stix.json`;
a.download = filename;
a.click();
URL.revokeObjectURL(href);
} catch {
// best-effort
}
};
const handleStixDownload = () =>
_download(
`/attackers/${attacker.uuid}/export/stix`,
`decnet-attacker-${attacker.uuid.slice(0, 8)}.stix.json`,
);
const handleMispDownload = () =>
_download(
`/attackers/${attacker.uuid}/export/misp`,
`decnet-attacker-${attacker.uuid.slice(0, 8)}.misp.json`,
);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Crosshair size={32} className="violet-accent" />
@@ -60,16 +73,26 @@ export const AttackerHeader: React.FC<Props> = ({ attacker }) => {
IDENTITY · {attacker.identity_id.slice(0, 8)}
</span>
)}
<button
type="button"
className="btn"
style={{ marginLeft: 'auto' }}
title="Download STIX 2.1 bundle for this attacker"
onClick={handleStixDownload}
>
<Download size={12} />
<span style={{ marginLeft: 6 }}>STIX</span>
</button>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<button
type="button"
className="btn"
title="Download STIX 2.1 bundle for this attacker"
onClick={handleStixDownload}
>
<Download size={12} />
<span style={{ marginLeft: 6 }}>STIX</span>
</button>
<button
type="button"
className="btn"
title="Download MISP event for this attacker"
onClick={handleMispDownload}
>
<Download size={12} />
<span style={{ marginLeft: 6 }}>MISP</span>
</button>
</div>
</div>
);
};

View File

@@ -117,12 +117,12 @@ const Attackers: React.FC = () => {
const totalPages = Math.max(1, Math.ceil(total / limit));
const handleExport = async () => {
const _fleetDownload = async (endpoint: string, fallback: string) => {
try {
const res = await api.get('/attackers/export/stix', { responseType: 'blob' });
const res = await api.get(endpoint, { responseType: 'blob' });
const disposition: string = res.headers['content-disposition'] || '';
const match = disposition.match(/filename="([^"]+)"/);
const filename = match ? match[1] : 'decnet-fleet.stix.json';
const filename = match ? match[1] : fallback;
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/json' }));
const a = document.createElement('a');
a.href = url;
@@ -134,6 +134,9 @@ const Attackers: React.FC = () => {
}
};
const handleExport = () => _fleetDownload('/attackers/export/stix', 'decnet-fleet.stix.json');
const handleMispExport = () => _fleetDownload('/attackers/export/misp', 'decnet-fleet.misp.json');
const activityCounts = attackers.reduce(
(acc, a) => { acc[deriveActivity(a)]++; return acc; },
{ active: 0, passive: 0, inactive: 0 } as Record<ActivityTier, number>,
@@ -236,7 +239,17 @@ const Attackers: React.FC = () => {
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
>
<Download size={13} />
EXPORT
STIX
</button>
<button
type="button"
className="btn btn-ghost"
onClick={handleMispExport}
title="Export all attackers as MISP collection"
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
>
<Download size={13} />
MISP
</button>
<div className="pager">
<span className="dim">Page {page} of {totalPages}</span>