feat: extract HTTP User-Agent and VNC client version as fingerprint bounties
Some checks failed
CI / Lint (ruff) (push) Successful in 11s
CI / SAST (bandit) (push) Successful in 14s
CI / Dependency audit (pip-audit) (push) Successful in 24s
CI / Test (Standard) (3.11) (push) Successful in 2m2s
CI / Test (Standard) (3.12) (push) Successful in 2m5s
CI / Test (Live) (3.11) (push) Successful in 56s
CI / Test (Fuzz) (3.11) (push) Failing after 6m25s
CI / Merge dev → testing (push) Has been skipped
CI / Prepare Merge to Main (push) Has been skipped
CI / Finalize Merge to Main (push) Has been skipped
Some checks failed
CI / Lint (ruff) (push) Successful in 11s
CI / SAST (bandit) (push) Successful in 14s
CI / Dependency audit (pip-audit) (push) Successful in 24s
CI / Test (Standard) (3.11) (push) Successful in 2m2s
CI / Test (Standard) (3.12) (push) Successful in 2m5s
CI / Test (Live) (3.11) (push) Successful in 56s
CI / Test (Fuzz) (3.11) (push) Failing after 6m25s
CI / Merge dev → testing (push) Has been skipped
CI / Prepare Merge to Main (push) Has been skipped
CI / Finalize Merge to Main (push) Has been skipped
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
## Core / Hardening
|
## 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.
|
- [ ] **Canary tokens** — Embed fake AWS keys and honeydocs into decky filesystems.
|
||||||
- [ ] **Tarpit mode** — Slow down attackers by drip-feeding bytes or delaying responses.
|
- [ ] **Tarpit mode** — Slow down attackers by drip-feeding bytes or delaying responses.
|
||||||
- [x] **Dynamic decky mutation** — Rotate exposed services or OS fingerprints over time.
|
- [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] **Web dashboard** — Real-time React SPA + FastAPI backend for logs and fleet status.
|
||||||
- [x] **Decky Inventory** — Dedicated "Decoy Fleet" page showing all deployed assets.
|
- [x] **Decky Inventory** — Dedicated "Decoy Fleet" page showing all deployed assets.
|
||||||
- [ ] **Pre-built Kibana/Grafana dashboards** — Ship JSON exports for ELK/Grafana.
|
- [ ] **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).
|
- [x] **Traversal graph export** — Export attacker movement as JSON (via CLI).
|
||||||
|
|
||||||
## Deployment & Infrastructure
|
## Deployment & Infrastructure
|
||||||
|
|||||||
208
tests/test_fingerprinting.py
Normal file
208
tests/test_fingerprinting.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user