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:
|
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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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 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() : '-'}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user