From 41fd4961286c612b7085b4b46cf3ab7b7c7fe72a Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 18 Apr 2026 05:36:48 -0400 Subject: [PATCH] feat(web): attacker artifacts endpoint + UI drawer Adds the server-side wiring and frontend UI to surface files captured by the SSH honeypot for a given attacker. - New repository method get_attacker_artifacts (abstract + SQLModel impl) that joins the attacker's IP to `file_captured` log rows. - New route GET /attackers/{uuid}/artifacts. - New router /artifacts/{decky}/{service}/{stored_as} that streams a quarantined file back to an authenticated viewer. - AttackerDetail grows an ArtifactDrawer panel with per-file metadata (sha256, size, orig_path) and a download action. - ssh service fragment now sets NODE_NAME=decky_name so logs and the host-side artifacts bind-mount share the same decky identifier. --- decnet/services/ssh.py | 6 + decnet/web/db/repository.py | 5 + decnet/web/db/sqlmodel_repo.py | 24 +++ decnet/web/router/__init__.py | 6 + decnet/web/router/artifacts/__init__.py | 0 .../web/router/artifacts/api_get_artifact.py | 84 ++++++++ .../attackers/api_get_attacker_artifacts.py | 34 ++++ decnet_web/src/components/ArtifactDrawer.tsx | 186 ++++++++++++++++++ decnet_web/src/components/AttackerDetail.tsx | 110 ++++++++++- tests/api/artifacts/__init__.py | 0 tests/api/artifacts/test_get_artifact.py | 127 ++++++++++++ tests/test_api_attackers.py | 55 ++++++ tests/test_services.py | 3 +- 13 files changed, 638 insertions(+), 2 deletions(-) create mode 100644 decnet/web/router/artifacts/__init__.py create mode 100644 decnet/web/router/artifacts/api_get_artifact.py create mode 100644 decnet/web/router/attackers/api_get_attacker_artifacts.py create mode 100644 decnet_web/src/components/ArtifactDrawer.tsx create mode 100644 tests/api/artifacts/__init__.py create mode 100644 tests/api/artifacts/test_get_artifact.py diff --git a/decnet/services/ssh.py b/decnet/services/ssh.py index 1148c82..f3bc18e 100644 --- a/decnet/services/ssh.py +++ b/decnet/services/ssh.py @@ -32,6 +32,12 @@ class SSHService(BaseService): cfg = service_cfg or {} env: dict = { "SSH_ROOT_PASSWORD": cfg.get("password", "admin"), + # NODE_NAME is the authoritative decky identifier for log + # attribution — matches the host path used for the artifacts + # bind mount below. The container hostname (optionally overridden + # via SSH_HOSTNAME) is cosmetic and may differ to keep the + # decoy looking heterogeneous. + "NODE_NAME": decky_name, } if "hostname" in cfg: env["SSH_HOSTNAME"] = cfg["hostname"] diff --git a/decnet/web/db/repository.py b/decnet/web/db/repository.py index 7ea025c..07f5e8a 100644 --- a/decnet/web/db/repository.py +++ b/decnet/web/db/repository.py @@ -192,3 +192,8 @@ class BaseRepository(ABC): ) -> dict[str, Any]: """Retrieve paginated commands for an attacker, optionally filtered by service.""" pass + + @abstractmethod + async def get_attacker_artifacts(self, uuid: str) -> list[dict[str, Any]]: + """Return `file_captured` log rows for this attacker, newest first.""" + pass diff --git a/decnet/web/db/sqlmodel_repo.py b/decnet/web/db/sqlmodel_repo.py index c027b48..e6cb46f 100644 --- a/decnet/web/db/sqlmodel_repo.py +++ b/decnet/web/db/sqlmodel_repo.py @@ -729,3 +729,27 @@ class SQLModelRepository(BaseRepository): total = len(commands) page = commands[offset: offset + limit] return {"total": total, "data": page} + + async def get_attacker_artifacts(self, uuid: str) -> list[dict[str, Any]]: + """Return `file_captured` logs for the attacker identified by UUID. + + Resolves the attacker's IP first, then queries the logs table on two + indexed columns (``attacker_ip`` and ``event_type``). No JSON extract + needed — the decky/stored_as are already decoded into ``fields`` by + the ingester and returned to the frontend for drawer rendering. + """ + async with self._session() as session: + ip_res = await session.execute( + select(Attacker.ip).where(Attacker.uuid == uuid) + ) + ip = ip_res.scalar_one_or_none() + if not ip: + return [] + rows = await session.execute( + select(Log) + .where(Log.attacker_ip == ip) + .where(Log.event_type == "file_captured") + .order_by(desc(Log.timestamp)) + .limit(200) + ) + return [r.model_dump(mode="json") for r in rows.scalars().all()] diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py index dbbc805..7efc410 100644 --- a/decnet/web/router/__init__.py +++ b/decnet/web/router/__init__.py @@ -14,11 +14,13 @@ from .stream.api_stream_events import router as stream_router from .attackers.api_get_attackers import router as attackers_router from .attackers.api_get_attacker_detail import router as attacker_detail_router from .attackers.api_get_attacker_commands import router as attacker_commands_router +from .attackers.api_get_attacker_artifacts import router as attacker_artifacts_router from .config.api_get_config import router as config_get_router from .config.api_update_config import router as config_update_router from .config.api_manage_users import router as config_users_router from .config.api_reinit import router as config_reinit_router from .health.api_get_health import router as health_router +from .artifacts.api_get_artifact import router as artifacts_router api_router = APIRouter() @@ -43,6 +45,7 @@ api_router.include_router(deploy_deckies_router) api_router.include_router(attackers_router) api_router.include_router(attacker_detail_router) api_router.include_router(attacker_commands_router) +api_router.include_router(attacker_artifacts_router) # Observability api_router.include_router(stats_router) @@ -54,3 +57,6 @@ api_router.include_router(config_get_router) api_router.include_router(config_update_router) api_router.include_router(config_users_router) api_router.include_router(config_reinit_router) + +# Artifacts (captured attacker file drops) +api_router.include_router(artifacts_router) diff --git a/decnet/web/router/artifacts/__init__.py b/decnet/web/router/artifacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/decnet/web/router/artifacts/api_get_artifact.py b/decnet/web/router/artifacts/api_get_artifact.py new file mode 100644 index 0000000..c5f6c92 --- /dev/null +++ b/decnet/web/router/artifacts/api_get_artifact.py @@ -0,0 +1,84 @@ +""" +Artifact download endpoint. + +SSH deckies farm attacker file drops into a host-mounted quarantine: + /var/lib/decnet/artifacts/{decky}/ssh/{stored_as} + +The capture event already flows through the normal log pipeline (one +RFC 5424 line per capture, see templates/ssh/emit_capture.py), so metadata +is served via /logs. This endpoint exists only to retrieve the raw bytes — +admin-gated because the payloads are attacker-controlled content. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse + +from decnet.telemetry import traced as _traced +from decnet.web.dependencies import require_admin + +router = APIRouter() + +# Override via env for tests; the prod path matches the bind mount declared in +# decnet/services/ssh.py. +ARTIFACTS_ROOT = Path(os.environ.get("DECNET_ARTIFACTS_ROOT", "/var/lib/decnet/artifacts")) + +# decky names come from the deployer — lowercase alnum plus hyphens. +_DECKY_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$") + +# stored_as is assembled by capture.sh as: +# ${ts}_${sha:0:12}_${base} +# where ts is ISO-8601 UTC (e.g. 2026-04-18T02:22:56Z), sha is 12 hex chars, +# and base is the original filename's basename. Keep the filename charset +# tight but allow common punctuation dropped files actually use. +_STORED_AS_RE = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z_[a-f0-9]{12}_[A-Za-z0-9._-]{1,255}$" +) + + +def _resolve_artifact_path(decky: str, stored_as: str) -> Path: + """Validate inputs, resolve the on-disk path, and confirm it stays inside + the artifacts root. Raises HTTPException(400) on any violation.""" + if not _DECKY_RE.fullmatch(decky): + raise HTTPException(status_code=400, detail="invalid decky name") + if not _STORED_AS_RE.fullmatch(stored_as): + raise HTTPException(status_code=400, detail="invalid stored_as") + + root = ARTIFACTS_ROOT.resolve() + candidate = (root / decky / "ssh" / stored_as).resolve() + # defence-in-depth: even though the regexes reject `..`, make sure a + # symlink or weird filesystem state can't escape the root. + if root not in candidate.parents and candidate != root: + raise HTTPException(status_code=400, detail="path escapes artifacts root") + return candidate + + +@router.get( + "/artifacts/{decky}/{stored_as}", + tags=["Artifacts"], + responses={ + 400: {"description": "Invalid decky or stored_as parameter"}, + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Admin access required"}, + 404: {"description": "Artifact not found"}, + }, +) +@_traced("api.get_artifact") +async def get_artifact( + decky: str, + stored_as: str, + admin: dict = Depends(require_admin), +) -> FileResponse: + path = _resolve_artifact_path(decky, stored_as) + if not path.is_file(): + raise HTTPException(status_code=404, detail="artifact not found") + return FileResponse( + path=str(path), + media_type="application/octet-stream", + filename=stored_as, + ) diff --git a/decnet/web/router/attackers/api_get_attacker_artifacts.py b/decnet/web/router/attackers/api_get_attacker_artifacts.py new file mode 100644 index 0000000..000dc1f --- /dev/null +++ b/decnet/web/router/attackers/api_get_attacker_artifacts.py @@ -0,0 +1,34 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException + +from decnet.telemetry import traced as _traced +from decnet.web.dependencies import require_viewer, repo + +router = APIRouter() + + +@router.get( + "/attackers/{uuid}/artifacts", + tags=["Attacker Profiles"], + responses={ + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Attacker not found"}, + }, +) +@_traced("api.get_attacker_artifacts") +async def get_attacker_artifacts( + uuid: str, + user: dict = Depends(require_viewer), +) -> dict[str, Any]: + """List captured file-drop artifacts for an attacker (newest first). + + Each entry is a `file_captured` log row — the frontend renders the + badge/drawer using the same `fields` payload as /logs. + """ + attacker = await repo.get_attacker_by_uuid(uuid) + if not attacker: + raise HTTPException(status_code=404, detail="Attacker not found") + rows = await repo.get_attacker_artifacts(uuid) + return {"total": len(rows), "data": rows} diff --git a/decnet_web/src/components/ArtifactDrawer.tsx b/decnet_web/src/components/ArtifactDrawer.tsx new file mode 100644 index 0000000..491ec9c --- /dev/null +++ b/decnet_web/src/components/ArtifactDrawer.tsx @@ -0,0 +1,186 @@ +import React, { useState } from 'react'; +import { X, Download, AlertTriangle } from 'lucide-react'; +import api from '../utils/api'; + +interface ArtifactDrawerProps { + decky: string; + storedAs: string; + fields: Record; + onClose: () => void; +} + +// Bulky nested structures are shipped as one base64-encoded JSON blob in +// `meta_json_b64` (see templates/ssh/emit_capture.py). All summary fields +// arrive as top-level SD params already present in `fields`. +function decodeMeta(fields: Record): Record | null { + const b64 = fields.meta_json_b64; + if (typeof b64 !== 'string' || !b64) return null; + try { + const json = atob(b64); + return JSON.parse(json); + } catch (err) { + console.error('artifact: failed to decode meta_json_b64', err); + return null; + } +} + +const Row: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( +
+
{label}
+
{value ?? }
+
+); + +const ArtifactDrawer: React.FC = ({ decky, storedAs, fields, onClose }) => { + const [downloading, setDownloading] = useState(false); + const [error, setError] = useState(null); + const meta = decodeMeta(fields); + + const handleDownload = async () => { + setDownloading(true); + setError(null); + try { + const res = await api.get( + `/artifacts/${encodeURIComponent(decky)}/${encodeURIComponent(storedAs)}`, + { responseType: 'blob' }, + ); + const blobUrl = URL.createObjectURL(res.data); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = storedAs; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(blobUrl); + } catch (err: any) { + const status = err?.response?.status; + setError( + status === 403 ? 'Admin role required to download artifacts.' : + status === 404 ? 'Artifact not found on disk (may have been purged).' : + status === 400 ? 'Server rejected the request (invalid parameters).' : + 'Download failed — see console.' + ); + console.error('artifact download failed', err); + } finally { + setDownloading(false); + } + }; + + const concurrent = meta?.concurrent_sessions; + const ssSnapshot = meta?.ss_snapshot; + + return ( +
+
e.stopPropagation()} + style={{ + width: 'min(620px, 100%)', height: '100%', + backgroundColor: 'var(--bg-color, #0d1117)', + borderLeft: '1px solid var(--border-color, #30363d)', + padding: '24px', overflowY: 'auto', + color: 'var(--text-color)', + }} + > +
+
+
+ CAPTURED ARTIFACT · {decky} +
+
+ {storedAs} +
+
+ +
+ +
+ + Attacker-controlled content. Download at your own risk. +
+ + + {error && ( +
{error}
+ )} + +
+

+ ORIGIN +

+ + + + +
+ +
+

+ ATTRIBUTION · {fields.attribution ?? 'unknown'} +

+ + + + + + + + + +
+ + {Array.isArray(concurrent) && concurrent.length > 0 && ( +
+

+ CONCURRENT SESSIONS ({concurrent.length}) +

+
+              {JSON.stringify(concurrent, null, 2)}
+            
+
+ )} + + {Array.isArray(ssSnapshot) && ssSnapshot.length > 0 && ( +
+

+ SS SNAPSHOT ({ssSnapshot.length}) +

+
+              {JSON.stringify(ssSnapshot, null, 2)}
+            
+
+ )} +
+
+ ); +}; + +export default ArtifactDrawer; diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 3c8eda6..1d0d5af 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Activity, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey, Radio, Timer } from 'lucide-react'; +import { Activity, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey, Radio, Timer, Paperclip } from 'lucide-react'; import api from '../utils/api'; +import ArtifactDrawer from './ArtifactDrawer'; import './Dashboard.css'; interface AttackerBehavior { @@ -705,7 +706,19 @@ const AttackerDetail: React.FC = () => { behavior: true, commands: true, fingerprints: true, + artifacts: true, }); + + // Captured file-drop artifacts (ssh inotify farm) for this attacker. + type ArtifactLog = { + id: number; + timestamp: string; + decky: string; + service: string; + fields: string; // JSON-encoded SD params (parsed lazily below) + }; + const [artifacts, setArtifacts] = useState([]); + const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); const toggle = (key: string) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); // Commands pagination state @@ -759,6 +772,19 @@ const AttackerDetail: React.FC = () => { setCmdPage(1); }, [serviceFilter]); + useEffect(() => { + if (!id) return; + const fetchArtifacts = async () => { + try { + const res = await api.get(`/attackers/${id}/artifacts`); + setArtifacts(res.data.data ?? []); + } catch { + setArtifacts([]); + } + }; + fetchArtifacts(); + }, [id]); + if (loading) { return (
@@ -1058,6 +1084,88 @@ const AttackerDetail: React.FC = () => { ); })()} + {/* Captured Artifacts */} +
CAPTURED ARTIFACTS ({artifacts.length})} + open={openSections.artifacts} + onToggle={() => toggle('artifacts')} + > + {artifacts.length > 0 ? ( +
+ + + + + + + + + + + + + {artifacts.map((row) => { + let fields: Record = {}; + try { fields = JSON.parse(row.fields || '{}'); } catch {} + const storedAs = fields.stored_as ? String(fields.stored_as) : null; + const sha = fields.sha256 ? String(fields.sha256) : ''; + return ( + + + + + + + + + ); + })} + +
TIMESTAMPDECKYFILENAMESIZESHA-256
+ {new Date(row.timestamp).toLocaleString()} + {row.decky} + {fields.orig_path ?? storedAs ?? '—'} + + {fields.size ? `${fields.size} B` : '—'} + + {sha ? `${sha.slice(0, 12)}…` : '—'} + + {storedAs && ( + + )} +
+
+ ) : ( +
+ NO ARTIFACTS CAPTURED FROM THIS ATTACKER +
+ )} +
+ + {artifact && ( + setArtifact(null)} + /> + )} + {/* UUID footer */}
UUID: {attacker.uuid} diff --git a/tests/api/artifacts/__init__.py b/tests/api/artifacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/artifacts/test_get_artifact.py b/tests/api/artifacts/test_get_artifact.py new file mode 100644 index 0000000..dc7da12 --- /dev/null +++ b/tests/api/artifacts/test_get_artifact.py @@ -0,0 +1,127 @@ +""" +Tests for GET /api/v1/artifacts/{decky}/{stored_as}. + +Verifies admin-gating, 404 on missing files, 400 on malformed inputs, and +that path traversal attempts cannot escape DECNET_ARTIFACTS_ROOT. +""" + +from __future__ import annotations + +import httpx +import pytest + + +_DECKY = "test-decky-01" +_VALID_STORED_AS = "2026-04-18T02:22:56Z_abc123def456_payload.bin" +_PAYLOAD = b"attacker-drop-bytes\x00\x01\x02\xff" + + +@pytest.fixture +def artifacts_root(tmp_path, monkeypatch): + """Point the artifact endpoint at a tmp dir and seed one valid file.""" + root = tmp_path / "artifacts" + (root / _DECKY / "ssh").mkdir(parents=True) + (root / _DECKY / "ssh" / _VALID_STORED_AS).write_bytes(_PAYLOAD) + + # Patch the module-level constant (captured at import time). + from decnet.web.router.artifacts import api_get_artifact + monkeypatch.setattr(api_get_artifact, "ARTIFACTS_ROOT", root) + return root + + +async def test_admin_downloads_artifact(client: httpx.AsyncClient, auth_token: str, artifacts_root): + res = await client.get( + f"/api/v1/artifacts/{_DECKY}/{_VALID_STORED_AS}", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert res.status_code == 200, res.text + assert res.content == _PAYLOAD + assert res.headers["content-type"] == "application/octet-stream" + + +async def test_viewer_forbidden(client: httpx.AsyncClient, viewer_token: str, artifacts_root): + res = await client.get( + f"/api/v1/artifacts/{_DECKY}/{_VALID_STORED_AS}", + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert res.status_code == 403 + + +async def test_unauthenticated_rejected(client: httpx.AsyncClient, artifacts_root): + res = await client.get(f"/api/v1/artifacts/{_DECKY}/{_VALID_STORED_AS}") + assert res.status_code == 401 + + +async def test_missing_file_returns_404(client: httpx.AsyncClient, auth_token: str, artifacts_root): + missing = "2026-04-18T02:22:56Z_000000000000_nope.bin" + res = await client.get( + f"/api/v1/artifacts/{_DECKY}/{missing}", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert res.status_code == 404 + + +@pytest.mark.parametrize("bad_decky", [ + "UPPERCASE", + "has_underscore", + "has.dot", + "-leading-hyphen", + "", + "a/b", +]) +async def test_bad_decky_rejected(client: httpx.AsyncClient, auth_token: str, artifacts_root, bad_decky): + res = await client.get( + f"/api/v1/artifacts/{bad_decky}/{_VALID_STORED_AS}", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + # FastAPI returns 404 for routes that fail to match (e.g. `a/b` splits the + # path param); malformed-but-matching cases yield our 400. + assert res.status_code in (400, 404) + + +@pytest.mark.parametrize("bad_stored_as", [ + "not-a-timestamp_abc123def456_payload.bin", + "2026-04-18T02:22:56Z_SHORT_payload.bin", + "2026-04-18T02:22:56Z_abc123def456_", + "random-string", + "", +]) +async def test_bad_stored_as_rejected(client: httpx.AsyncClient, auth_token: str, artifacts_root, bad_stored_as): + res = await client.get( + f"/api/v1/artifacts/{_DECKY}/{bad_stored_as}", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert res.status_code in (400, 404) + + +async def test_path_traversal_blocked(client: httpx.AsyncClient, auth_token: str, artifacts_root, tmp_path): + """A file placed outside the artifacts root must be unreachable even if a + caller crafts a URL-encoded `..` in the stored_as segment.""" + secret = tmp_path / "secret.txt" + secret.write_bytes(b"top-secret") + # The regex for stored_as forbids slashes, `..`, etc. Any encoding trick + # that reaches the handler must still fail the regex → 400. + for payload in ( + "..%2Fsecret.txt", + "..", + "../../etc/passwd", + "%2e%2e/%2e%2e/etc/passwd", + ): + res = await client.get( + f"/api/v1/artifacts/{_DECKY}/{payload}", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + # Either 400 (our validator) or 404 (FastAPI didn't match the route) is fine; + # what's NOT fine is 200 with secret bytes. + assert res.status_code != 200 + assert b"top-secret" not in res.content + + +async def test_content_disposition_is_attachment(client: httpx.AsyncClient, auth_token: str, artifacts_root): + res = await client.get( + f"/api/v1/artifacts/{_DECKY}/{_VALID_STORED_AS}", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert res.status_code == 200 + cd = res.headers.get("content-disposition", "") + assert "attachment" in cd.lower() diff --git a/tests/test_api_attackers.py b/tests/test_api_attackers.py index 9efa573..3be4c1f 100644 --- a/tests/test_api_attackers.py +++ b/tests/test_api_attackers.py @@ -280,6 +280,61 @@ class TestGetAttackerCommands: assert exc_info.value.status_code == 404 +# ─── GET /attackers/{uuid}/artifacts ───────────────────────────────────────── + +class TestGetAttackerArtifacts: + @pytest.mark.asyncio + async def test_returns_artifacts(self): + from decnet.web.router.attackers.api_get_attacker_artifacts import get_attacker_artifacts + + sample = _sample_attacker() + rows = [ + { + "id": 1, + "timestamp": "2026-04-18T02:22:56+00:00", + "decky": "decky-01", + "service": "ssh", + "event_type": "file_captured", + "attacker_ip": "1.2.3.4", + "raw_line": "", + "msg": "", + "fields": json.dumps({ + "stored_as": "2026-04-18T02:22:56Z_abc123def456_drop.bin", + "sha256": "deadbeef" * 8, + "size": "4096", + "orig_path": "/root/drop.bin", + }), + }, + ] + with patch("decnet.web.router.attackers.api_get_attacker_artifacts.repo") as mock_repo: + mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample) + mock_repo.get_attacker_artifacts = AsyncMock(return_value=rows) + + result = await get_attacker_artifacts( + uuid="att-uuid-1", + user={"uuid": "test-user", "role": "viewer"}, + ) + + assert result["total"] == 1 + assert result["data"][0]["decky"] == "decky-01" + mock_repo.get_attacker_artifacts.assert_awaited_once_with("att-uuid-1") + + @pytest.mark.asyncio + async def test_404_on_unknown_uuid(self): + from decnet.web.router.attackers.api_get_attacker_artifacts import get_attacker_artifacts + + with patch("decnet.web.router.attackers.api_get_attacker_artifacts.repo") as mock_repo: + mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None) + + with pytest.raises(HTTPException) as exc_info: + await get_attacker_artifacts( + uuid="nonexistent", + user={"uuid": "test-user", "role": "viewer"}, + ) + + assert exc_info.value.status_code == 404 + + # ─── Auth enforcement ──────────────────────────────────────────────────────── class TestAttackersAuth: diff --git a/tests/test_services.py b/tests/test_services.py index 3e59e56..0fbb6e2 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -271,7 +271,8 @@ def test_ssh_default_env(): env = _fragment("ssh").get("environment", {}) assert env.get("SSH_ROOT_PASSWORD") == "admin" assert not any(k.startswith("COWRIE_") for k in env) - assert "NODE_NAME" not in env + # SSH propagates NODE_NAME for log attribution / artifact bind-mount paths. + assert env.get("NODE_NAME") == "test-decky" def test_ssh_custom_password():