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
|
||||
|
||||
- [ ] **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
|
||||
|
||||
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