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 import uuid as _uuid
from typing import Any, List, Optional 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: class AttackersCoreMixin:
@@ -81,6 +81,41 @@ class AttackersCoreMixin:
for a in result.scalars().all() 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( async def get_total_attackers(
self, search: Optional[str] = None, service: Optional[str] = None self, search: Optional[str] = None, service: Optional[str] = None
) -> int: ) -> 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 .fleet.api_deploy_deckies import router as deploy_deckies_router
from .stream.api_stream_events import router as stream_router from .stream.api_stream_events import router as stream_router
from .attackers.api_get_attackers import router as attackers_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_detail import router as attacker_detail_router
from .attackers.api_get_attacker_commands import router as attacker_commands_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 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 # Attacker Profiles
api_router.include_router(attackers_router) 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_detail_router)
api_router.include_router(attacker_commands_router) api_router.include_router(attacker_commands_router)
api_router.include_router(attacker_artifacts_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 React, { useEffect, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; 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 api from '../utils/api';
import EmptyState from './EmptyState/EmptyState'; import EmptyState from './EmptyState/EmptyState';
import { useFocusSearch } from '../hooks/useFocusSearch'; import { useFocusSearch } from '../hooks/useFocusSearch';
@@ -117,6 +117,23 @@ const Attackers: React.FC = () => {
const totalPages = Math.max(1, Math.ceil(total / limit)); 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( const activityCounts = attackers.reduce(
(acc, a) => { acc[deriveActivity(a)]++; return acc; }, (acc, a) => { acc[deriveActivity(a)]++; return acc; },
{ active: 0, passive: 0, inactive: 0 } as Record<ActivityTier, number>, { active: 0, passive: 0, inactive: 0 } as Record<ActivityTier, number>,
@@ -211,6 +228,16 @@ const Attackers: React.FC = () => {
)} )}
</div> </div>
<div className="section-actions"> <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"> <div className="pager">
<span className="dim">Page {page} of {totalPages}</span> <span className="dim">Page {page} of {totalPages}</span>
<button disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page"> <button disabled={page <= 1} onClick={() => setPage(page - 1)} aria-label="Previous page">