feat(attackers): add JSON export endpoint and download button

This commit is contained in:
2026-04-30 10:43:46 -04:00
parent f0756dcdec
commit 2ddba04f79
4 changed files with 159 additions and 3 deletions

View File

@@ -11,9 +11,9 @@ import json
import uuid as _uuid
from typing import Any, List, Optional
from sqlalchemy import desc, func, select
from sqlalchemy import desc, func, outerjoin, select
from decnet.web.db.models import Attacker
from decnet.web.db.models import Attacker, AttackerIntel
class AttackersCoreMixin:
@@ -81,6 +81,41 @@ class AttackersCoreMixin:
for a in result.scalars().all()
]
async def get_all_attackers_for_export(self) -> list[dict[str, Any]]:
"""Return every attacker row left-joined with its intel row.
Used exclusively by the export endpoint — no pagination, ordered by
last_seen desc so the file reads newest-first.
"""
stmt = (
select(Attacker, AttackerIntel)
.select_from(
outerjoin(Attacker, AttackerIntel, Attacker.uuid == AttackerIntel.attacker_uuid)
)
.order_by(desc(Attacker.last_seen))
)
async with self._session() as session:
rows = (await session.execute(stmt)).all()
_intel_raw_keys = ("greynoise_raw", "abuseipdb_raw", "feodo_raw", "threatfox_raw")
result = []
for attacker, intel in rows:
d = self._deserialize_attacker(attacker.model_dump(mode="json"))
if intel is not None:
intel_d = intel.model_dump(mode="json")
for key in _intel_raw_keys:
raw = intel_d.get(key)
if isinstance(raw, str):
try:
intel_d[key] = json.loads(raw)
except (json.JSONDecodeError, TypeError):
pass
d["threat_intel"] = intel_d
else:
d["threat_intel"] = None
result.append(d)
return result
async def get_total_attackers(
self, search: Optional[str] = None, service: Optional[str] = None
) -> int:

View File

@@ -14,6 +14,7 @@ from .fleet.api_mutate_interval import router as mutate_interval_router
from .fleet.api_deploy_deckies import router as deploy_deckies_router
from .stream.api_stream_events import router as stream_router
from .attackers.api_get_attackers import router as attackers_router
from .attackers.api_export_attackers import router as attackers_export_router
from .attackers.api_get_attacker_detail import router as attacker_detail_router
from .attackers.api_get_attacker_commands import router as attacker_commands_router
from .attackers.api_get_attacker_artifacts import router as attacker_artifacts_router
@@ -91,6 +92,7 @@ api_router.include_router(deploy_deckies_router)
# Attacker Profiles
api_router.include_router(attackers_router)
api_router.include_router(attackers_export_router)
api_router.include_router(attacker_detail_router)
api_router.include_router(attacker_commands_router)
api_router.include_router(attacker_artifacts_router)

View File

@@ -0,0 +1,92 @@
"""GET /api/v1/attackers/export — bulk JSON export of all attacker + intel data."""
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from fastapi.responses import Response
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import repo, require_viewer
router = APIRouter()
_SCHEMA_VERSION = "1.0"
_SOURCE = "DECNET Honeypot"
def _shape_observation(row: dict) -> dict:
intel = row.get("threat_intel")
return {
"uuid": row.get("uuid"),
"ip": row.get("ip"),
"first_seen": row.get("first_seen"),
"last_seen": row.get("last_seen"),
"identity_id": row.get("identity_id"),
"event_count": row.get("event_count", 0),
"service_count": row.get("service_count", 0),
"decky_count": row.get("decky_count", 0),
"services": row.get("services", []),
"deckies": row.get("deckies", []),
"traversal_path": row.get("traversal_path"),
"is_traversal": row.get("is_traversal", False),
"bounty_count": row.get("bounty_count", 0),
"credential_count": row.get("credential_count", 0),
"fingerprints": row.get("fingerprints", []),
"commands": row.get("commands", []),
"geoip": {
"country_code": row.get("country_code"),
"source": row.get("country_source"),
},
"network": {
"asn": row.get("asn"),
"as_name": row.get("as_name"),
"ptr_record": row.get("ptr_record"),
},
"threat_intel": {
"aggregate_verdict": intel.get("aggregate_verdict"),
"greynoise_classification": intel.get("greynoise_classification"),
"abuseipdb_score": intel.get("abuseipdb_score"),
"feodo_listed": intel.get("feodo_listed"),
"threatfox_listed": intel.get("threatfox_listed"),
"cached_at": intel.get("cached_at"),
} if intel else None,
}
@router.get(
"/attackers/export",
tags=["Attacker Profiles"],
responses={
200: {"content": {"application/json": {}}, "description": "JSON export download"},
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
},
)
@_traced("api.export_attackers")
async def export_attackers(
user: dict = Depends(require_viewer),
) -> Response:
"""Export all attacker observations and threat-intel as a single JSON file.
Returns a downloadable JSON blob. Intel columns are null for attackers the
enrichment worker has not yet processed.
"""
rows = await repo.get_all_attackers_for_export()
observations = [_shape_observation(r) for r in rows]
payload = {
"export_metadata": {
"source": _SOURCE,
"version": _SCHEMA_VERSION,
"exported_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"total_records": len(observations),
"schema_version": _SCHEMA_VERSION,
},
"observations": observations,
}
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
filename = f"decnet-export-{ts}.json"
return Response(
content=json.dumps(payload, default=str, ensure_ascii=False, indent=2),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Search, ChevronLeft, ChevronRight, Users } from '../icons';
import { Search, ChevronLeft, ChevronRight, Users, Download } from '../icons';
import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import { useFocusSearch } from '../hooks/useFocusSearch';
@@ -117,6 +117,23 @@ const Attackers: React.FC = () => {
const totalPages = Math.max(1, Math.ceil(total / limit));
const handleExport = async () => {
try {
const res = await api.get('/attackers/export', { responseType: 'blob' });
const disposition: string = res.headers['content-disposition'] || '';
const match = disposition.match(/filename="([^"]+)"/);
const filename = match ? match[1] : 'decnet-export.json';
const url = URL.createObjectURL(new Blob([res.data], { type: 'application/json' }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error('Export failed', err);
}
};
const activityCounts = attackers.reduce(
(acc, a) => { acc[deriveActivity(a)]++; return acc; },
{ active: 0, passive: 0, inactive: 0 } as Record<ActivityTier, number>,
@@ -211,6 +228,16 @@ const Attackers: React.FC = () => {
)}
</div>
<div className="section-actions">
<button
type="button"
className="btn btn-ghost"
onClick={handleExport}
title="Export all threat data as JSON"
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
>
<Download size={13} />
EXPORT
</button>
<div className="pager">
<span className="dim">Page {page} of {totalPages}</span>
<button disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">