From f3bb0b31ae406701d5f31c5edace5e908c9143bc Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 14 Apr 2026 01:45:19 -0400 Subject: [PATCH] feat: paginated commands endpoint for attacker profiles New GET /attackers/{uuid}/commands?limit=&offset=&service= endpoint serves commands with server-side pagination and optional service filter. AttackerDetail frontend fetches commands from this endpoint with page controls. Service badge filter now drives both the API query and the local fingerprint filter. --- decnet/web/db/repository.py | 11 ++++ decnet/web/db/sqlite/repository.py | 23 +++++++ decnet/web/router/__init__.py | 2 + .../attackers/api_get_attacker_commands.py | 38 +++++++++++ decnet_web/src/components/AttackerDetail.tsx | 66 ++++++++++++++++--- tests/test_api_attackers.py | 60 +++++++++++++++++ tests/test_base_repo.py | 2 + 7 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 decnet/web/router/attackers/api_get_attacker_commands.py diff --git a/decnet/web/db/repository.py b/decnet/web/db/repository.py index 944b73b..b5ac989 100644 --- a/decnet/web/db/repository.py +++ b/decnet/web/db/repository.py @@ -142,3 +142,14 @@ class BaseRepository(ABC): async def get_total_attackers(self, search: Optional[str] = None, service: Optional[str] = None) -> int: """Retrieve the total count of attacker profile records, optionally filtered.""" pass + + @abstractmethod + async def get_attacker_commands( + self, + uuid: str, + limit: int = 50, + offset: int = 0, + service: Optional[str] = None, + ) -> dict[str, Any]: + """Retrieve paginated commands for an attacker, optionally filtered by service.""" + pass diff --git a/decnet/web/db/sqlite/repository.py b/decnet/web/db/sqlite/repository.py index c58747d..b2766d4 100644 --- a/decnet/web/db/sqlite/repository.py +++ b/decnet/web/db/sqlite/repository.py @@ -514,3 +514,26 @@ class SQLiteRepository(BaseRepository): async with self.session_factory() as session: result = await session.execute(statement) return result.scalar() or 0 + + async def get_attacker_commands( + self, + uuid: str, + limit: int = 50, + offset: int = 0, + service: Optional[str] = None, + ) -> dict[str, Any]: + async with self.session_factory() as session: + result = await session.execute( + select(Attacker.commands).where(Attacker.uuid == uuid) + ) + raw = result.scalar_one_or_none() + if raw is None: + return {"total": 0, "data": []} + + commands: list = json.loads(raw) if isinstance(raw, str) else raw + if service: + commands = [c for c in commands if c.get("service") == service] + + total = len(commands) + page = commands[offset: offset + limit] + return {"total": total, "data": page} diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py index 87a2cef..f9bc6a9 100644 --- a/decnet/web/router/__init__.py +++ b/decnet/web/router/__init__.py @@ -13,6 +13,7 @@ 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_get_attacker_detail import router as attacker_detail_router +from .attackers.api_get_attacker_commands import router as attacker_commands_router api_router = APIRouter() @@ -36,6 +37,7 @@ api_router.include_router(deploy_deckies_router) # Attacker Profiles api_router.include_router(attackers_router) api_router.include_router(attacker_detail_router) +api_router.include_router(attacker_commands_router) # Observability api_router.include_router(stats_router) diff --git a/decnet/web/router/attackers/api_get_attacker_commands.py b/decnet/web/router/attackers/api_get_attacker_commands.py new file mode 100644 index 0000000..c0d152b --- /dev/null +++ b/decnet/web/router/attackers/api_get_attacker_commands.py @@ -0,0 +1,38 @@ +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from decnet.web.dependencies import get_current_user, repo + +router = APIRouter() + + +@router.get( + "/attackers/{uuid}/commands", + tags=["Attacker Profiles"], + responses={ + 401: {"description": "Could not validate credentials"}, + 404: {"description": "Attacker not found"}, + }, +) +async def get_attacker_commands( + uuid: str, + limit: int = Query(50, ge=1, le=1000), + offset: int = Query(0, ge=0, le=2147483647), + service: Optional[str] = None, + current_user: str = Depends(get_current_user), +) -> dict[str, Any]: + """Retrieve paginated commands for an attacker profile.""" + attacker = await repo.get_attacker_by_uuid(uuid) + if not attacker: + raise HTTPException(status_code=404, detail="Attacker not found") + + def _norm(v: Optional[str]) -> Optional[str]: + if v in (None, "null", "NULL", "undefined", ""): + return None + return v + + result = await repo.get_attacker_commands( + uuid=uuid, limit=limit, offset=offset, service=_norm(service), + ) + return {"total": result["total"], "limit": limit, "offset": offset, "data": result["data"]} diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index c4d93cb..a9d862c 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey } from 'lucide-react'; +import { ArrowLeft, ChevronLeft, ChevronRight, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey } from 'lucide-react'; import api from '../utils/api'; import './Dashboard.css'; @@ -218,6 +218,12 @@ const AttackerDetail: React.FC = () => { const [error, setError] = useState(null); const [serviceFilter, setServiceFilter] = useState(null); + // Commands pagination state + const [commands, setCommands] = useState([]); + const [cmdTotal, setCmdTotal] = useState(0); + const [cmdPage, setCmdPage] = useState(1); + const cmdLimit = 50; + useEffect(() => { const fetchAttacker = async () => { setLoading(true); @@ -237,6 +243,29 @@ const AttackerDetail: React.FC = () => { fetchAttacker(); }, [id]); + useEffect(() => { + if (!id) return; + const fetchCommands = async () => { + try { + const offset = (cmdPage - 1) * cmdLimit; + let url = `/attackers/${id}/commands?limit=${cmdLimit}&offset=${offset}`; + if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`; + const res = await api.get(url); + setCommands(res.data.data); + setCmdTotal(res.data.total); + } catch { + setCommands([]); + setCmdTotal(0); + } + }; + fetchCommands(); + }, [id, cmdPage, serviceFilter]); + + // Reset command page when service filter changes + useEffect(() => { + setCmdPage(1); + }, [serviceFilter]); + if (loading) { return (
@@ -383,15 +412,36 @@ const AttackerDetail: React.FC = () => { {/* Commands */} {(() => { - const filteredCmds = serviceFilter - ? attacker.commands.filter((cmd) => cmd.service === serviceFilter) - : attacker.commands; + const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit); return (
-
-

COMMANDS ({filteredCmds.length}{serviceFilter ? ` / ${attacker.commands.length}` : ''})

+
+

COMMANDS ({cmdTotal}{serviceFilter ? ` ${serviceFilter.toUpperCase()}` : ''})

+ {cmdTotalPages > 1 && ( +
+ + Page {cmdPage} of {cmdTotalPages} + +
+ + +
+
+ )}
- {filteredCmds.length > 0 ? ( + {commands.length > 0 ? (
@@ -403,7 +453,7 @@ const AttackerDetail: React.FC = () => { - {filteredCmds.map((cmd, i) => ( + {commands.map((cmd, i) => (
{cmd.timestamp ? new Date(cmd.timestamp).toLocaleString() : '-'} diff --git a/tests/test_api_attackers.py b/tests/test_api_attackers.py index e873efa..151f860 100644 --- a/tests/test_api_attackers.py +++ b/tests/test_api_attackers.py @@ -204,6 +204,66 @@ class TestGetAttackerDetail: assert isinstance(result["commands"], list) +# ─── GET /attackers/{uuid}/commands ────────────────────────────────────────── + +class TestGetAttackerCommands: + @pytest.mark.asyncio + async def test_returns_paginated_commands(self): + from decnet.web.router.attackers.api_get_attacker_commands import get_attacker_commands + + sample = _sample_attacker() + cmds = [ + {"service": "ssh", "decky": "decky-01", "command": "id", "timestamp": "2026-04-01T10:00:00"}, + {"service": "ssh", "decky": "decky-01", "command": "whoami", "timestamp": "2026-04-01T10:01:00"}, + ] + with patch("decnet.web.router.attackers.api_get_attacker_commands.repo") as mock_repo: + mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample) + mock_repo.get_attacker_commands = AsyncMock(return_value={"total": 2, "data": cmds}) + + result = await get_attacker_commands( + uuid="att-uuid-1", limit=50, offset=0, service=None, + current_user="test-user", + ) + + assert result["total"] == 2 + assert len(result["data"]) == 2 + assert result["limit"] == 50 + assert result["offset"] == 0 + + @pytest.mark.asyncio + async def test_service_filter_forwarded(self): + from decnet.web.router.attackers.api_get_attacker_commands import get_attacker_commands + + sample = _sample_attacker() + with patch("decnet.web.router.attackers.api_get_attacker_commands.repo") as mock_repo: + mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample) + mock_repo.get_attacker_commands = AsyncMock(return_value={"total": 0, "data": []}) + + await get_attacker_commands( + uuid="att-uuid-1", limit=50, offset=0, service="ssh", + current_user="test-user", + ) + + mock_repo.get_attacker_commands.assert_awaited_once_with( + uuid="att-uuid-1", limit=50, offset=0, service="ssh", + ) + + @pytest.mark.asyncio + async def test_404_on_unknown_uuid(self): + from decnet.web.router.attackers.api_get_attacker_commands import get_attacker_commands + + with patch("decnet.web.router.attackers.api_get_attacker_commands.repo") as mock_repo: + mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None) + + with pytest.raises(HTTPException) as exc_info: + await get_attacker_commands( + uuid="nonexistent", limit=50, offset=0, service=None, + current_user="test-user", + ) + + assert exc_info.value.status_code == 404 + + # ─── Auth enforcement ──────────────────────────────────────────────────────── class TestAttackersAuth: diff --git a/tests/test_base_repo.py b/tests/test_base_repo.py index dad3496..4d00572 100644 --- a/tests/test_base_repo.py +++ b/tests/test_base_repo.py @@ -30,6 +30,7 @@ class DummyRepo(BaseRepository): async def get_attacker_by_uuid(self, u): await super().get_attacker_by_uuid(u) async def get_attackers(self, **kw): await super().get_attackers(**kw) async def get_total_attackers(self, **kw): await super().get_total_attackers(**kw) + async def get_attacker_commands(self, **kw): await super().get_attacker_commands(**kw) @pytest.mark.asyncio async def test_base_repo_coverage(): @@ -59,3 +60,4 @@ async def test_base_repo_coverage(): await dr.get_attacker_by_uuid("a") await dr.get_attackers() await dr.get_total_attackers() + await dr.get_attacker_commands(uuid="a")