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.
This commit is contained in:
2026-04-14 01:45:19 -04:00
parent 8c249f6987
commit f3bb0b31ae
7 changed files with 194 additions and 8 deletions

View File

@@ -142,3 +142,14 @@ class BaseRepository(ABC):
async def get_total_attackers(self, search: Optional[str] = None, service: Optional[str] = None) -> int: 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.""" """Retrieve the total count of attacker profile records, optionally filtered."""
pass 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

View File

@@ -514,3 +514,26 @@ class SQLiteRepository(BaseRepository):
async with self.session_factory() as session: async with self.session_factory() as session:
result = await session.execute(statement) result = await session.execute(statement)
return result.scalar() or 0 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}

View File

@@ -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 .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_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
api_router = APIRouter() api_router = APIRouter()
@@ -36,6 +37,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(attacker_detail_router) api_router.include_router(attacker_detail_router)
api_router.include_router(attacker_commands_router)
# Observability # Observability
api_router.include_router(stats_router) api_router.include_router(stats_router)

View File

@@ -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"]}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; 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 api from '../utils/api';
import './Dashboard.css'; import './Dashboard.css';
@@ -218,6 +218,12 @@ const AttackerDetail: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [serviceFilter, setServiceFilter] = useState<string | null>(null); const [serviceFilter, setServiceFilter] = useState<string | null>(null);
// Commands pagination state
const [commands, setCommands] = useState<AttackerData['commands']>([]);
const [cmdTotal, setCmdTotal] = useState(0);
const [cmdPage, setCmdPage] = useState(1);
const cmdLimit = 50;
useEffect(() => { useEffect(() => {
const fetchAttacker = async () => { const fetchAttacker = async () => {
setLoading(true); setLoading(true);
@@ -237,6 +243,29 @@ const AttackerDetail: React.FC = () => {
fetchAttacker(); fetchAttacker();
}, [id]); }, [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) { if (loading) {
return ( return (
<div className="dashboard"> <div className="dashboard">
@@ -383,15 +412,36 @@ const AttackerDetail: React.FC = () => {
{/* Commands */} {/* Commands */}
{(() => { {(() => {
const filteredCmds = serviceFilter const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit);
? attacker.commands.filter((cmd) => cmd.service === serviceFilter)
: attacker.commands;
return ( return (
<div className="logs-section"> <div className="logs-section">
<div className="section-header"> <div className="section-header" style={{ justifyContent: 'space-between' }}>
<h2>COMMANDS ({filteredCmds.length}{serviceFilter ? ` / ${attacker.commands.length}` : ''})</h2> <h2>COMMANDS ({cmdTotal}{serviceFilter ? ` ${serviceFilter.toUpperCase()}` : ''})</h2>
{cmdTotalPages > 1 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span className="dim" style={{ fontSize: '0.8rem' }}>
Page {cmdPage} of {cmdTotalPages}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
disabled={cmdPage <= 1}
onClick={() => setCmdPage(cmdPage - 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: cmdPage <= 1 ? 0.3 : 1 }}
>
<ChevronLeft size={16} />
</button>
<button
disabled={cmdPage >= cmdTotalPages}
onClick={() => setCmdPage(cmdPage + 1)}
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: cmdPage >= cmdTotalPages ? 0.3 : 1 }}
>
<ChevronRight size={16} />
</button>
</div> </div>
{filteredCmds.length > 0 ? ( </div>
)}
</div>
{commands.length > 0 ? (
<div className="logs-table-container"> <div className="logs-table-container">
<table className="logs-table"> <table className="logs-table">
<thead> <thead>
@@ -403,7 +453,7 @@ const AttackerDetail: React.FC = () => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredCmds.map((cmd, i) => ( {commands.map((cmd, i) => (
<tr key={i}> <tr key={i}>
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}> <td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
{cmd.timestamp ? new Date(cmd.timestamp).toLocaleString() : '-'} {cmd.timestamp ? new Date(cmd.timestamp).toLocaleString() : '-'}

View File

@@ -204,6 +204,66 @@ class TestGetAttackerDetail:
assert isinstance(result["commands"], list) 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 ──────────────────────────────────────────────────────── # ─── Auth enforcement ────────────────────────────────────────────────────────
class TestAttackersAuth: class TestAttackersAuth:

View File

@@ -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_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_attackers(self, **kw): await super().get_attackers(**kw)
async def get_total_attackers(self, **kw): await super().get_total_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 @pytest.mark.asyncio
async def test_base_repo_coverage(): 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_attacker_by_uuid("a")
await dr.get_attackers() await dr.get_attackers()
await dr.get_total_attackers() await dr.get_total_attackers()
await dr.get_attacker_commands(uuid="a")