feat: attacker profiles — UUID model, API routes, list/detail frontend
Migrate Attacker model from IP-based to UUID-based primary key with
auto-migration for old schema. Add GET /attackers (paginated, search,
sort) and GET /attackers/{uuid} API routes. Rewrite Attackers.tsx as
a card grid with full threat info and create AttackerDetail.tsx as a
dedicated detail page with back navigation, stats, commands table,
and fingerprints.
This commit is contained in:
@@ -1,21 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
Attacker profile builder — background worker.
|
Attacker profile builder — incremental background worker.
|
||||||
|
|
||||||
Periodically rebuilds the `attackers` table by:
|
Maintains a persistent CorrelationEngine and a log-ID cursor across cycles.
|
||||||
1. Feeding all stored Log.raw_line values through the CorrelationEngine
|
On cold start (first cycle or process restart), performs one full build from
|
||||||
(which parses RFC 5424 and tracks per-IP event histories + traversals).
|
all stored logs. Subsequent cycles fetch only new logs via the cursor,
|
||||||
2. Merging with the Bounty table (fingerprints, credentials).
|
ingest them into the existing engine, and rebuild profiles for affected IPs
|
||||||
3. Extracting commands executed per IP from the structured log fields.
|
only.
|
||||||
4. Upserting one Attacker record per observed IP.
|
|
||||||
|
|
||||||
Runs every _REBUILD_INTERVAL seconds. Full rebuild each cycle — simple and
|
Complexity per cycle: O(new_logs + affected_ips) instead of O(total_logs²).
|
||||||
correct at honeypot log volumes.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -27,6 +26,8 @@ from decnet.web.db.repository import BaseRepository
|
|||||||
logger = get_logger("attacker_worker")
|
logger = get_logger("attacker_worker")
|
||||||
|
|
||||||
_REBUILD_INTERVAL = 30 # seconds
|
_REBUILD_INTERVAL = 30 # seconds
|
||||||
|
_BATCH_SIZE = 500
|
||||||
|
_STATE_KEY = "attacker_worker_cursor"
|
||||||
|
|
||||||
# Event types that indicate active command/query execution (not just connection/scan)
|
# Event types that indicate active command/query execution (not just connection/scan)
|
||||||
_COMMAND_EVENT_TYPES = frozenset({
|
_COMMAND_EVENT_TYPES = frozenset({
|
||||||
@@ -38,44 +39,95 @@ _COMMAND_EVENT_TYPES = frozenset({
|
|||||||
_COMMAND_FIELDS = ("command", "query", "input", "line", "sql", "cmd")
|
_COMMAND_FIELDS = ("command", "query", "input", "line", "sql", "cmd")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _WorkerState:
|
||||||
|
engine: CorrelationEngine = field(default_factory=CorrelationEngine)
|
||||||
|
last_log_id: int = 0
|
||||||
|
initialized: bool = False
|
||||||
|
|
||||||
|
|
||||||
async def attacker_profile_worker(repo: BaseRepository) -> None:
|
async def attacker_profile_worker(repo: BaseRepository) -> None:
|
||||||
"""Periodically rebuilds the Attacker table. Designed to run as an asyncio Task."""
|
"""Periodically updates the Attacker table incrementally. Designed to run as an asyncio Task."""
|
||||||
logger.info("attacker profile worker started interval=%ds", _REBUILD_INTERVAL)
|
logger.info("attacker profile worker started interval=%ds", _REBUILD_INTERVAL)
|
||||||
|
state = _WorkerState()
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(_REBUILD_INTERVAL)
|
await asyncio.sleep(_REBUILD_INTERVAL)
|
||||||
try:
|
try:
|
||||||
await _rebuild(repo)
|
await _incremental_update(repo, state)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("attacker worker: rebuild failed: %s", exc)
|
logger.error("attacker worker: update failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
async def _rebuild(repo: BaseRepository) -> None:
|
async def _incremental_update(repo: BaseRepository, state: _WorkerState) -> None:
|
||||||
|
if not state.initialized:
|
||||||
|
await _cold_start(repo, state)
|
||||||
|
return
|
||||||
|
|
||||||
|
affected_ips: set[str] = set()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
batch = await repo.get_logs_after_id(state.last_log_id, limit=_BATCH_SIZE)
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
|
||||||
|
for row in batch:
|
||||||
|
event = state.engine.ingest(row["raw_line"])
|
||||||
|
if event and event.attacker_ip:
|
||||||
|
affected_ips.add(event.attacker_ip)
|
||||||
|
state.last_log_id = row["id"]
|
||||||
|
|
||||||
|
if len(batch) < _BATCH_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not affected_ips:
|
||||||
|
await repo.set_state(_STATE_KEY, {"last_log_id": state.last_log_id})
|
||||||
|
return
|
||||||
|
|
||||||
|
await _update_profiles(repo, state, affected_ips)
|
||||||
|
await repo.set_state(_STATE_KEY, {"last_log_id": state.last_log_id})
|
||||||
|
|
||||||
|
logger.debug("attacker worker: updated %d profiles (incremental)", len(affected_ips))
|
||||||
|
|
||||||
|
|
||||||
|
async def _cold_start(repo: BaseRepository, state: _WorkerState) -> None:
|
||||||
all_logs = await repo.get_all_logs_raw()
|
all_logs = await repo.get_all_logs_raw()
|
||||||
if not all_logs:
|
if not all_logs:
|
||||||
|
state.last_log_id = await repo.get_max_log_id()
|
||||||
|
state.initialized = True
|
||||||
|
await repo.set_state(_STATE_KEY, {"last_log_id": state.last_log_id})
|
||||||
return
|
return
|
||||||
|
|
||||||
# Feed raw RFC 5424 lines into the CorrelationEngine
|
|
||||||
engine = CorrelationEngine()
|
|
||||||
for row in all_logs:
|
for row in all_logs:
|
||||||
engine.ingest(row["raw_line"])
|
state.engine.ingest(row["raw_line"])
|
||||||
|
state.last_log_id = max(state.last_log_id, row["id"])
|
||||||
|
|
||||||
if not engine._events:
|
all_ips = set(state.engine._events.keys())
|
||||||
return
|
await _update_profiles(repo, state, all_ips)
|
||||||
|
await repo.set_state(_STATE_KEY, {"last_log_id": state.last_log_id})
|
||||||
|
|
||||||
traversal_map = {t.attacker_ip: t for t in engine.traversals(min_deckies=2)}
|
state.initialized = True
|
||||||
all_bounties = await repo.get_all_bounties_by_ip()
|
logger.debug("attacker worker: cold start rebuilt %d profiles", len(all_ips))
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_profiles(
|
||||||
|
repo: BaseRepository,
|
||||||
|
state: _WorkerState,
|
||||||
|
ips: set[str],
|
||||||
|
) -> None:
|
||||||
|
traversal_map = {t.attacker_ip: t for t in state.engine.traversals(min_deckies=2)}
|
||||||
|
bounties_map = await repo.get_bounties_for_ips(ips)
|
||||||
|
|
||||||
|
for ip in ips:
|
||||||
|
events = state.engine._events.get(ip, [])
|
||||||
|
if not events:
|
||||||
|
continue
|
||||||
|
|
||||||
count = 0
|
|
||||||
for ip, events in engine._events.items():
|
|
||||||
traversal = traversal_map.get(ip)
|
traversal = traversal_map.get(ip)
|
||||||
bounties = all_bounties.get(ip, [])
|
bounties = bounties_map.get(ip, [])
|
||||||
commands = _extract_commands(all_logs, ip)
|
commands = _extract_commands_from_events(events)
|
||||||
|
|
||||||
record = _build_record(ip, events, traversal, bounties, commands)
|
record = _build_record(ip, events, traversal, bounties, commands)
|
||||||
await repo.upsert_attacker(record)
|
await repo.upsert_attacker(record)
|
||||||
count += 1
|
|
||||||
|
|
||||||
logger.debug("attacker worker: rebuilt %d profiles", count)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_record(
|
def _build_record(
|
||||||
@@ -122,42 +174,20 @@ def _first_contact_deckies(events: list[LogEvent]) -> list[str]:
|
|||||||
return seen
|
return seen
|
||||||
|
|
||||||
|
|
||||||
def _extract_commands(
|
def _extract_commands_from_events(events: list[LogEvent]) -> list[dict[str, Any]]:
|
||||||
all_logs: list[dict[str, Any]], ip: str
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""
|
"""
|
||||||
Extract executed commands for a given attacker IP from raw log rows.
|
Extract executed commands from LogEvent objects.
|
||||||
|
|
||||||
Looks for rows where:
|
Works directly on LogEvent.fields (already a dict), so no JSON parsing needed.
|
||||||
- attacker_ip matches
|
|
||||||
- event_type is a known command-execution type
|
|
||||||
- fields JSON contains a command-like key
|
|
||||||
|
|
||||||
Returns a list of {service, decky, command, timestamp} dicts.
|
|
||||||
"""
|
"""
|
||||||
commands: list[dict[str, Any]] = []
|
commands: list[dict[str, Any]] = []
|
||||||
for row in all_logs:
|
for event in events:
|
||||||
if row.get("attacker_ip") != ip:
|
if event.event_type not in _COMMAND_EVENT_TYPES:
|
||||||
continue
|
continue
|
||||||
if row.get("event_type") not in _COMMAND_EVENT_TYPES:
|
|
||||||
continue
|
|
||||||
|
|
||||||
raw_fields = row.get("fields")
|
|
||||||
if not raw_fields:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# fields is stored as a JSON string in the DB row
|
|
||||||
if isinstance(raw_fields, str):
|
|
||||||
try:
|
|
||||||
fields = json.loads(raw_fields)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
fields = raw_fields
|
|
||||||
|
|
||||||
cmd_text: str | None = None
|
cmd_text: str | None = None
|
||||||
for key in _COMMAND_FIELDS:
|
for key in _COMMAND_FIELDS:
|
||||||
val = fields.get(key)
|
val = event.fields.get(key)
|
||||||
if val:
|
if val:
|
||||||
cmd_text = str(val)
|
cmd_text = str(val)
|
||||||
break
|
break
|
||||||
@@ -165,12 +195,11 @@ def _extract_commands(
|
|||||||
if not cmd_text:
|
if not cmd_text:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ts = row.get("timestamp")
|
|
||||||
commands.append({
|
commands.append({
|
||||||
"service": row.get("service", ""),
|
"service": event.service,
|
||||||
"decky": row.get("decky", ""),
|
"decky": event.decky,
|
||||||
"command": cmd_text,
|
"command": cmd_text,
|
||||||
"timestamp": ts.isoformat() if isinstance(ts, datetime) else str(ts),
|
"timestamp": event.timestamp.isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
return commands
|
return commands
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ class State(SQLModel, table=True):
|
|||||||
|
|
||||||
class Attacker(SQLModel, table=True):
|
class Attacker(SQLModel, table=True):
|
||||||
__tablename__ = "attackers"
|
__tablename__ = "attackers"
|
||||||
ip: str = Field(primary_key=True)
|
uuid: str = Field(primary_key=True)
|
||||||
|
ip: str = Field(index=True)
|
||||||
first_seen: datetime = Field(index=True)
|
first_seen: datetime = Field(index=True)
|
||||||
last_seen: datetime = Field(index=True)
|
last_seen: datetime = Field(index=True)
|
||||||
event_count: int = Field(default=0)
|
event_count: int = Field(default=0)
|
||||||
|
|||||||
@@ -96,16 +96,36 @@ class BaseRepository(ABC):
|
|||||||
"""Retrieve all log rows with fields needed by the attacker profile worker."""
|
"""Retrieve all log rows with fields needed by the attacker profile worker."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_max_log_id(self) -> int:
|
||||||
|
"""Return the highest log ID, or 0 if the table is empty."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_logs_after_id(self, last_id: int, limit: int = 500) -> list[dict[str, Any]]:
|
||||||
|
"""Return logs with id > last_id, ordered by id ASC, up to limit."""
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_all_bounties_by_ip(self) -> dict[str, list[dict[str, Any]]]:
|
async def get_all_bounties_by_ip(self) -> dict[str, list[dict[str, Any]]]:
|
||||||
"""Retrieve all bounty rows grouped by attacker_ip."""
|
"""Retrieve all bounty rows grouped by attacker_ip."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_bounties_for_ips(self, ips: set[str]) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""Retrieve bounty rows grouped by attacker_ip, filtered to only the given IPs."""
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def upsert_attacker(self, data: dict[str, Any]) -> None:
|
async def upsert_attacker(self, data: dict[str, Any]) -> None:
|
||||||
"""Insert or replace an attacker profile record."""
|
"""Insert or replace an attacker profile record."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_attacker_by_uuid(self, uuid: str) -> Optional[dict[str, Any]]:
|
||||||
|
"""Retrieve a single attacker profile by UUID."""
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_attackers(
|
async def get_attackers(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class SQLiteRepository(BaseRepository):
|
|||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
"""Async warm-up / verification. Creates tables if they don't exist."""
|
"""Async warm-up / verification. Creates tables if they don't exist."""
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
await self._migrate_attackers_table()
|
||||||
async with self.engine.begin() as conn:
|
async with self.engine.begin() as conn:
|
||||||
await conn.run_sync(SQLModel.metadata.create_all)
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
@@ -47,6 +48,13 @@ class SQLiteRepository(BaseRepository):
|
|||||||
))
|
))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
async def _migrate_attackers_table(self) -> None:
|
||||||
|
"""Drop the old attackers table if it lacks the uuid column (pre-UUID schema)."""
|
||||||
|
async with self.engine.begin() as conn:
|
||||||
|
rows = (await conn.execute(text("PRAGMA table_info(attackers)"))).fetchall()
|
||||||
|
if rows and not any(r[1] == "uuid" for r in rows):
|
||||||
|
await conn.execute(text("DROP TABLE attackers"))
|
||||||
|
|
||||||
async def reinitialize(self) -> None:
|
async def reinitialize(self) -> None:
|
||||||
"""Initialize the database schema asynchronously (useful for tests)."""
|
"""Initialize the database schema asynchronously (useful for tests)."""
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
@@ -418,6 +426,22 @@ class SQLiteRepository(BaseRepository):
|
|||||||
grouped[item.attacker_ip].append(d)
|
grouped[item.attacker_ip].append(d)
|
||||||
return dict(grouped)
|
return dict(grouped)
|
||||||
|
|
||||||
|
async def get_bounties_for_ips(self, ips: set[str]) -> dict[str, List[dict[str, Any]]]:
|
||||||
|
from collections import defaultdict
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Bounty).where(Bounty.attacker_ip.in_(ips)).order_by(asc(Bounty.timestamp))
|
||||||
|
)
|
||||||
|
grouped: dict[str, List[dict[str, Any]]] = defaultdict(list)
|
||||||
|
for item in result.scalars().all():
|
||||||
|
d = item.model_dump(mode="json")
|
||||||
|
try:
|
||||||
|
d["payload"] = json.loads(d["payload"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
grouped[item.attacker_ip].append(d)
|
||||||
|
return dict(grouped)
|
||||||
|
|
||||||
async def upsert_attacker(self, data: dict[str, Any]) -> None:
|
async def upsert_attacker(self, data: dict[str, Any]) -> None:
|
||||||
async with self.session_factory() as session:
|
async with self.session_factory() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -429,9 +453,31 @@ class SQLiteRepository(BaseRepository):
|
|||||||
setattr(existing, k, v)
|
setattr(existing, k, v)
|
||||||
session.add(existing)
|
session.add(existing)
|
||||||
else:
|
else:
|
||||||
|
data["uuid"] = str(uuid.uuid4())
|
||||||
session.add(Attacker(**data))
|
session.add(Attacker(**data))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _deserialize_attacker(d: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Parse JSON-encoded list fields in an attacker dict."""
|
||||||
|
for key in ("services", "deckies", "fingerprints", "commands"):
|
||||||
|
if isinstance(d.get(key), str):
|
||||||
|
try:
|
||||||
|
d[key] = json.loads(d[key])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
return d
|
||||||
|
|
||||||
|
async def get_attacker_by_uuid(self, uuid: str) -> Optional[dict[str, Any]]:
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Attacker).where(Attacker.uuid == uuid)
|
||||||
|
)
|
||||||
|
attacker = result.scalar_one_or_none()
|
||||||
|
if not attacker:
|
||||||
|
return None
|
||||||
|
return self._deserialize_attacker(attacker.model_dump(mode="json"))
|
||||||
|
|
||||||
async def get_attackers(
|
async def get_attackers(
|
||||||
self,
|
self,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
@@ -450,7 +496,10 @@ 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 [a.model_dump(mode="json") for a in result.scalars().all()]
|
return [
|
||||||
|
self._deserialize_attacker(a.model_dump(mode="json"))
|
||||||
|
for a in result.scalars().all()
|
||||||
|
]
|
||||||
|
|
||||||
async def get_total_attackers(self, search: Optional[str] = None) -> int:
|
async def get_total_attackers(self, search: Optional[str] = None) -> int:
|
||||||
statement = select(func.count()).select_from(Attacker)
|
statement = select(func.count()).select_from(Attacker)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from .fleet.api_mutate_decky import router as mutate_decky_router
|
|||||||
from .fleet.api_mutate_interval import router as mutate_interval_router
|
from .fleet.api_mutate_interval import router as mutate_interval_router
|
||||||
from .fleet.api_deploy_deckies import router as deploy_deckies_router
|
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_attacker_detail import router as attacker_detail_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -31,6 +33,10 @@ api_router.include_router(mutate_decky_router)
|
|||||||
api_router.include_router(mutate_interval_router)
|
api_router.include_router(mutate_interval_router)
|
||||||
api_router.include_router(deploy_deckies_router)
|
api_router.include_router(deploy_deckies_router)
|
||||||
|
|
||||||
|
# Attacker Profiles
|
||||||
|
api_router.include_router(attackers_router)
|
||||||
|
api_router.include_router(attacker_detail_router)
|
||||||
|
|
||||||
# Observability
|
# Observability
|
||||||
api_router.include_router(stats_router)
|
api_router.include_router(stats_router)
|
||||||
api_router.include_router(stream_router)
|
api_router.include_router(stream_router)
|
||||||
|
|||||||
0
decnet/web/router/attackers/__init__.py
Normal file
0
decnet/web/router/attackers/__init__.py
Normal file
26
decnet/web/router/attackers/api_get_attacker_detail.py
Normal file
26
decnet/web/router/attackers/api_get_attacker_detail.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from decnet.web.dependencies import get_current_user, repo
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/attackers/{uuid}",
|
||||||
|
tags=["Attacker Profiles"],
|
||||||
|
responses={
|
||||||
|
401: {"description": "Could not validate credentials"},
|
||||||
|
404: {"description": "Attacker not found"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def get_attacker_detail(
|
||||||
|
uuid: str,
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Retrieve a single attacker profile by UUID."""
|
||||||
|
attacker = await repo.get_attacker_by_uuid(uuid)
|
||||||
|
if not attacker:
|
||||||
|
raise HTTPException(status_code=404, detail="Attacker not found")
|
||||||
|
return attacker
|
||||||
36
decnet/web/router/attackers/api_get_attackers.py
Normal file
36
decnet/web/router/attackers/api_get_attackers.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from decnet.web.dependencies import get_current_user, repo
|
||||||
|
from decnet.web.db.models import AttackersResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/attackers",
|
||||||
|
response_model=AttackersResponse,
|
||||||
|
tags=["Attacker Profiles"],
|
||||||
|
responses={
|
||||||
|
401: {"description": "Could not validate credentials"},
|
||||||
|
422: {"description": "Validation error"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def get_attackers(
|
||||||
|
limit: int = Query(50, ge=1, le=1000),
|
||||||
|
offset: int = Query(0, ge=0, le=2147483647),
|
||||||
|
search: Optional[str] = None,
|
||||||
|
sort_by: str = Query("recent", pattern="^(recent|active|traversals)$"),
|
||||||
|
current_user: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Retrieve paginated attacker profiles."""
|
||||||
|
def _norm(v: Optional[str]) -> Optional[str]:
|
||||||
|
if v in (None, "null", "NULL", "undefined", ""):
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
s = _norm(search)
|
||||||
|
_data = await repo.get_attackers(limit=limit, offset=offset, search=s, sort_by=sort_by)
|
||||||
|
_total = await repo.get_total_attackers(search=s)
|
||||||
|
return {"total": _total, "limit": limit, "offset": offset, "data": _data}
|
||||||
@@ -6,6 +6,7 @@ import Dashboard from './components/Dashboard';
|
|||||||
import DeckyFleet from './components/DeckyFleet';
|
import DeckyFleet from './components/DeckyFleet';
|
||||||
import LiveLogs from './components/LiveLogs';
|
import LiveLogs from './components/LiveLogs';
|
||||||
import Attackers from './components/Attackers';
|
import Attackers from './components/Attackers';
|
||||||
|
import AttackerDetail from './components/AttackerDetail';
|
||||||
import Config from './components/Config';
|
import Config from './components/Config';
|
||||||
import Bounty from './components/Bounty';
|
import Bounty from './components/Bounty';
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ function App() {
|
|||||||
<Route path="/live-logs" element={<LiveLogs />} />
|
<Route path="/live-logs" element={<LiveLogs />} />
|
||||||
<Route path="/bounty" element={<Bounty />} />
|
<Route path="/bounty" element={<Bounty />} />
|
||||||
<Route path="/attackers" element={<Attackers />} />
|
<Route path="/attackers" element={<Attackers />} />
|
||||||
|
<Route path="/attackers/:id" element={<AttackerDetail />} />
|
||||||
<Route path="/config" element={<Config />} />
|
<Route path="/config" element={<Config />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
258
decnet_web/src/components/AttackerDetail.tsx
Normal file
258
decnet_web/src/components/AttackerDetail.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, Crosshair } from 'lucide-react';
|
||||||
|
import api from '../utils/api';
|
||||||
|
import './Dashboard.css';
|
||||||
|
|
||||||
|
interface AttackerData {
|
||||||
|
uuid: string;
|
||||||
|
ip: string;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
event_count: number;
|
||||||
|
service_count: number;
|
||||||
|
decky_count: number;
|
||||||
|
services: string[];
|
||||||
|
deckies: string[];
|
||||||
|
traversal_path: string | null;
|
||||||
|
is_traversal: boolean;
|
||||||
|
bounty_count: number;
|
||||||
|
credential_count: number;
|
||||||
|
fingerprints: any[];
|
||||||
|
commands: { service: string; decky: string; command: string; timestamp: string }[];
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AttackerDetail: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [attacker, setAttacker] = useState<AttackerData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAttacker = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/attackers/${id}`);
|
||||||
|
setAttacker(res.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
setError('ATTACKER NOT FOUND');
|
||||||
|
} else {
|
||||||
|
setError('FAILED TO LOAD ATTACKER PROFILE');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAttacker();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<div style={{ textAlign: 'center', padding: '80px', opacity: 0.5, letterSpacing: '4px' }}>
|
||||||
|
LOADING THREAT PROFILE...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !attacker) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<button onClick={() => navigate('/attackers')} className="back-button">
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
<span>BACK TO PROFILES</span>
|
||||||
|
</button>
|
||||||
|
<div style={{ textAlign: 'center', padding: '80px', opacity: 0.5, letterSpacing: '4px' }}>
|
||||||
|
{error || 'ATTACKER NOT FOUND'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
{/* Back Button */}
|
||||||
|
<button onClick={() => navigate('/attackers')} className="back-button">
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
<span>BACK TO PROFILES</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
|
<Crosshair size={32} className="violet-accent" />
|
||||||
|
<h1 className="matrix-text" style={{ fontSize: '1.8rem', letterSpacing: '2px' }}>
|
||||||
|
{attacker.ip}
|
||||||
|
</h1>
|
||||||
|
{attacker.is_traversal && (
|
||||||
|
<span className="traversal-badge" style={{ fontSize: '0.8rem' }}>TRAVERSAL</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Row */}
|
||||||
|
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value matrix-text">{attacker.event_count}</div>
|
||||||
|
<div className="stat-label">EVENTS</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value violet-accent">{attacker.bounty_count}</div>
|
||||||
|
<div className="stat-label">BOUNTIES</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value violet-accent">{attacker.credential_count}</div>
|
||||||
|
<div className="stat-label">CREDENTIALS</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value matrix-text">{attacker.service_count}</div>
|
||||||
|
<div className="stat-label">SERVICES</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-value matrix-text">{attacker.decky_count}</div>
|
||||||
|
<div className="stat-label">DECKIES</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamps */}
|
||||||
|
<div className="logs-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>TIMELINE</h2>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px', display: 'flex', gap: '32px', fontSize: '0.85rem' }}>
|
||||||
|
<div>
|
||||||
|
<span className="dim">FIRST SEEN: </span>
|
||||||
|
<span className="matrix-text">{new Date(attacker.first_seen).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="dim">LAST SEEN: </span>
|
||||||
|
<span className="matrix-text">{new Date(attacker.last_seen).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="dim">UPDATED: </span>
|
||||||
|
<span className="dim">{new Date(attacker.updated_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
<div className="logs-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>SERVICES TARGETED</h2>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px', display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{attacker.services.length > 0 ? attacker.services.map((svc) => (
|
||||||
|
<span key={svc} className="service-badge" style={{ fontSize: '0.85rem', padding: '4px 12px' }}>
|
||||||
|
{svc.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)) : (
|
||||||
|
<span className="dim">No services recorded</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deckies & Traversal */}
|
||||||
|
<div className="logs-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>DECKY INTERACTIONS</h2>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px', fontSize: '0.85rem' }}>
|
||||||
|
{attacker.traversal_path ? (
|
||||||
|
<div>
|
||||||
|
<span className="dim">TRAVERSAL PATH: </span>
|
||||||
|
<span className="violet-accent">{attacker.traversal_path}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{attacker.deckies.map((d) => (
|
||||||
|
<span key={d} className="service-badge" style={{ borderColor: 'var(--accent-color)', color: 'var(--accent-color)' }}>
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{attacker.deckies.length === 0 && <span className="dim">No deckies recorded</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commands */}
|
||||||
|
<div className="logs-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>COMMANDS ({attacker.commands.length})</h2>
|
||||||
|
</div>
|
||||||
|
{attacker.commands.length > 0 ? (
|
||||||
|
<div className="logs-table-container">
|
||||||
|
<table className="logs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>TIMESTAMP</th>
|
||||||
|
<th>SERVICE</th>
|
||||||
|
<th>DECKY</th>
|
||||||
|
<th>COMMAND</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{attacker.commands.map((cmd, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>
|
||||||
|
{cmd.timestamp ? new Date(cmd.timestamp).toLocaleString() : '-'}
|
||||||
|
</td>
|
||||||
|
<td>{cmd.service}</td>
|
||||||
|
<td className="violet-accent">{cmd.decky}</td>
|
||||||
|
<td className="matrix-text" style={{ fontFamily: 'monospace' }}>{cmd.command}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}>
|
||||||
|
NO COMMANDS CAPTURED
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fingerprints */}
|
||||||
|
<div className="logs-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>FINGERPRINTS ({attacker.fingerprints.length})</h2>
|
||||||
|
</div>
|
||||||
|
{attacker.fingerprints.length > 0 ? (
|
||||||
|
<div className="logs-table-container">
|
||||||
|
<table className="logs-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>TYPE</th>
|
||||||
|
<th>VALUE</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{attacker.fingerprints.map((fp, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="violet-accent">{fp.type || fp.bounty_type || 'unknown'}</td>
|
||||||
|
<td className="dim" style={{ fontSize: '0.8rem', wordBreak: 'break-all' }}>
|
||||||
|
{typeof fp === 'object' ? JSON.stringify(fp) : String(fp)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}>
|
||||||
|
NO FINGERPRINTS CAPTURED
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* UUID footer */}
|
||||||
|
<div style={{ textAlign: 'right', fontSize: '0.65rem', opacity: 0.3, marginTop: '8px' }}>
|
||||||
|
UUID: {attacker.uuid}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AttackerDetail;
|
||||||
@@ -1,17 +1,233 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Activity } from 'lucide-react';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Crosshair, Search, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
|
||||||
|
import api from '../utils/api';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
|
|
||||||
|
interface AttackerEntry {
|
||||||
|
uuid: string;
|
||||||
|
ip: string;
|
||||||
|
first_seen: string;
|
||||||
|
last_seen: string;
|
||||||
|
event_count: number;
|
||||||
|
service_count: number;
|
||||||
|
decky_count: number;
|
||||||
|
services: string[];
|
||||||
|
deckies: string[];
|
||||||
|
traversal_path: string | null;
|
||||||
|
is_traversal: boolean;
|
||||||
|
bounty_count: number;
|
||||||
|
credential_count: number;
|
||||||
|
fingerprints: any[];
|
||||||
|
commands: any[];
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
const days = Math.floor(hrs / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
const Attackers: React.FC = () => {
|
const Attackers: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const query = searchParams.get('q') || '';
|
||||||
|
const sortBy = searchParams.get('sort_by') || 'recent';
|
||||||
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
|
|
||||||
|
const [attackers, setAttackers] = useState<AttackerEntry[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchInput, setSearchInput] = useState(query);
|
||||||
|
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
const fetchAttackers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
let url = `/attackers?limit=${limit}&offset=${offset}&sort_by=${sortBy}`;
|
||||||
|
if (query) url += `&search=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
const res = await api.get(url);
|
||||||
|
setAttackers(res.data.data);
|
||||||
|
setTotal(res.data.total);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch attackers', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttackers();
|
||||||
|
}, [query, sortBy, page]);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearchParams({ q: searchInput, sort_by: sortBy, page: '1' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPage = (p: number) => {
|
||||||
|
setSearchParams({ q: query, sort_by: sortBy, page: p.toString() });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSort = (s: string) => {
|
||||||
|
setSearchParams({ q: query, sort_by: s, page: '1' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="logs-section">
|
<div className="dashboard">
|
||||||
<div className="section-header">
|
{/* Page Header */}
|
||||||
<Activity size={20} />
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<h2>ATTACKER PROFILES</h2>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
|
<Crosshair size={32} className="violet-accent" />
|
||||||
|
<h1 style={{ fontSize: '1.5rem', letterSpacing: '4px' }}>ATTACKER PROFILES</h1>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '40px', textAlign: 'center', opacity: 0.5 }}>
|
|
||||||
<p>NO ACTIVE THREATS PROFILED YET.</p>
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
<p style={{ marginTop: '10px', fontSize: '0.8rem' }}>(Attackers view placeholder)</p>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid var(--border-color)', padding: '4px 12px' }}>
|
||||||
|
<Filter size={16} className="dim" />
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSort(e.target.value)}
|
||||||
|
style={{ background: 'transparent', border: 'none', color: 'inherit', fontSize: '0.8rem', outline: 'none' }}
|
||||||
|
>
|
||||||
|
<option value="recent">RECENT</option>
|
||||||
|
<option value="active">MOST ACTIVE</option>
|
||||||
|
<option value="traversals">TRAVERSALS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearch} style={{ display: 'flex', alignItems: 'center', border: '1px solid var(--border-color)', padding: '4px 12px' }}>
|
||||||
|
<Search size={18} style={{ opacity: 0.5, marginRight: '8px' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by IP..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
style={{ background: 'transparent', border: 'none', padding: '4px', fontSize: '0.8rem', width: '200px' }}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary & Pagination */}
|
||||||
|
<div className="logs-section">
|
||||||
|
<div className="section-header" style={{ justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<span className="matrix-text" style={{ fontSize: '0.8rem' }}>{total} THREATS PROFILED</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
|
<span className="dim" style={{ fontSize: '0.8rem' }}>
|
||||||
|
Page {page} of {totalPages || 1}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: page <= 1 ? 0.3 : 1 }}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
style={{ padding: '4px', border: '1px solid var(--border-color)', opacity: page >= totalPages ? 0.3 : 1 }}
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px', opacity: 0.5, letterSpacing: '4px' }}>
|
||||||
|
SCANNING THREAT PROFILES...
|
||||||
|
</div>
|
||||||
|
) : attackers.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px', opacity: 0.5, letterSpacing: '4px' }}>
|
||||||
|
NO ACTIVE THREATS PROFILED YET
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="attacker-grid">
|
||||||
|
{attackers.map((a) => {
|
||||||
|
const lastCmd = a.commands.length > 0
|
||||||
|
? a.commands[a.commands.length - 1]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={a.uuid}
|
||||||
|
className="attacker-card"
|
||||||
|
onClick={() => navigate(`/attackers/${a.uuid}`)}
|
||||||
|
>
|
||||||
|
{/* Header row */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||||
|
<span className="matrix-text" style={{ fontSize: '1.1rem', fontWeight: 'bold' }}>{a.ip}</span>
|
||||||
|
{a.is_traversal && (
|
||||||
|
<span className="traversal-badge">TRAVERSAL</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamps */}
|
||||||
|
<div style={{ display: 'flex', gap: '16px', marginBottom: '8px', fontSize: '0.75rem' }}>
|
||||||
|
<span className="dim">First: {new Date(a.first_seen).toLocaleDateString()}</span>
|
||||||
|
<span className="dim">Last: {timeAgo(a.last_seen)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Counts */}
|
||||||
|
<div style={{ display: 'flex', gap: '16px', marginBottom: '10px', fontSize: '0.8rem' }}>
|
||||||
|
<span>Events: <span className="matrix-text">{a.event_count}</span></span>
|
||||||
|
<span>Bounties: <span className="violet-accent">{a.bounty_count}</span></span>
|
||||||
|
<span>Creds: <span className="violet-accent">{a.credential_count}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginBottom: '8px' }}>
|
||||||
|
{a.services.map((svc) => (
|
||||||
|
<span key={svc} className="service-badge">{svc.toUpperCase()}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deckies / Traversal Path */}
|
||||||
|
{a.traversal_path ? (
|
||||||
|
<div style={{ fontSize: '0.75rem', marginBottom: '8px', opacity: 0.7 }}>
|
||||||
|
Path: {a.traversal_path}
|
||||||
|
</div>
|
||||||
|
) : a.deckies.length > 0 ? (
|
||||||
|
<div style={{ fontSize: '0.75rem', marginBottom: '8px', opacity: 0.7 }}>
|
||||||
|
Deckies: {a.deckies.join(', ')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Commands & Fingerprints */}
|
||||||
|
<div style={{ display: 'flex', gap: '16px', fontSize: '0.75rem', marginBottom: '6px' }}>
|
||||||
|
<span>Cmds: <span className="matrix-text">{a.commands.length}</span></span>
|
||||||
|
<span>Fingerprints: <span className="matrix-text">{a.fingerprints.length}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last command preview */}
|
||||||
|
{lastCmd && (
|
||||||
|
<div style={{ fontSize: '0.7rem', opacity: 0.6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
Last cmd: <span className="matrix-text">{lastCmd.command}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -127,3 +127,61 @@
|
|||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Attacker Profiles */
|
||||||
|
.attacker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attacker-card {
|
||||||
|
background: var(--secondary-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attacker-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--text-color);
|
||||||
|
box-shadow: var(--matrix-green-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.traversal-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--accent-color);
|
||||||
|
background: rgba(238, 130, 238, 0.1);
|
||||||
|
color: var(--accent-color);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--text-color);
|
||||||
|
background: rgba(0, 255, 65, 0.05);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button:hover {
|
||||||
|
border-color: var(--text-color);
|
||||||
|
box-shadow: var(--matrix-green-glow);
|
||||||
|
}
|
||||||
|
|||||||
213
tests/test_api_attackers.py
Normal file
213
tests/test_api_attackers.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
Tests for the attacker profile API routes.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- GET /attackers: paginated list, search, sort_by
|
||||||
|
- GET /attackers/{uuid}: single profile detail, 404 on missing UUID
|
||||||
|
- Auth enforcement on both endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from decnet.web.auth import create_access_token
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _auth_request(uuid: str = "test-user-uuid") -> MagicMock:
|
||||||
|
token = create_access_token({"uuid": uuid})
|
||||||
|
req = MagicMock()
|
||||||
|
req.headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_attacker(uuid: str = "att-uuid-1", ip: str = "1.2.3.4") -> dict:
|
||||||
|
return {
|
||||||
|
"uuid": uuid,
|
||||||
|
"ip": ip,
|
||||||
|
"first_seen": datetime(2026, 4, 1, tzinfo=timezone.utc).isoformat(),
|
||||||
|
"last_seen": datetime(2026, 4, 10, tzinfo=timezone.utc).isoformat(),
|
||||||
|
"event_count": 42,
|
||||||
|
"service_count": 3,
|
||||||
|
"decky_count": 2,
|
||||||
|
"services": ["ssh", "http", "ftp"],
|
||||||
|
"deckies": ["decky-01", "decky-02"],
|
||||||
|
"traversal_path": "decky-01 → decky-02",
|
||||||
|
"is_traversal": True,
|
||||||
|
"bounty_count": 5,
|
||||||
|
"credential_count": 2,
|
||||||
|
"fingerprints": [{"type": "ja3", "hash": "abc"}],
|
||||||
|
"commands": [{"service": "ssh", "decky": "decky-01", "command": "id", "timestamp": "2026-04-01T10:00:00"}],
|
||||||
|
"updated_at": datetime(2026, 4, 10, tzinfo=timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GET /attackers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestGetAttackers:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_paginated_response(self):
|
||||||
|
from decnet.web.router.attackers.api_get_attackers import get_attackers
|
||||||
|
|
||||||
|
sample = _sample_attacker()
|
||||||
|
with patch("decnet.web.router.attackers.api_get_attackers.repo") as mock_repo:
|
||||||
|
mock_repo.get_attackers = AsyncMock(return_value=[sample])
|
||||||
|
mock_repo.get_total_attackers = AsyncMock(return_value=1)
|
||||||
|
|
||||||
|
result = await get_attackers(
|
||||||
|
limit=50, offset=0, search=None, sort_by="recent",
|
||||||
|
current_user="test-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["total"] == 1
|
||||||
|
assert result["limit"] == 50
|
||||||
|
assert result["offset"] == 0
|
||||||
|
assert len(result["data"]) == 1
|
||||||
|
assert result["data"][0]["uuid"] == "att-uuid-1"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_parameter_forwarded(self):
|
||||||
|
from decnet.web.router.attackers.api_get_attackers import get_attackers
|
||||||
|
|
||||||
|
with patch("decnet.web.router.attackers.api_get_attackers.repo") as mock_repo:
|
||||||
|
mock_repo.get_attackers = AsyncMock(return_value=[])
|
||||||
|
mock_repo.get_total_attackers = AsyncMock(return_value=0)
|
||||||
|
|
||||||
|
await get_attackers(
|
||||||
|
limit=50, offset=0, search="192.168", sort_by="recent",
|
||||||
|
current_user="test-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_repo.get_attackers.assert_awaited_once_with(
|
||||||
|
limit=50, offset=0, search="192.168", sort_by="recent",
|
||||||
|
)
|
||||||
|
mock_repo.get_total_attackers.assert_awaited_once_with(search="192.168")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_null_search_normalized(self):
|
||||||
|
from decnet.web.router.attackers.api_get_attackers import get_attackers
|
||||||
|
|
||||||
|
with patch("decnet.web.router.attackers.api_get_attackers.repo") as mock_repo:
|
||||||
|
mock_repo.get_attackers = AsyncMock(return_value=[])
|
||||||
|
mock_repo.get_total_attackers = AsyncMock(return_value=0)
|
||||||
|
|
||||||
|
await get_attackers(
|
||||||
|
limit=50, offset=0, search="null", sort_by="recent",
|
||||||
|
current_user="test-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_repo.get_attackers.assert_awaited_once_with(
|
||||||
|
limit=50, offset=0, search=None, sort_by="recent",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sort_by_active(self):
|
||||||
|
from decnet.web.router.attackers.api_get_attackers import get_attackers
|
||||||
|
|
||||||
|
with patch("decnet.web.router.attackers.api_get_attackers.repo") as mock_repo:
|
||||||
|
mock_repo.get_attackers = AsyncMock(return_value=[])
|
||||||
|
mock_repo.get_total_attackers = AsyncMock(return_value=0)
|
||||||
|
|
||||||
|
await get_attackers(
|
||||||
|
limit=50, offset=0, search=None, sort_by="active",
|
||||||
|
current_user="test-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_repo.get_attackers.assert_awaited_once_with(
|
||||||
|
limit=50, offset=0, search=None, sort_by="active",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_search_normalized_to_none(self):
|
||||||
|
from decnet.web.router.attackers.api_get_attackers import get_attackers
|
||||||
|
|
||||||
|
with patch("decnet.web.router.attackers.api_get_attackers.repo") as mock_repo:
|
||||||
|
mock_repo.get_attackers = AsyncMock(return_value=[])
|
||||||
|
mock_repo.get_total_attackers = AsyncMock(return_value=0)
|
||||||
|
|
||||||
|
await get_attackers(
|
||||||
|
limit=50, offset=0, search="", sort_by="recent",
|
||||||
|
current_user="test-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_repo.get_attackers.assert_awaited_once_with(
|
||||||
|
limit=50, offset=0, search=None, sort_by="recent",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GET /attackers/{uuid} ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestGetAttackerDetail:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_attacker_by_uuid(self):
|
||||||
|
from decnet.web.router.attackers.api_get_attacker_detail import get_attacker_detail
|
||||||
|
|
||||||
|
sample = _sample_attacker()
|
||||||
|
with patch("decnet.web.router.attackers.api_get_attacker_detail.repo") as mock_repo:
|
||||||
|
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample)
|
||||||
|
|
||||||
|
result = await get_attacker_detail(uuid="att-uuid-1", current_user="test-user")
|
||||||
|
|
||||||
|
assert result["uuid"] == "att-uuid-1"
|
||||||
|
assert result["ip"] == "1.2.3.4"
|
||||||
|
assert result["is_traversal"] is True
|
||||||
|
assert isinstance(result["commands"], list)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_404_on_unknown_uuid(self):
|
||||||
|
from decnet.web.router.attackers.api_get_attacker_detail import get_attacker_detail
|
||||||
|
|
||||||
|
with patch("decnet.web.router.attackers.api_get_attacker_detail.repo") as mock_repo:
|
||||||
|
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_attacker_detail(uuid="nonexistent", current_user="test-user")
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deserialized_json_fields(self):
|
||||||
|
from decnet.web.router.attackers.api_get_attacker_detail import get_attacker_detail
|
||||||
|
|
||||||
|
sample = _sample_attacker()
|
||||||
|
with patch("decnet.web.router.attackers.api_get_attacker_detail.repo") as mock_repo:
|
||||||
|
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample)
|
||||||
|
|
||||||
|
result = await get_attacker_detail(uuid="att-uuid-1", current_user="test-user")
|
||||||
|
|
||||||
|
assert isinstance(result["services"], list)
|
||||||
|
assert isinstance(result["deckies"], list)
|
||||||
|
assert isinstance(result["fingerprints"], list)
|
||||||
|
assert isinstance(result["commands"], list)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Auth enforcement ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestAttackersAuth:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_requires_auth(self):
|
||||||
|
"""get_current_user dependency raises 401 when called without valid token."""
|
||||||
|
from decnet.web.dependencies import get_current_user
|
||||||
|
|
||||||
|
req = MagicMock()
|
||||||
|
req.headers = {}
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_current_user(req)
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_detail_requires_auth(self):
|
||||||
|
from decnet.web.dependencies import get_current_user
|
||||||
|
|
||||||
|
req = MagicMock()
|
||||||
|
req.headers = {"Authorization": "Bearer bad-token"}
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_current_user(req)
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
Tests for decnet/web/attacker_worker.py
|
Tests for decnet/web/attacker_worker.py
|
||||||
|
|
||||||
Covers:
|
Covers:
|
||||||
- _rebuild(): CorrelationEngine integration, traversal detection, upsert calls
|
- _cold_start(): full build on first run, cursor persistence
|
||||||
- _extract_commands(): command harvesting from raw log rows
|
- _incremental_update(): delta processing, affected-IP-only updates
|
||||||
|
- _update_profiles(): traversal detection, bounty merging
|
||||||
|
- _extract_commands_from_events(): command harvesting from LogEvent objects
|
||||||
- _build_record(): record assembly from engine events + bounties
|
- _build_record(): record assembly from engine events + bounties
|
||||||
- _first_contact_deckies(): ordering for single-decky attackers
|
- _first_contact_deckies(): ordering for single-decky attackers
|
||||||
- attacker_profile_worker(): cancellation and error handling
|
- attacker_profile_worker(): cancellation and error handling
|
||||||
@@ -18,15 +20,20 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from decnet.correlation.parser import LogEvent
|
||||||
from decnet.logging.syslog_formatter import SEVERITY_INFO, format_rfc5424
|
from decnet.logging.syslog_formatter import SEVERITY_INFO, format_rfc5424
|
||||||
from decnet.web.attacker_worker import (
|
from decnet.web.attacker_worker import (
|
||||||
|
_BATCH_SIZE,
|
||||||
|
_STATE_KEY,
|
||||||
|
_WorkerState,
|
||||||
_build_record,
|
_build_record,
|
||||||
_extract_commands,
|
_cold_start,
|
||||||
|
_extract_commands_from_events,
|
||||||
_first_contact_deckies,
|
_first_contact_deckies,
|
||||||
_rebuild,
|
_incremental_update,
|
||||||
|
_update_profiles,
|
||||||
attacker_profile_worker,
|
attacker_profile_worker,
|
||||||
)
|
)
|
||||||
from decnet.correlation.parser import LogEvent
|
|
||||||
|
|
||||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -59,6 +66,7 @@ def _make_raw_line(
|
|||||||
|
|
||||||
|
|
||||||
def _make_log_row(
|
def _make_log_row(
|
||||||
|
row_id: int = 1,
|
||||||
raw_line: str = "",
|
raw_line: str = "",
|
||||||
attacker_ip: str = "1.2.3.4",
|
attacker_ip: str = "1.2.3.4",
|
||||||
service: str = "ssh",
|
service: str = "ssh",
|
||||||
@@ -76,7 +84,7 @@ def _make_log_row(
|
|||||||
timestamp=timestamp.isoformat(),
|
timestamp=timestamp.isoformat(),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"id": 1,
|
"id": row_id,
|
||||||
"raw_line": raw_line,
|
"raw_line": raw_line,
|
||||||
"attacker_ip": attacker_ip,
|
"attacker_ip": attacker_ip,
|
||||||
"service": service,
|
"service": service,
|
||||||
@@ -87,10 +95,15 @@ def _make_log_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _make_repo(logs=None, bounties=None):
|
def _make_repo(logs=None, bounties=None, bounties_for_ips=None, max_log_id=0, saved_state=None):
|
||||||
repo = MagicMock()
|
repo = MagicMock()
|
||||||
repo.get_all_logs_raw = AsyncMock(return_value=logs or [])
|
repo.get_all_logs_raw = AsyncMock(return_value=logs or [])
|
||||||
repo.get_all_bounties_by_ip = AsyncMock(return_value=bounties or {})
|
repo.get_all_bounties_by_ip = AsyncMock(return_value=bounties or {})
|
||||||
|
repo.get_bounties_for_ips = AsyncMock(return_value=bounties_for_ips or {})
|
||||||
|
repo.get_max_log_id = AsyncMock(return_value=max_log_id)
|
||||||
|
repo.get_logs_after_id = AsyncMock(return_value=[])
|
||||||
|
repo.get_state = AsyncMock(return_value=saved_state)
|
||||||
|
repo.set_state = AsyncMock()
|
||||||
repo.upsert_attacker = AsyncMock()
|
repo.upsert_attacker = AsyncMock()
|
||||||
return repo
|
return repo
|
||||||
|
|
||||||
@@ -101,6 +114,7 @@ def _make_log_event(
|
|||||||
service: str = "ssh",
|
service: str = "ssh",
|
||||||
event_type: str = "connection",
|
event_type: str = "connection",
|
||||||
timestamp: datetime = _DT1,
|
timestamp: datetime = _DT1,
|
||||||
|
fields: dict | None = None,
|
||||||
) -> LogEvent:
|
) -> LogEvent:
|
||||||
return LogEvent(
|
return LogEvent(
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
@@ -108,7 +122,7 @@ def _make_log_event(
|
|||||||
service=service,
|
service=service,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
attacker_ip=ip,
|
attacker_ip=ip,
|
||||||
fields={},
|
fields=fields or {},
|
||||||
raw="",
|
raw="",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,75 +152,52 @@ class TestFirstContactDeckies:
|
|||||||
assert result.count("decky-01") == 1
|
assert result.count("decky-01") == 1
|
||||||
|
|
||||||
|
|
||||||
# ─── _extract_commands ────────────────────────────────────────────────────────
|
# ─── _extract_commands_from_events ───────────────────────────────────────────
|
||||||
|
|
||||||
class TestExtractCommands:
|
|
||||||
def _row(self, ip, event_type, fields):
|
|
||||||
return _make_log_row(
|
|
||||||
attacker_ip=ip,
|
|
||||||
event_type=event_type,
|
|
||||||
service="ssh",
|
|
||||||
decky="decky-01",
|
|
||||||
fields=json.dumps(fields),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
class TestExtractCommandsFromEvents:
|
||||||
def test_extracts_command_field(self):
|
def test_extracts_command_field(self):
|
||||||
rows = [self._row("1.1.1.1", "command", {"command": "id"})]
|
events = [_make_log_event("1.1.1.1", "decky-01", "ssh", "command", _DT1, {"command": "id"})]
|
||||||
result = _extract_commands(rows, "1.1.1.1")
|
result = _extract_commands_from_events(events)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["command"] == "id"
|
assert result[0]["command"] == "id"
|
||||||
assert result[0]["service"] == "ssh"
|
assert result[0]["service"] == "ssh"
|
||||||
assert result[0]["decky"] == "decky-01"
|
assert result[0]["decky"] == "decky-01"
|
||||||
|
|
||||||
def test_extracts_query_field(self):
|
def test_extracts_query_field(self):
|
||||||
rows = [self._row("2.2.2.2", "query", {"query": "SELECT * FROM users"})]
|
events = [_make_log_event("2.2.2.2", "decky-01", "mysql", "query", _DT1, {"query": "SELECT * FROM users"})]
|
||||||
result = _extract_commands(rows, "2.2.2.2")
|
result = _extract_commands_from_events(events)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["command"] == "SELECT * FROM users"
|
assert result[0]["command"] == "SELECT * FROM users"
|
||||||
|
|
||||||
def test_extracts_input_field(self):
|
def test_extracts_input_field(self):
|
||||||
rows = [self._row("3.3.3.3", "input", {"input": "ls -la"})]
|
events = [_make_log_event("3.3.3.3", "decky-01", "ssh", "input", _DT1, {"input": "ls -la"})]
|
||||||
result = _extract_commands(rows, "3.3.3.3")
|
result = _extract_commands_from_events(events)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["command"] == "ls -la"
|
assert result[0]["command"] == "ls -la"
|
||||||
|
|
||||||
def test_non_command_event_type_ignored(self):
|
def test_non_command_event_type_ignored(self):
|
||||||
rows = [self._row("1.1.1.1", "connection", {"command": "id"})]
|
events = [_make_log_event("1.1.1.1", "decky-01", "ssh", "connection", _DT1, {"command": "id"})]
|
||||||
result = _extract_commands(rows, "1.1.1.1")
|
result = _extract_commands_from_events(events)
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_wrong_ip_ignored(self):
|
|
||||||
rows = [self._row("9.9.9.9", "command", {"command": "whoami"})]
|
|
||||||
result = _extract_commands(rows, "1.1.1.1")
|
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
def test_no_command_field_skipped(self):
|
def test_no_command_field_skipped(self):
|
||||||
rows = [self._row("1.1.1.1", "command", {"other": "stuff"})]
|
events = [_make_log_event("1.1.1.1", "decky-01", "ssh", "command", _DT1, {"other": "stuff"})]
|
||||||
result = _extract_commands(rows, "1.1.1.1")
|
result = _extract_commands_from_events(events)
|
||||||
assert result == []
|
|
||||||
|
|
||||||
def test_invalid_json_fields_skipped(self):
|
|
||||||
row = _make_log_row(
|
|
||||||
attacker_ip="1.1.1.1",
|
|
||||||
event_type="command",
|
|
||||||
fields="not valid json",
|
|
||||||
)
|
|
||||||
result = _extract_commands([row], "1.1.1.1")
|
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
def test_multiple_commands_all_extracted(self):
|
def test_multiple_commands_all_extracted(self):
|
||||||
rows = [
|
events = [
|
||||||
self._row("5.5.5.5", "command", {"command": "id"}),
|
_make_log_event("5.5.5.5", "decky-01", "ssh", "command", _DT1, {"command": "id"}),
|
||||||
self._row("5.5.5.5", "command", {"command": "uname -a"}),
|
_make_log_event("5.5.5.5", "decky-01", "ssh", "command", _DT2, {"command": "uname -a"}),
|
||||||
]
|
]
|
||||||
result = _extract_commands(rows, "5.5.5.5")
|
result = _extract_commands_from_events(events)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
cmds = {r["command"] for r in result}
|
cmds = {r["command"] for r in result}
|
||||||
assert cmds == {"id", "uname -a"}
|
assert cmds == {"id", "uname -a"}
|
||||||
|
|
||||||
def test_timestamp_serialized_to_string(self):
|
def test_timestamp_serialized_to_string(self):
|
||||||
rows = [self._row("1.1.1.1", "command", {"command": "pwd"})]
|
events = [_make_log_event("1.1.1.1", "decky-01", "ssh", "command", _DT1, {"command": "pwd"})]
|
||||||
result = _extract_commands(rows, "1.1.1.1")
|
result = _extract_commands_from_events(events)
|
||||||
assert isinstance(result[0]["timestamp"], str)
|
assert isinstance(result[0]["timestamp"], str)
|
||||||
|
|
||||||
|
|
||||||
@@ -291,112 +282,283 @@ class TestBuildRecord:
|
|||||||
assert record["updated_at"].tzinfo is not None
|
assert record["updated_at"].tzinfo is not None
|
||||||
|
|
||||||
|
|
||||||
# ─── _rebuild ─────────────────────────────────────────────────────────────────
|
# ─── _cold_start ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestRebuild:
|
class TestColdStart:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_empty_logs_no_upsert(self):
|
async def test_cold_start_builds_all_profiles(self):
|
||||||
repo = _make_repo(logs=[])
|
|
||||||
await _rebuild(repo)
|
|
||||||
repo.upsert_attacker.assert_not_awaited()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_single_attacker_upserted(self):
|
|
||||||
raw = _make_raw_line("ssh", "decky-01", "connection", "10.0.0.1", _TS1)
|
|
||||||
row = _make_log_row(raw_line=raw, attacker_ip="10.0.0.1")
|
|
||||||
repo = _make_repo(logs=[row])
|
|
||||||
await _rebuild(repo)
|
|
||||||
repo.upsert_attacker.assert_awaited_once()
|
|
||||||
record = repo.upsert_attacker.call_args[0][0]
|
|
||||||
assert record["ip"] == "10.0.0.1"
|
|
||||||
assert record["event_count"] == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_multiple_attackers_all_upserted(self):
|
|
||||||
rows = [
|
rows = [
|
||||||
_make_log_row(
|
_make_log_row(
|
||||||
|
row_id=i + 1,
|
||||||
raw_line=_make_raw_line("ssh", "decky-01", "conn", ip, _TS1),
|
raw_line=_make_raw_line("ssh", "decky-01", "conn", ip, _TS1),
|
||||||
attacker_ip=ip,
|
attacker_ip=ip,
|
||||||
)
|
)
|
||||||
for ip in ["1.1.1.1", "2.2.2.2", "3.3.3.3"]
|
for i, ip in enumerate(["1.1.1.1", "2.2.2.2", "3.3.3.3"])
|
||||||
]
|
]
|
||||||
repo = _make_repo(logs=rows)
|
repo = _make_repo(logs=rows, max_log_id=3)
|
||||||
await _rebuild(repo)
|
state = _WorkerState()
|
||||||
|
|
||||||
|
await _cold_start(repo, state)
|
||||||
|
|
||||||
|
assert state.initialized is True
|
||||||
|
assert state.last_log_id == 3
|
||||||
assert repo.upsert_attacker.await_count == 3
|
assert repo.upsert_attacker.await_count == 3
|
||||||
upserted_ips = {c[0][0]["ip"] for c in repo.upsert_attacker.call_args_list}
|
upserted_ips = {c[0][0]["ip"] for c in repo.upsert_attacker.call_args_list}
|
||||||
assert upserted_ips == {"1.1.1.1", "2.2.2.2", "3.3.3.3"}
|
assert upserted_ips == {"1.1.1.1", "2.2.2.2", "3.3.3.3"}
|
||||||
|
repo.set_state.assert_awaited_with(_STATE_KEY, {"last_log_id": 3})
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_traversal_detected_across_two_deckies(self):
|
async def test_cold_start_empty_db(self):
|
||||||
|
repo = _make_repo(logs=[], max_log_id=0)
|
||||||
|
state = _WorkerState()
|
||||||
|
|
||||||
|
await _cold_start(repo, state)
|
||||||
|
|
||||||
|
assert state.initialized is True
|
||||||
|
assert state.last_log_id == 0
|
||||||
|
repo.upsert_attacker.assert_not_awaited()
|
||||||
|
repo.set_state.assert_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cold_start_traversal_detected(self):
|
||||||
rows = [
|
rows = [
|
||||||
_make_log_row(
|
_make_log_row(
|
||||||
|
row_id=1,
|
||||||
raw_line=_make_raw_line("ssh", "decky-01", "conn", "5.5.5.5", _TS1),
|
raw_line=_make_raw_line("ssh", "decky-01", "conn", "5.5.5.5", _TS1),
|
||||||
attacker_ip="5.5.5.5", decky="decky-01",
|
attacker_ip="5.5.5.5", decky="decky-01",
|
||||||
),
|
),
|
||||||
_make_log_row(
|
_make_log_row(
|
||||||
|
row_id=2,
|
||||||
raw_line=_make_raw_line("http", "decky-02", "req", "5.5.5.5", _TS2),
|
raw_line=_make_raw_line("http", "decky-02", "req", "5.5.5.5", _TS2),
|
||||||
attacker_ip="5.5.5.5", decky="decky-02",
|
attacker_ip="5.5.5.5", decky="decky-02",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
repo = _make_repo(logs=rows)
|
repo = _make_repo(logs=rows, max_log_id=2)
|
||||||
await _rebuild(repo)
|
state = _WorkerState()
|
||||||
|
|
||||||
|
await _cold_start(repo, state)
|
||||||
|
|
||||||
record = repo.upsert_attacker.call_args[0][0]
|
record = repo.upsert_attacker.call_args[0][0]
|
||||||
assert record["is_traversal"] is True
|
assert record["is_traversal"] is True
|
||||||
assert "decky-01" in record["traversal_path"]
|
assert "decky-01" in record["traversal_path"]
|
||||||
assert "decky-02" in record["traversal_path"]
|
assert "decky-02" in record["traversal_path"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_single_decky_not_traversal(self):
|
async def test_cold_start_bounties_merged(self):
|
||||||
rows = [
|
|
||||||
_make_log_row(
|
|
||||||
raw_line=_make_raw_line("ssh", "decky-01", "conn", "7.7.7.7", _TS1),
|
|
||||||
attacker_ip="7.7.7.7",
|
|
||||||
),
|
|
||||||
_make_log_row(
|
|
||||||
raw_line=_make_raw_line("http", "decky-01", "req", "7.7.7.7", _TS2),
|
|
||||||
attacker_ip="7.7.7.7",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
repo = _make_repo(logs=rows)
|
|
||||||
await _rebuild(repo)
|
|
||||||
record = repo.upsert_attacker.call_args[0][0]
|
|
||||||
assert record["is_traversal"] is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bounties_merged_into_record(self):
|
|
||||||
raw = _make_raw_line("ssh", "decky-01", "conn", "8.8.8.8", _TS1)
|
raw = _make_raw_line("ssh", "decky-01", "conn", "8.8.8.8", _TS1)
|
||||||
repo = _make_repo(
|
repo = _make_repo(
|
||||||
logs=[_make_log_row(raw_line=raw, attacker_ip="8.8.8.8")],
|
logs=[_make_log_row(row_id=1, raw_line=raw, attacker_ip="8.8.8.8")],
|
||||||
bounties={"8.8.8.8": [
|
max_log_id=1,
|
||||||
|
bounties_for_ips={"8.8.8.8": [
|
||||||
{"bounty_type": "credential", "attacker_ip": "8.8.8.8", "payload": {}},
|
{"bounty_type": "credential", "attacker_ip": "8.8.8.8", "payload": {}},
|
||||||
{"bounty_type": "fingerprint", "attacker_ip": "8.8.8.8", "payload": {"ja3": "abc"}},
|
{"bounty_type": "fingerprint", "attacker_ip": "8.8.8.8", "payload": {"ja3": "abc"}},
|
||||||
]},
|
]},
|
||||||
)
|
)
|
||||||
await _rebuild(repo)
|
state = _WorkerState()
|
||||||
|
|
||||||
|
await _cold_start(repo, state)
|
||||||
|
|
||||||
record = repo.upsert_attacker.call_args[0][0]
|
record = repo.upsert_attacker.call_args[0][0]
|
||||||
assert record["bounty_count"] == 2
|
assert record["bounty_count"] == 2
|
||||||
assert record["credential_count"] == 1
|
assert record["credential_count"] == 1
|
||||||
fps = json.loads(record["fingerprints"])
|
|
||||||
assert len(fps) == 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_commands_extracted_during_rebuild(self):
|
async def test_cold_start_commands_extracted(self):
|
||||||
raw = _make_raw_line("ssh", "decky-01", "command", "9.9.9.9", _TS1)
|
raw = _make_raw_line("ssh", "decky-01", "command", "9.9.9.9", _TS1, command="cat /etc/passwd")
|
||||||
row = _make_log_row(
|
row = _make_log_row(
|
||||||
|
row_id=1,
|
||||||
raw_line=raw,
|
raw_line=raw,
|
||||||
attacker_ip="9.9.9.9",
|
attacker_ip="9.9.9.9",
|
||||||
event_type="command",
|
event_type="command",
|
||||||
fields=json.dumps({"command": "cat /etc/passwd"}),
|
fields=json.dumps({"command": "cat /etc/passwd"}),
|
||||||
)
|
)
|
||||||
repo = _make_repo(logs=[row])
|
repo = _make_repo(logs=[row], max_log_id=1)
|
||||||
await _rebuild(repo)
|
state = _WorkerState()
|
||||||
|
|
||||||
|
await _cold_start(repo, state)
|
||||||
|
|
||||||
record = repo.upsert_attacker.call_args[0][0]
|
record = repo.upsert_attacker.call_args[0][0]
|
||||||
commands = json.loads(record["commands"])
|
commands = json.loads(record["commands"])
|
||||||
assert len(commands) == 1
|
assert len(commands) == 1
|
||||||
assert commands[0]["command"] == "cat /etc/passwd"
|
assert commands[0]["command"] == "cat /etc/passwd"
|
||||||
|
|
||||||
|
|
||||||
# ─── attacker_profile_worker ──────────────────────────────────────────────────
|
# ─── _incremental_update ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestIncrementalUpdate:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_new_logs_skips_upsert(self):
|
||||||
|
repo = _make_repo()
|
||||||
|
state = _WorkerState(initialized=True, last_log_id=10)
|
||||||
|
|
||||||
|
await _incremental_update(repo, state)
|
||||||
|
|
||||||
|
repo.upsert_attacker.assert_not_awaited()
|
||||||
|
repo.set_state.assert_awaited_with(_STATE_KEY, {"last_log_id": 10})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_only_affected_ips_upserted(self):
|
||||||
|
"""Pre-populate engine with IP-A, then feed new logs only for IP-B."""
|
||||||
|
state = _WorkerState(initialized=True, last_log_id=5)
|
||||||
|
# Pre-populate engine with IP-A events
|
||||||
|
line_a = _make_raw_line("ssh", "decky-01", "conn", "1.1.1.1", _TS1)
|
||||||
|
state.engine.ingest(line_a)
|
||||||
|
|
||||||
|
# New batch has only IP-B
|
||||||
|
new_row = _make_log_row(
|
||||||
|
row_id=6,
|
||||||
|
raw_line=_make_raw_line("ssh", "decky-01", "conn", "2.2.2.2", _TS2),
|
||||||
|
attacker_ip="2.2.2.2",
|
||||||
|
)
|
||||||
|
repo = _make_repo()
|
||||||
|
repo.get_logs_after_id = AsyncMock(return_value=[new_row])
|
||||||
|
|
||||||
|
await _incremental_update(repo, state)
|
||||||
|
|
||||||
|
assert repo.upsert_attacker.await_count == 1
|
||||||
|
upserted_ip = repo.upsert_attacker.call_args[0][0]["ip"]
|
||||||
|
assert upserted_ip == "2.2.2.2"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_merges_with_existing_engine_state(self):
|
||||||
|
"""Engine has 2 events for IP. New batch adds 1 more. Record should show event_count=3."""
|
||||||
|
state = _WorkerState(initialized=True, last_log_id=2)
|
||||||
|
state.engine.ingest(_make_raw_line("ssh", "decky-01", "conn", "1.1.1.1", _TS1))
|
||||||
|
state.engine.ingest(_make_raw_line("http", "decky-01", "req", "1.1.1.1", _TS2))
|
||||||
|
|
||||||
|
new_row = _make_log_row(
|
||||||
|
row_id=3,
|
||||||
|
raw_line=_make_raw_line("ftp", "decky-01", "login", "1.1.1.1", _TS3),
|
||||||
|
attacker_ip="1.1.1.1",
|
||||||
|
)
|
||||||
|
repo = _make_repo()
|
||||||
|
repo.get_logs_after_id = AsyncMock(return_value=[new_row])
|
||||||
|
|
||||||
|
await _incremental_update(repo, state)
|
||||||
|
|
||||||
|
record = repo.upsert_attacker.call_args[0][0]
|
||||||
|
assert record["event_count"] == 3
|
||||||
|
assert record["ip"] == "1.1.1.1"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cursor_persisted_after_update(self):
|
||||||
|
new_row = _make_log_row(
|
||||||
|
row_id=42,
|
||||||
|
raw_line=_make_raw_line("ssh", "decky-01", "conn", "1.1.1.1", _TS1),
|
||||||
|
attacker_ip="1.1.1.1",
|
||||||
|
)
|
||||||
|
repo = _make_repo()
|
||||||
|
repo.get_logs_after_id = AsyncMock(return_value=[new_row])
|
||||||
|
state = _WorkerState(initialized=True, last_log_id=41)
|
||||||
|
|
||||||
|
await _incremental_update(repo, state)
|
||||||
|
|
||||||
|
assert state.last_log_id == 42
|
||||||
|
repo.set_state.assert_awaited_with(_STATE_KEY, {"last_log_id": 42})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_traversal_detected_across_cycles(self):
|
||||||
|
"""IP hits decky-01 during cold start, decky-02 in incremental → traversal."""
|
||||||
|
state = _WorkerState(initialized=True, last_log_id=1)
|
||||||
|
state.engine.ingest(_make_raw_line("ssh", "decky-01", "conn", "5.5.5.5", _TS1))
|
||||||
|
|
||||||
|
new_row = _make_log_row(
|
||||||
|
row_id=2,
|
||||||
|
raw_line=_make_raw_line("http", "decky-02", "req", "5.5.5.5", _TS2),
|
||||||
|
attacker_ip="5.5.5.5",
|
||||||
|
)
|
||||||
|
repo = _make_repo()
|
||||||
|
repo.get_logs_after_id = AsyncMock(return_value=[new_row])
|
||||||
|
|
||||||
|
await _incremental_update(repo, state)
|
||||||
|
|
||||||
|
record = repo.upsert_attacker.call_args[0][0]
|
||||||
|
assert record["is_traversal"] is True
|
||||||
|
assert "decky-01" in record["traversal_path"]
|
||||||
|
assert "decky-02" in record["traversal_path"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_batch_loop_processes_all(self):
|
||||||
|
"""First batch returns BATCH_SIZE rows, second returns fewer — all processed."""
|
||||||
|
batch_1 = [
|
||||||
|
_make_log_row(
|
||||||
|
row_id=i + 1,
|
||||||
|
raw_line=_make_raw_line("ssh", "decky-01", "conn", f"10.0.0.{i}", _TS1),
|
||||||
|
attacker_ip=f"10.0.0.{i}",
|
||||||
|
)
|
||||||
|
for i in range(_BATCH_SIZE)
|
||||||
|
]
|
||||||
|
batch_2 = [
|
||||||
|
_make_log_row(
|
||||||
|
row_id=_BATCH_SIZE + 1,
|
||||||
|
raw_line=_make_raw_line("ssh", "decky-01", "conn", "10.0.1.1", _TS2),
|
||||||
|
attacker_ip="10.0.1.1",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def mock_get_logs(last_id, limit=_BATCH_SIZE):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
return batch_1
|
||||||
|
elif call_count == 2:
|
||||||
|
return batch_2
|
||||||
|
return []
|
||||||
|
|
||||||
|
repo = _make_repo()
|
||||||
|
repo.get_logs_after_id = AsyncMock(side_effect=mock_get_logs)
|
||||||
|
state = _WorkerState(initialized=True, last_log_id=0)
|
||||||
|
|
||||||
|
await _incremental_update(repo, state)
|
||||||
|
|
||||||
|
assert state.last_log_id == _BATCH_SIZE + 1
|
||||||
|
assert repo.upsert_attacker.await_count == _BATCH_SIZE + 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bounties_fetched_only_for_affected_ips(self):
|
||||||
|
new_rows = [
|
||||||
|
_make_log_row(
|
||||||
|
row_id=1,
|
||||||
|
raw_line=_make_raw_line("ssh", "decky-01", "conn", "1.1.1.1", _TS1),
|
||||||
|
attacker_ip="1.1.1.1",
|
||||||
|
),
|
||||||
|
_make_log_row(
|
||||||
|
row_id=2,
|
||||||
|
raw_line=_make_raw_line("ssh", "decky-01", "conn", "2.2.2.2", _TS2),
|
||||||
|
attacker_ip="2.2.2.2",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
repo = _make_repo()
|
||||||
|
repo.get_logs_after_id = AsyncMock(return_value=new_rows)
|
||||||
|
state = _WorkerState(initialized=True, last_log_id=0)
|
||||||
|
|
||||||
|
await _incremental_update(repo, state)
|
||||||
|
|
||||||
|
repo.get_bounties_for_ips.assert_awaited_once()
|
||||||
|
called_ips = repo.get_bounties_for_ips.call_args[0][0]
|
||||||
|
assert called_ips == {"1.1.1.1", "2.2.2.2"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_uninitialized_state_triggers_cold_start(self):
|
||||||
|
rows = [
|
||||||
|
_make_log_row(
|
||||||
|
row_id=1,
|
||||||
|
raw_line=_make_raw_line("ssh", "decky-01", "conn", "1.1.1.1", _TS1),
|
||||||
|
attacker_ip="1.1.1.1",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
repo = _make_repo(logs=rows, max_log_id=1)
|
||||||
|
state = _WorkerState()
|
||||||
|
|
||||||
|
await _incremental_update(repo, state)
|
||||||
|
|
||||||
|
assert state.initialized is True
|
||||||
|
repo.get_all_logs_raw.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── attacker_profile_worker ────────────────────────────────────────────────
|
||||||
|
|
||||||
class TestAttackerProfileWorker:
|
class TestAttackerProfileWorker:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -409,7 +571,7 @@ class TestAttackerProfileWorker:
|
|||||||
await task
|
await task
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_worker_handles_rebuild_error_without_crashing(self):
|
async def test_worker_handles_update_error_without_crashing(self):
|
||||||
repo = _make_repo()
|
repo = _make_repo()
|
||||||
_call_count = 0
|
_call_count = 0
|
||||||
|
|
||||||
@@ -419,16 +581,16 @@ class TestAttackerProfileWorker:
|
|||||||
if _call_count >= 2:
|
if _call_count >= 2:
|
||||||
raise asyncio.CancelledError()
|
raise asyncio.CancelledError()
|
||||||
|
|
||||||
async def bad_rebuild(_repo):
|
async def bad_update(_repo, _state):
|
||||||
raise RuntimeError("DB exploded")
|
raise RuntimeError("DB exploded")
|
||||||
|
|
||||||
with patch("decnet.web.attacker_worker.asyncio.sleep", side_effect=fake_sleep):
|
with patch("decnet.web.attacker_worker.asyncio.sleep", side_effect=fake_sleep):
|
||||||
with patch("decnet.web.attacker_worker._rebuild", side_effect=bad_rebuild):
|
with patch("decnet.web.attacker_worker._incremental_update", side_effect=bad_update):
|
||||||
with pytest.raises(asyncio.CancelledError):
|
with pytest.raises(asyncio.CancelledError):
|
||||||
await attacker_profile_worker(repo)
|
await attacker_profile_worker(repo)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_worker_calls_rebuild_after_sleep(self):
|
async def test_worker_calls_update_after_sleep(self):
|
||||||
repo = _make_repo()
|
repo = _make_repo()
|
||||||
_call_count = 0
|
_call_count = 0
|
||||||
|
|
||||||
@@ -438,17 +600,17 @@ class TestAttackerProfileWorker:
|
|||||||
if _call_count >= 2:
|
if _call_count >= 2:
|
||||||
raise asyncio.CancelledError()
|
raise asyncio.CancelledError()
|
||||||
|
|
||||||
rebuild_calls = []
|
update_calls = []
|
||||||
|
|
||||||
async def mock_rebuild(_repo):
|
async def mock_update(_repo, _state):
|
||||||
rebuild_calls.append(True)
|
update_calls.append(True)
|
||||||
|
|
||||||
with patch("decnet.web.attacker_worker.asyncio.sleep", side_effect=fake_sleep):
|
with patch("decnet.web.attacker_worker.asyncio.sleep", side_effect=fake_sleep):
|
||||||
with patch("decnet.web.attacker_worker._rebuild", side_effect=mock_rebuild):
|
with patch("decnet.web.attacker_worker._incremental_update", side_effect=mock_update):
|
||||||
with pytest.raises(asyncio.CancelledError):
|
with pytest.raises(asyncio.CancelledError):
|
||||||
await attacker_profile_worker(repo)
|
await attacker_profile_worker(repo)
|
||||||
|
|
||||||
assert len(rebuild_calls) >= 1
|
assert len(update_calls) >= 1
|
||||||
|
|
||||||
|
|
||||||
# ─── JA3 bounty extraction from ingester ─────────────────────────────────────
|
# ─── JA3 bounty extraction from ingester ─────────────────────────────────────
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ class DummyRepo(BaseRepository):
|
|||||||
async def get_state(self, k): await super().get_state(k)
|
async def get_state(self, k): await super().get_state(k)
|
||||||
async def set_state(self, k, v): await super().set_state(k, v)
|
async def set_state(self, k, v): await super().set_state(k, v)
|
||||||
async def get_all_logs_raw(self): await super().get_all_logs_raw()
|
async def get_all_logs_raw(self): await super().get_all_logs_raw()
|
||||||
|
async def get_max_log_id(self): await super().get_max_log_id()
|
||||||
|
async def get_logs_after_id(self, last_id, limit=500): await super().get_logs_after_id(last_id, limit)
|
||||||
async def get_all_bounties_by_ip(self): await super().get_all_bounties_by_ip()
|
async def get_all_bounties_by_ip(self): await super().get_all_bounties_by_ip()
|
||||||
|
async def get_bounties_for_ips(self, ips): await super().get_bounties_for_ips(ips)
|
||||||
async def upsert_attacker(self, d): await super().upsert_attacker(d)
|
async def upsert_attacker(self, d): await super().upsert_attacker(d)
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -47,7 +51,11 @@ async def test_base_repo_coverage():
|
|||||||
await dr.get_state("k")
|
await dr.get_state("k")
|
||||||
await dr.set_state("k", "v")
|
await dr.set_state("k", "v")
|
||||||
await dr.get_all_logs_raw()
|
await dr.get_all_logs_raw()
|
||||||
|
await dr.get_max_log_id()
|
||||||
|
await dr.get_logs_after_id(0)
|
||||||
await dr.get_all_bounties_by_ip()
|
await dr.get_all_bounties_by_ip()
|
||||||
|
await dr.get_bounties_for_ips({"1.1.1.1"})
|
||||||
await dr.upsert_attacker({})
|
await dr.upsert_attacker({})
|
||||||
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user