merge testing->tomerge/main #7
@@ -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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filteredCmds.length > 0 ? (
|
||||
{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