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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
38
decnet/web/router/attackers/api_get_attacker_commands.py
Normal file
38
decnet/web/router/attackers/api_get_attacker_commands.py
Normal 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"]}
|
||||
@@ -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<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(() => {
|
||||
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 (
|
||||
<div className="dashboard">
|
||||
@@ -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 (
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<h2>COMMANDS ({filteredCmds.length}{serviceFilter ? ` / ${attacker.commands.length}` : ''})</h2>
|
||||
<div className="section-header" style={{ justifyContent: 'space-between' }}>
|
||||
<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>
|
||||
{filteredCmds.length > 0 ? (
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{commands.length > 0 ? (
|
||||
<div className="logs-table-container">
|
||||
<table className="logs-table">
|
||||
<thead>
|
||||
@@ -403,7 +453,7 @@ const AttackerDetail: React.FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCmds.map((cmd, i) => (
|
||||
{commands.map((cmd, i) => (
|
||||
<tr key={i}>
|
||||
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
|
||||
{cmd.timestamp ? new Date(cmd.timestamp).toLocaleString() : '-'}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user