diff --git a/decnet/web/db/sqlmodel_repo/attackers/_core.py b/decnet/web/db/sqlmodel_repo/attackers/_core.py index a5240032..6479c40e 100644 --- a/decnet/web/db/sqlmodel_repo/attackers/_core.py +++ b/decnet/web/db/sqlmodel_repo/attackers/_core.py @@ -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: diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py index 8d1b57a9..c7138d27 100644 --- a/decnet/web/router/__init__.py +++ b/decnet/web/router/__init__.py @@ -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) diff --git a/decnet/web/router/attackers/api_export_attackers.py b/decnet/web/router/attackers/api_export_attackers.py new file mode 100644 index 00000000..1f021d1d --- /dev/null +++ b/decnet/web/router/attackers/api_export_attackers.py @@ -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}"'}, + ) diff --git a/decnet_web/src/components/Attackers.tsx b/decnet_web/src/components/Attackers.tsx index 4fd9813d..03be522a 100644 --- a/decnet_web/src/components/Attackers.tsx +++ b/decnet_web/src/components/Attackers.tsx @@ -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, @@ -211,6 +228,16 @@ const Attackers: React.FC = () => { )}
+
Page {page} of {totalPages}