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.
This commit is contained in:
2026-04-18 05:36:48 -04:00
parent 39dafaf384
commit 41fd496128
13 changed files with 638 additions and 2 deletions

View File

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