diff --git a/decnet/web/ingester.py b/decnet/web/ingester.py index ddf555d..675b418 100644 --- a/decnet/web/ingester.py +++ b/decnet/web/ingester.py @@ -97,4 +97,36 @@ async def _extract_bounty(repo: BaseRepository, log_data: dict[str, Any]) -> Non } }) - # 2. Add more extractors here later (e.g. file hashes, crypto keys) + # 2. HTTP User-Agent fingerprint + _headers = _fields.get("headers") if isinstance(_fields.get("headers"), dict) else {} + _ua = _headers.get("User-Agent") or _headers.get("user-agent") + if _ua: + await repo.add_bounty({ + "decky": log_data.get("decky"), + "service": log_data.get("service"), + "attacker_ip": log_data.get("attacker_ip"), + "bounty_type": "fingerprint", + "payload": { + "fingerprint_type": "http_useragent", + "value": _ua, + "method": _fields.get("method"), + "path": _fields.get("path"), + } + }) + + # 3. VNC client version fingerprint + _vnc_ver = _fields.get("client_version") + if _vnc_ver and log_data.get("event_type") == "version": + await repo.add_bounty({ + "decky": log_data.get("decky"), + "service": log_data.get("service"), + "attacker_ip": log_data.get("attacker_ip"), + "bounty_type": "fingerprint", + "payload": { + "fingerprint_type": "vnc_client_version", + "value": _vnc_ver, + } + }) + + # 4. SSH client banner fingerprint (deferred — requires asyncssh server) + # Fires on: service=ssh, event_type=client_banner, fields.client_banner diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index cbd908d..681068f 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -45,7 +45,7 @@ ## Core / Hardening -- [ ] **Attacker fingerprinting** — Capture TLS JA3/JA4 hashes, TCP window sizes, User-Agent strings, and SSH client banners. +- [x] **Attacker fingerprinting** — HTTP User-Agent and VNC client version stored as `fingerprint` bounties. TLS JA3/JA4 and TCP window sizes require pcap (out of scope). SSH client banner deferred pending asyncssh server. - [ ] **Canary tokens** — Embed fake AWS keys and honeydocs into decky filesystems. - [ ] **Tarpit mode** — Slow down attackers by drip-feeding bytes or delaying responses. - [x] **Dynamic decky mutation** — Rotate exposed services or OS fingerprints over time. @@ -66,7 +66,7 @@ - [x] **Web dashboard** — Real-time React SPA + FastAPI backend for logs and fleet status. - [x] **Decky Inventory** — Dedicated "Decoy Fleet" page showing all deployed assets. - [ ] **Pre-built Kibana/Grafana dashboards** — Ship JSON exports for ELK/Grafana. -- [ ] **CLI live feed** — `decnet watch` command for a unified, colored terminal stream. +- [~] **CLI live feed** — `decnet watch` — WON'T IMPLEMENT: redundant with `tail -f` on the existing log file; adds bloat without meaningful value. - [x] **Traversal graph export** — Export attacker movement as JSON (via CLI). ## Deployment & Infrastructure diff --git a/tests/test_fingerprinting.py b/tests/test_fingerprinting.py new file mode 100644 index 0000000..544efe6 --- /dev/null +++ b/tests/test_fingerprinting.py @@ -0,0 +1,208 @@ +"""Tests for attacker fingerprint extraction in the ingester.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, call +from decnet.web.ingester import _extract_bounty + + +def _make_repo(): + repo = MagicMock() + repo.add_bounty = AsyncMock() + return repo + + +# --------------------------------------------------------------------------- +# HTTP User-Agent +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_http_useragent_extracted(): + repo = _make_repo() + log_data = { + "decky": "decky-01", + "service": "http", + "attacker_ip": "10.0.0.1", + "event_type": "request", + "fields": { + "method": "GET", + "path": "/admin", + "headers": {"User-Agent": "Nikto/2.1.6", "Host": "target"}, + }, + } + await _extract_bounty(repo, log_data) + repo.add_bounty.assert_awaited_once() + call_kwargs = repo.add_bounty.call_args[0][0] + assert call_kwargs["bounty_type"] == "fingerprint" + assert call_kwargs["payload"]["fingerprint_type"] == "http_useragent" + assert call_kwargs["payload"]["value"] == "Nikto/2.1.6" + assert call_kwargs["payload"]["path"] == "/admin" + assert call_kwargs["payload"]["method"] == "GET" + + +@pytest.mark.asyncio +async def test_http_useragent_lowercase_key(): + repo = _make_repo() + log_data = { + "decky": "decky-01", + "service": "http", + "attacker_ip": "10.0.0.2", + "event_type": "request", + "fields": { + "headers": {"user-agent": "sqlmap/1.7"}, + }, + } + await _extract_bounty(repo, log_data) + call_kwargs = repo.add_bounty.call_args[0][0] + assert call_kwargs["payload"]["value"] == "sqlmap/1.7" + + +@pytest.mark.asyncio +async def test_http_no_useragent_no_fingerprint_bounty(): + repo = _make_repo() + log_data = { + "decky": "decky-01", + "service": "http", + "attacker_ip": "10.0.0.3", + "event_type": "request", + "fields": { + "headers": {"Host": "target"}, + }, + } + await _extract_bounty(repo, log_data) + repo.add_bounty.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_http_headers_not_dict_no_crash(): + repo = _make_repo() + log_data = { + "decky": "decky-01", + "service": "http", + "attacker_ip": "10.0.0.4", + "event_type": "request", + "fields": {"headers": "raw-string-not-a-dict"}, + } + await _extract_bounty(repo, log_data) + repo.add_bounty.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# VNC client version +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_vnc_client_version_extracted(): + repo = _make_repo() + log_data = { + "decky": "decky-02", + "service": "vnc", + "attacker_ip": "10.0.0.5", + "event_type": "version", + "fields": {"client_version": "RFB 003.008", "src": "10.0.0.5"}, + } + await _extract_bounty(repo, log_data) + repo.add_bounty.assert_awaited_once() + call_kwargs = repo.add_bounty.call_args[0][0] + assert call_kwargs["bounty_type"] == "fingerprint" + assert call_kwargs["payload"]["fingerprint_type"] == "vnc_client_version" + assert call_kwargs["payload"]["value"] == "RFB 003.008" + + +@pytest.mark.asyncio +async def test_vnc_non_version_event_no_fingerprint(): + repo = _make_repo() + log_data = { + "decky": "decky-02", + "service": "vnc", + "attacker_ip": "10.0.0.6", + "event_type": "auth_response", + "fields": {"client_version": "RFB 003.008", "src": "10.0.0.6"}, + } + await _extract_bounty(repo, log_data) + repo.add_bounty.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_vnc_version_event_no_client_version_field(): + repo = _make_repo() + log_data = { + "decky": "decky-02", + "service": "vnc", + "attacker_ip": "10.0.0.7", + "event_type": "version", + "fields": {"src": "10.0.0.7"}, + } + await _extract_bounty(repo, log_data) + repo.add_bounty.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Credential extraction unaffected +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_credential_still_extracted_alongside_fingerprint(): + repo = _make_repo() + log_data = { + "decky": "decky-03", + "service": "ftp", + "attacker_ip": "10.0.0.8", + "event_type": "auth_attempt", + "fields": {"username": "admin", "password": "1234"}, + } + await _extract_bounty(repo, log_data) + repo.add_bounty.assert_awaited_once() + call_kwargs = repo.add_bounty.call_args[0][0] + assert call_kwargs["bounty_type"] == "credential" + + +@pytest.mark.asyncio +async def test_http_credential_and_fingerprint_both_extracted(): + """An HTTP login attempt can yield both a credential and a UA fingerprint.""" + repo = _make_repo() + log_data = { + "decky": "decky-03", + "service": "http", + "attacker_ip": "10.0.0.9", + "event_type": "request", + "fields": { + "username": "root", + "password": "toor", + "headers": {"User-Agent": "curl/7.88.1"}, + }, + } + await _extract_bounty(repo, log_data) + assert repo.add_bounty.await_count == 2 + types = {c[0][0]["bounty_type"] for c in repo.add_bounty.call_args_list} + assert types == {"credential", "fingerprint"} + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_fields_not_dict_no_crash(): + repo = _make_repo() + log_data = { + "decky": "decky-04", + "service": "http", + "attacker_ip": "10.0.0.10", + "event_type": "request", + "fields": None, + } + await _extract_bounty(repo, log_data) + repo.add_bounty.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_fields_missing_entirely_no_crash(): + repo = _make_repo() + log_data = { + "decky": "decky-04", + "service": "http", + "attacker_ip": "10.0.0.11", + "event_type": "request", + } + await _extract_bounty(repo, log_data) + repo.add_bounty.assert_not_awaited()