merge testing->tomerge/main #7

Open
anti wants to merge 242 commits from testing into tomerge/main
7 changed files with 194 additions and 8 deletions
Showing only changes of commit f3bb0b31ae - Show all commits

View File

@@ -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

View File

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

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 .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)

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 { 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() : '-'}

View File

@@ -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:

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_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")