feat(pr2): HTTP/2+HTTP/3 fingerprint extractors — JA4H, H2 SETTINGS, JA4-QUIC
This commit is contained in:
@@ -29,11 +29,12 @@ class TestExtractFpSummaries:
|
||||
|
||||
def test_empty_input_returns_all_none(self):
|
||||
result = extract_fp_summaries([])
|
||||
assert result == {
|
||||
"ja3_hashes": None,
|
||||
"hassh_hashes": None,
|
||||
"tls_cert_sha256": None,
|
||||
}
|
||||
assert all(v is None for v in result.values())
|
||||
assert "ja3_hashes" in result
|
||||
assert "hassh_hashes" in result
|
||||
assert "tls_cert_sha256" in result
|
||||
assert "ja4h_hashes" in result
|
||||
assert "ja4_quic_hashes" in result
|
||||
|
||||
def test_single_row_single_cert(self):
|
||||
row = _row_with(_bounty("tls_certificate", cert_sha256="ab" * 32))
|
||||
@@ -139,3 +140,50 @@ class TestExtractFpSummaries:
|
||||
assert json.loads(result["ja3_hashes"]) == sorted(
|
||||
["ja3-shared", "ja3-second", "ja3-third"]
|
||||
)
|
||||
|
||||
# ── ja4h + ja4_quic (PR2 columns) ────────────────────────────────
|
||||
|
||||
def test_ja4h_single_value(self):
|
||||
row = _row_with(_bounty("ja4h", ja4h="GE11nn0000_02_abc_000"))
|
||||
result = extract_fp_summaries([row])
|
||||
assert json.loads(result["ja4h_hashes"]) == ["GE11nn0000_02_abc_000"]
|
||||
|
||||
def test_ja4_quic_single_value(self):
|
||||
row = _row_with(_bounty("ja4_quic", ja4_quic="q13d0310h2_002f_0403_h3"))
|
||||
result = extract_fp_summaries([row])
|
||||
assert json.loads(result["ja4_quic_hashes"]) == ["q13d0310h2_002f_0403_h3"]
|
||||
|
||||
def test_ja4h_dedup_across_rows(self):
|
||||
a = _row_with(_bounty("ja4h", ja4h="GE11nn0000_02_abc_000"))
|
||||
b = _row_with(_bounty("ja4h", ja4h="GE11nn0000_02_abc_000"))
|
||||
c = _row_with(_bounty("ja4h", ja4h="GE20nn0000_04_def_000"))
|
||||
result = extract_fp_summaries([a, b, c])
|
||||
hashes = json.loads(result["ja4h_hashes"])
|
||||
assert len(hashes) == 2
|
||||
assert "GE11nn0000_02_abc_000" in hashes
|
||||
assert "GE20nn0000_04_def_000" in hashes
|
||||
|
||||
def test_ja4h_and_ja4_quic_coexist(self):
|
||||
row = _row_with(
|
||||
_bounty("ja4h", ja4h="GE11nn0000_02_abc_000"),
|
||||
_bounty("ja4_quic", ja4_quic="q13d0310h2_002f_0403_h3"),
|
||||
)
|
||||
result = extract_fp_summaries([row])
|
||||
assert json.loads(result["ja4h_hashes"]) == ["GE11nn0000_02_abc_000"]
|
||||
assert json.loads(result["ja4_quic_hashes"]) == ["q13d0310h2_002f_0403_h3"]
|
||||
|
||||
def test_ja4h_missing_payload_key_skipped(self):
|
||||
# bounty shaped like a fingerprint but missing the 'ja4h' key
|
||||
row = _row_with({
|
||||
"bounty_type": "fingerprint",
|
||||
"payload": {"fingerprint_type": "ja4h", "protocol": "h1"},
|
||||
})
|
||||
result = extract_fp_summaries([row])
|
||||
assert result["ja4h_hashes"] is None
|
||||
|
||||
def test_empty_returns_none_for_new_columns(self):
|
||||
result = extract_fp_summaries([])
|
||||
assert "ja4h_hashes" in result
|
||||
assert result["ja4h_hashes"] is None
|
||||
assert "ja4_quic_hashes" in result
|
||||
assert result["ja4_quic_hashes"] is None
|
||||
|
||||
153
tests/sniffer/test_ja4h.py
Normal file
153
tests/sniffer/test_ja4h.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Tests for _ja4h computation and QUIC helpers in decnet.sniffer.fingerprint."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.sniffer.fingerprint import _ja4h, _quic_varint, _extract_crypto_frames
|
||||
|
||||
|
||||
class TestJA4H:
|
||||
def test_basic_get_h11(self):
|
||||
result = _ja4h(
|
||||
method="GET",
|
||||
version="HTTP/1.1",
|
||||
headers_ordered=["Host", "User-Agent", "Accept"],
|
||||
)
|
||||
parts = result.split("_")
|
||||
assert len(parts) == 4
|
||||
assert parts[0].startswith("GE11") # method + version
|
||||
assert parts[0][4] == "n" # no cookie
|
||||
assert parts[0][5] == "n" # no referer
|
||||
assert parts[0][6:10] == "0000" # no Accept-Language
|
||||
|
||||
def test_cookie_flag(self):
|
||||
result = _ja4h(
|
||||
method="POST",
|
||||
version="HTTP/1.1",
|
||||
headers_ordered=["Host", "Cookie", "Content-Type"],
|
||||
cookie_val="session=abc",
|
||||
)
|
||||
parts = result.split("_")
|
||||
assert parts[0][4] == "c" # has cookie
|
||||
assert parts[0][5] == "n" # no referer
|
||||
|
||||
def test_referer_flag(self):
|
||||
result = _ja4h(
|
||||
method="GET",
|
||||
version="HTTP/1.1",
|
||||
headers_ordered=["Host", "Referer"],
|
||||
)
|
||||
parts = result.split("_")
|
||||
assert parts[0][5] == "r" # has referer
|
||||
|
||||
def test_h2_version_tag(self):
|
||||
result = _ja4h(
|
||||
method="GET",
|
||||
version="HTTP/2.0",
|
||||
headers_ordered=["Host", "User-Agent"],
|
||||
)
|
||||
assert result.startswith("GE20")
|
||||
|
||||
def test_h3_version_tag(self):
|
||||
result = _ja4h(
|
||||
method="GET",
|
||||
version="HTTP/3.0",
|
||||
headers_ordered=["Host", "User-Agent"],
|
||||
)
|
||||
assert result.startswith("GE30")
|
||||
|
||||
def test_cookie_and_referer_excluded_from_header_hash(self):
|
||||
result_with = _ja4h(
|
||||
method="GET",
|
||||
version="HTTP/1.1",
|
||||
headers_ordered=["Host", "User-Agent", "Cookie", "Referer"],
|
||||
cookie_val="x=1",
|
||||
)
|
||||
result_without = _ja4h(
|
||||
method="GET",
|
||||
version="HTTP/1.1",
|
||||
headers_ordered=["Host", "User-Agent"],
|
||||
)
|
||||
# Header hash (parts[2]) must be identical — cookie/referer excluded from it
|
||||
assert result_with.split("_")[2] == result_without.split("_")[2]
|
||||
|
||||
def test_header_count_excludes_cookie_and_referer(self):
|
||||
result = _ja4h(
|
||||
method="GET",
|
||||
version="HTTP/1.1",
|
||||
headers_ordered=["Host", "Cookie", "Accept", "Referer"],
|
||||
)
|
||||
parts = result.split("_")
|
||||
# 2 headers after dropping Cookie and Referer (Host + Accept)
|
||||
assert parts[1] == "02"
|
||||
|
||||
def test_cookie_hash_alphabetical_sort(self):
|
||||
r1 = _ja4h("GET", "HTTP/1.1", [], cookie_val="z=3; a=1; m=2")
|
||||
r2 = _ja4h("GET", "HTTP/1.1", [], cookie_val="a=1; m=2; z=3")
|
||||
# Both should produce the same cookie hash regardless of original order
|
||||
assert r1.split("_")[3] == r2.split("_")[3]
|
||||
|
||||
def test_no_cookie_produces_12_zeros(self):
|
||||
result = _ja4h("GET", "HTTP/1.1", ["Host"])
|
||||
assert result.split("_")[3] == "000000000000"
|
||||
|
||||
def test_accept_lang_truncated_to_4_chars(self):
|
||||
result = _ja4h("GET", "HTTP/1.1", [], accept_lang="en-US,en;q=0.9")
|
||||
parts = result.split("_")
|
||||
lang_tag = parts[0][6:10]
|
||||
assert lang_tag == "en-U"
|
||||
|
||||
def test_deterministic(self):
|
||||
kwargs = dict(
|
||||
method="POST",
|
||||
version="HTTP/1.1",
|
||||
headers_ordered=["Host", "Content-Type", "Accept"],
|
||||
)
|
||||
assert _ja4h(**kwargs) == _ja4h(**kwargs)
|
||||
|
||||
|
||||
class TestQuicVarint:
|
||||
def test_1_byte(self):
|
||||
assert _quic_varint(b"\x3f", 0) == (63, 1)
|
||||
|
||||
def test_2_byte(self):
|
||||
# 0x4000 → big 2-byte form: 01 + 14 bits = 0x4000 = 16384
|
||||
data = bytes([0x40, 0x00])
|
||||
assert _quic_varint(data, 0) == (0, 2)
|
||||
|
||||
def test_4_byte(self):
|
||||
# 0x80000000 → 2 MSB = 10, value = 0
|
||||
data = bytes([0x80, 0x00, 0x00, 0x00])
|
||||
assert _quic_varint(data, 0) == (0, 4)
|
||||
|
||||
def test_small_values(self):
|
||||
assert _quic_varint(b"\x00", 0) == (0, 1)
|
||||
assert _quic_varint(b"\x01", 0) == (1, 1)
|
||||
assert _quic_varint(b"\x25", 0) == (37, 1)
|
||||
|
||||
|
||||
class TestExtractCryptoFrames:
|
||||
def test_single_crypto_frame(self):
|
||||
# CRYPTO frame: type=0x06, offset=0x00 (varint), length=5 (varint), data
|
||||
data_bytes = b"hello"
|
||||
frame = bytes([0x06, 0x00, 0x05]) + data_bytes
|
||||
result = _extract_crypto_frames(frame)
|
||||
assert result == b"hello"
|
||||
|
||||
def test_empty_payload(self):
|
||||
result = _extract_crypto_frames(b"")
|
||||
assert result == b""
|
||||
|
||||
def test_padding_skipped(self):
|
||||
# PADDING (0x00) + CRYPTO frame
|
||||
data_bytes = b"world"
|
||||
frame = bytes([0x00, 0x00, 0x06, 0x00, 0x05]) + data_bytes
|
||||
result = _extract_crypto_frames(frame)
|
||||
assert result == b"world"
|
||||
|
||||
def test_non_crypto_frame_stops_parsing(self):
|
||||
# Unknown frame type (0x10) after CRYPTO — should stop and return what we have
|
||||
data = b"hello"
|
||||
frame = bytes([0x06, 0x00, 0x05]) + data + bytes([0x10, 0x00])
|
||||
result = _extract_crypto_frames(frame)
|
||||
assert result == b"hello"
|
||||
123
tests/sniffer/test_quic_initial.py
Normal file
123
tests/sniffer/test_quic_initial.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Tests for QUIC v1 Initial packet key derivation (RFC 9001 Appendix A vectors)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.sniffer.fingerprint import (
|
||||
_hkdf_extract,
|
||||
_hkdf_expand_label,
|
||||
_quic_initial_keys,
|
||||
_QUIC_V1_INITIAL_SALT,
|
||||
_ja4_quic,
|
||||
_parse_quic_initial,
|
||||
)
|
||||
|
||||
|
||||
# RFC 9001 Appendix A.1 key derivation test vectors
|
||||
_RFC9001_DCID = bytes.fromhex("8394c8f03e515708")
|
||||
_RFC9001_CLIENT_KEY = bytes.fromhex("1f369613dd76d5467730efcbe3b1a22d")
|
||||
_RFC9001_CLIENT_IV = bytes.fromhex("fa044b2f42a3fd3b46fb255c")
|
||||
_RFC9001_CLIENT_HP = bytes.fromhex("9f50449e04a0e810283a1e9933adedd2")
|
||||
|
||||
|
||||
class TestHKDF:
|
||||
def test_extract_sha256(self):
|
||||
# HKDF-Extract is HMAC-SHA256(salt, IKM). Cross-check with a known value.
|
||||
result = _hkdf_extract(b"salt", b"ikm")
|
||||
import hmac, hashlib
|
||||
expected = hmac.new(b"salt", b"ikm", hashlib.sha256).digest()
|
||||
assert result == expected
|
||||
|
||||
def test_expand_label_length(self):
|
||||
secret = _hkdf_extract(_QUIC_V1_INITIAL_SALT, _RFC9001_DCID)
|
||||
# "client in" expand should be 32 bytes
|
||||
client_secret = _hkdf_expand_label(secret, "client in", b"", 32)
|
||||
assert len(client_secret) == 32
|
||||
|
||||
def test_expand_label_key_length(self):
|
||||
secret = _hkdf_extract(_QUIC_V1_INITIAL_SALT, _RFC9001_DCID)
|
||||
client_secret = _hkdf_expand_label(secret, "client in", b"", 32)
|
||||
key = _hkdf_expand_label(client_secret, "quic key", b"", 16)
|
||||
assert len(key) == 16
|
||||
|
||||
def test_expand_label_iv_length(self):
|
||||
secret = _hkdf_extract(_QUIC_V1_INITIAL_SALT, _RFC9001_DCID)
|
||||
client_secret = _hkdf_expand_label(secret, "client in", b"", 32)
|
||||
iv = _hkdf_expand_label(client_secret, "quic iv", b"", 12)
|
||||
assert len(iv) == 12
|
||||
|
||||
|
||||
class TestQuicInitialKeys:
|
||||
def test_rfc9001_appendix_a_vectors(self):
|
||||
"""Key derivation must match RFC 9001 Appendix A.1 test vectors exactly."""
|
||||
key, iv, hp = _quic_initial_keys(_RFC9001_DCID)
|
||||
assert key == _RFC9001_CLIENT_KEY, f"key mismatch: {key.hex()}"
|
||||
assert iv == _RFC9001_CLIENT_IV, f"iv mismatch: {iv.hex()}"
|
||||
assert hp == _RFC9001_CLIENT_HP, f"hp mismatch: {hp.hex()}"
|
||||
|
||||
|
||||
class TestJA4Quic:
|
||||
def test_proto_prefix_is_q(self):
|
||||
ch = {
|
||||
"cipher_suites": [0x1301, 0x1302],
|
||||
"extensions": [0x000a, 0x000d, 0x002b],
|
||||
"signature_algorithms": [0x0403, 0x0804],
|
||||
"supported_versions": [0x0304],
|
||||
"sni": "example.com",
|
||||
"alpn": ["h3"],
|
||||
"tls_version": 0x0303,
|
||||
}
|
||||
result = _ja4_quic(ch)
|
||||
assert result.startswith("q"), f"expected 'q' prefix: {result}"
|
||||
|
||||
def test_structure(self):
|
||||
ch = {
|
||||
"cipher_suites": [0x1301],
|
||||
"extensions": [0x000a],
|
||||
"signature_algorithms": [],
|
||||
"supported_versions": [0x0304],
|
||||
"sni": "",
|
||||
"alpn": [],
|
||||
"tls_version": 0x0303,
|
||||
}
|
||||
result = _ja4_quic(ch)
|
||||
parts = result.split("_")
|
||||
assert len(parts) == 3
|
||||
|
||||
def test_deterministic(self):
|
||||
ch = {
|
||||
"cipher_suites": [0x1301, 0x1302, 0x1303],
|
||||
"extensions": [0x000a, 0x000d],
|
||||
"signature_algorithms": [0x0403],
|
||||
"supported_versions": [0x0304],
|
||||
"sni": "host.example",
|
||||
"alpn": ["h3"],
|
||||
"tls_version": 0x0303,
|
||||
}
|
||||
assert _ja4_quic(ch) == _ja4_quic(ch)
|
||||
|
||||
|
||||
class TestParseQuicInitial:
|
||||
def test_short_header_rejected(self):
|
||||
# Short header: bit 7 clear
|
||||
assert _parse_quic_initial(b"\x40" + b"\x00" * 20) is None
|
||||
|
||||
def test_wrong_version_rejected(self):
|
||||
# Long header, Initial type, version = 0x00000002
|
||||
pkt = bytearray(30)
|
||||
pkt[0] = 0xC0 # long header + Initial
|
||||
pkt[1:5] = b"\x00\x00\x00\x02" # version 2
|
||||
assert _parse_quic_initial(bytes(pkt)) is None
|
||||
|
||||
def test_non_initial_type_rejected(self):
|
||||
# Long header, Handshake type (0x20 set)
|
||||
pkt = bytearray(30)
|
||||
pkt[0] = 0xE0 # long header + Handshake
|
||||
pkt[1:5] = b"\x00\x00\x00\x01"
|
||||
assert _parse_quic_initial(bytes(pkt)) is None
|
||||
|
||||
def test_garbage_returns_none(self):
|
||||
assert _parse_quic_initial(b"garbage bytes that are not QUIC") is None
|
||||
|
||||
def test_too_short_returns_none(self):
|
||||
assert _parse_quic_initial(b"\xc0\x00") is None
|
||||
@@ -28,11 +28,13 @@ from decnet.ttp.base import TaggerEvent, TolerantTagger
|
||||
from decnet.ttp.impl.behavioral_lifter import BehavioralLifter
|
||||
from decnet.ttp.impl.canary_fingerprint_lifter import CanaryFingerprintLifter
|
||||
from decnet.ttp.impl.email_lifter import EmailLifter
|
||||
from decnet.ttp.impl.http_fingerprint_lifter import HttpFingerprintLifter
|
||||
from decnet.ttp.impl.intel_lifter import IntelLifter
|
||||
from decnet.web.db.models.ttp import (
|
||||
CanaryFingerprintEvidence,
|
||||
CommandEvidence,
|
||||
EmailEvidence,
|
||||
HttpFingerprintEvidence,
|
||||
IntelEvidence,
|
||||
TTPTag,
|
||||
compute_tag_uuid,
|
||||
@@ -76,6 +78,14 @@ def test_canary_fingerprint_evidence_keys() -> None:
|
||||
assert keys == {"metric", "matched_signature"}
|
||||
|
||||
|
||||
def test_http_fingerprint_evidence_keys() -> None:
|
||||
keys = (
|
||||
HttpFingerprintEvidence.__required_keys__
|
||||
| HttpFingerprintEvidence.__optional_keys__
|
||||
)
|
||||
assert keys == {"kind", "hash", "protocol", "client_ip", "seen_at", "raw"}
|
||||
|
||||
|
||||
# ── Per-lifter parametrized positive case (impl phase) ──────────────
|
||||
|
||||
|
||||
|
||||
220
tests/ttp/test_http_fingerprint_lifter.py
Normal file
220
tests/ttp/test_http_fingerprint_lifter.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""Per-predicate unit tests for :class:`HttpFingerprintLifter` (PR2).
|
||||
|
||||
Covers HFP-0001 (scanner JA4H), HFP-0002 (h2/h3 settings probe),
|
||||
and HFP-0003 (QUIC probe) using synthetic CompiledRule stubs injected
|
||||
directly into the lifter's RuleIndex — no YAML on disk required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.ttp.base import TaggerEvent
|
||||
from decnet.ttp.impl.http_fingerprint_lifter import HttpFingerprintLifter
|
||||
from decnet.ttp.impl.rule_engine import CompiledRule
|
||||
from decnet.ttp.store.base import RuleState
|
||||
from tests.ttp._stub_store import StubRuleStore
|
||||
|
||||
|
||||
_EMITS_BY_RULE: dict[str, tuple] = {
|
||||
"HFP-0001": (("T1592", "002", "TA0043", 0.6),),
|
||||
"HFP-0002": (("T1046", None, "TA0043", 0.6),),
|
||||
"HFP-0003": (("T1046", None, "TA0043", 0.6),),
|
||||
}
|
||||
|
||||
|
||||
def _rule(rule_id: str, applies_to: str = "http_fingerprint") -> CompiledRule:
|
||||
return CompiledRule(
|
||||
rule_id=rule_id,
|
||||
rule_version=1,
|
||||
name=rule_id,
|
||||
applies_to=frozenset({applies_to}),
|
||||
match_spec={},
|
||||
emits=_EMITS_BY_RULE.get(rule_id, ()),
|
||||
evidence_fields=(),
|
||||
state=RuleState(),
|
||||
)
|
||||
|
||||
|
||||
def _make_lifter(*rule_ids: str) -> HttpFingerprintLifter:
|
||||
rules = [_rule(rid) for rid in rule_ids]
|
||||
lifter = HttpFingerprintLifter(StubRuleStore(compiled=rules))
|
||||
for rule in rules:
|
||||
lifter._index.install(rule)
|
||||
return lifter
|
||||
|
||||
|
||||
def _ev(payload: dict[str, Any]) -> TaggerEvent:
|
||||
return TaggerEvent(
|
||||
source_kind="http_fingerprint",
|
||||
source_id="src-fp",
|
||||
attacker_uuid="att-1",
|
||||
identity_uuid=None,
|
||||
session_id=None,
|
||||
decky_id=None,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
# ── HFP-0001: scanner JA4H prefix match ─────────────────────────────
|
||||
|
||||
|
||||
class TestScannerJA4H:
|
||||
def test_curl_h1_ja4h_fires(self):
|
||||
lifter = _make_lifter("HFP-0001")
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"ja4h": "GE11nn0000_02_abc123def456_000000000000",
|
||||
"protocol": "h1",
|
||||
"client_ip": "1.2.3.4",
|
||||
"seen_at": "2026-05-10T00:00:00Z",
|
||||
})))
|
||||
assert out, "HFP-0001 must fire on curl-default JA4H prefix"
|
||||
assert out[0].technique_id == "T1592"
|
||||
|
||||
def test_curl_h2_ja4h_fires(self):
|
||||
lifter = _make_lifter("HFP-0001")
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"ja4h": "GE20nn0000_02_abc123def456_000000000000",
|
||||
"protocol": "h2",
|
||||
})))
|
||||
assert out
|
||||
|
||||
def test_browser_ja4h_no_fire(self):
|
||||
lifter = _make_lifter("HFP-0001")
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"ja4h": "GE11cn0000_08_realbrwsr1234_000000000000",
|
||||
"protocol": "h1",
|
||||
})))
|
||||
assert out == []
|
||||
|
||||
def test_missing_ja4h_no_fire(self):
|
||||
lifter = _make_lifter("HFP-0001")
|
||||
out = asyncio.run(lifter.tag(_ev({"protocol": "h1"})))
|
||||
assert out == []
|
||||
|
||||
def test_evidence_keys_match_typeddict(self):
|
||||
lifter = _make_lifter("HFP-0001")
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"ja4h": "GE11nn0000_02_abc123def456_000000000000",
|
||||
"protocol": "h1",
|
||||
"client_ip": "10.0.0.1",
|
||||
"seen_at": "2026-05-10T00:00:00Z",
|
||||
})))
|
||||
assert out
|
||||
ev = out[0].evidence
|
||||
assert set(ev) == {"kind", "hash", "protocol", "client_ip", "seen_at", "raw"}
|
||||
assert ev["kind"] == "ja4h"
|
||||
assert ev["protocol"] == "h1"
|
||||
|
||||
def test_rule_not_installed_no_fire(self):
|
||||
lifter = _make_lifter() # no rules installed
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"ja4h": "GE11nn0000_02_abc_000000000000",
|
||||
})))
|
||||
assert out == []
|
||||
|
||||
|
||||
# ── HFP-0002: h2/h3 settings probe ──────────────────────────────────
|
||||
|
||||
|
||||
class TestH2H3Probe:
|
||||
def test_h2_settings_fires(self):
|
||||
lifter = _make_lifter("HFP-0002")
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"fingerprint_type": "http2_settings",
|
||||
"settings": {"HEADER_TABLE_SIZE": 65536},
|
||||
"client_ip": "5.6.7.8",
|
||||
"seen_at": "2026-05-10T00:00:00Z",
|
||||
})))
|
||||
assert out, "HFP-0002 must fire on http2_settings"
|
||||
assert out[0].technique_id == "T1046"
|
||||
|
||||
def test_h3_settings_fires(self):
|
||||
lifter = _make_lifter("HFP-0002")
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"fingerprint_type": "http3_settings",
|
||||
"settings": {"QPACK_MAX_TABLE_CAPACITY": 0},
|
||||
})))
|
||||
assert out
|
||||
ev = out[0].evidence
|
||||
assert ev["protocol"] == "h3"
|
||||
|
||||
def test_h2_settings_evidence_carries_raw(self):
|
||||
lifter = _make_lifter("HFP-0002")
|
||||
settings = {"HEADER_TABLE_SIZE": 4096, "MAX_CONCURRENT_STREAMS": 100}
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"fingerprint_type": "http2_settings",
|
||||
"settings": settings,
|
||||
})))
|
||||
assert out
|
||||
assert out[0].evidence["raw"] == settings
|
||||
|
||||
def test_ja4h_event_does_not_fire_h2_probe(self):
|
||||
lifter = _make_lifter("HFP-0002")
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"ja4h": "GE11nn0000_02_abc_000000000000",
|
||||
})))
|
||||
assert out == []
|
||||
|
||||
def test_unknown_fp_type_no_fire(self):
|
||||
lifter = _make_lifter("HFP-0002")
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"fingerprint_type": "ja3",
|
||||
})))
|
||||
assert out == []
|
||||
|
||||
|
||||
# ── HFP-0003: QUIC probe ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestQuicProbe:
|
||||
def test_ja4_quic_fires(self):
|
||||
lifter = _make_lifter("HFP-0003")
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"ja4_quic": "q13d0310h2_002f,0035_0403,0804_h3",
|
||||
"client_ip": "9.8.7.6",
|
||||
"seen_at": "2026-05-10T00:00:00Z",
|
||||
})))
|
||||
assert out, "HFP-0003 must fire on ja4_quic"
|
||||
assert out[0].technique_id == "T1046"
|
||||
|
||||
def test_evidence_protocol_is_h3(self):
|
||||
lifter = _make_lifter("HFP-0003")
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"ja4_quic": "q13d0310h2_002f,0035_0403,0804_h3",
|
||||
})))
|
||||
assert out
|
||||
assert out[0].evidence["protocol"] == "h3"
|
||||
assert out[0].evidence["kind"] == "ja4_quic"
|
||||
|
||||
def test_missing_ja4_quic_no_fire(self):
|
||||
lifter = _make_lifter("HFP-0003")
|
||||
out = asyncio.run(lifter.tag(_ev({"client_ip": "1.1.1.1"})))
|
||||
assert out == []
|
||||
|
||||
|
||||
# ── Combined: all three rules installed ──────────────────────────────
|
||||
|
||||
|
||||
class TestAllRulesCombined:
|
||||
def test_only_matching_rule_fires(self):
|
||||
lifter = _make_lifter("HFP-0001", "HFP-0002", "HFP-0003")
|
||||
# h2_settings payload should only fire HFP-0002
|
||||
out = asyncio.run(lifter.tag(_ev({
|
||||
"fingerprint_type": "http2_settings",
|
||||
"settings": {},
|
||||
})))
|
||||
rule_ids = {tag.rule_id for tag in out}
|
||||
assert "HFP-0002" in rule_ids
|
||||
assert "HFP-0001" not in rule_ids
|
||||
assert "HFP-0003" not in rule_ids
|
||||
|
||||
def test_empty_payload_no_errors(self):
|
||||
lifter = _make_lifter("HFP-0001", "HFP-0002", "HFP-0003")
|
||||
out = asyncio.run(lifter.tag(_ev({})))
|
||||
assert out == []
|
||||
|
||||
def test_handles_only_http_fingerprint(self):
|
||||
assert HttpFingerprintLifter.HANDLES == frozenset({"http_fingerprint"})
|
||||
153
tests/web/test_attackers_fingerprint_columns.py
Normal file
153
tests/web/test_attackers_fingerprint_columns.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Round-trip tests for the three PR2 fingerprint columns on AttackerIdentity.
|
||||
|
||||
Verifies:
|
||||
* ``ja4h_hashes``, ``ja4_quic_hashes``, ``http_versions_seen`` exist as
|
||||
Optional[str] fields on the model (type-level, GREEN today).
|
||||
* A full SQLite round-trip stores and retrieves non-None values correctly.
|
||||
* Columns default to None and don't affect existing columns.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid as _uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, get_type_hints
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.web.db.factory import get_repository
|
||||
from decnet.web.db.models.attackers import AttackerIdentity
|
||||
|
||||
|
||||
# ── Field presence (type-level, GREEN today) ─────────────────────────
|
||||
|
||||
|
||||
def test_ja4h_hashes_field_is_optional_str() -> None:
|
||||
hints = get_type_hints(AttackerIdentity)
|
||||
# Optional[str] == Union[str, None], repr varies by Python version
|
||||
assert "ja4h_hashes" in hints
|
||||
h = hints["ja4h_hashes"]
|
||||
assert h == Optional[str], f"unexpected type: {h}"
|
||||
|
||||
|
||||
def test_ja4_quic_hashes_field_is_optional_str() -> None:
|
||||
hints = get_type_hints(AttackerIdentity)
|
||||
assert "ja4_quic_hashes" in hints
|
||||
h = hints["ja4_quic_hashes"]
|
||||
assert h == Optional[str], f"unexpected type: {h}"
|
||||
|
||||
|
||||
def test_http_versions_seen_field_is_optional_str() -> None:
|
||||
hints = get_type_hints(AttackerIdentity)
|
||||
assert "http_versions_seen" in hints
|
||||
h = hints["http_versions_seen"]
|
||||
assert h == Optional[str], f"unexpected type: {h}"
|
||||
|
||||
|
||||
def test_new_columns_default_to_none() -> None:
|
||||
row = AttackerIdentity(uuid=str(_uuid.uuid4()))
|
||||
assert row.ja4h_hashes is None
|
||||
assert row.ja4_quic_hashes is None
|
||||
assert row.http_versions_seen is None
|
||||
|
||||
|
||||
# ── SQLite round-trip ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def repo(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv("DECNET_DB_TYPE", "sqlite")
|
||||
r = get_repository(db_path=str(tmp_path / "fp_col_test.db"))
|
||||
await r.initialize()
|
||||
try:
|
||||
yield r
|
||||
finally:
|
||||
engine = getattr(r, "engine", None)
|
||||
if engine is not None:
|
||||
try:
|
||||
await engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _identity(extra: dict | None = None) -> AttackerIdentity:
|
||||
base = {
|
||||
"uuid": str(_uuid.uuid4()),
|
||||
"schema_version": 1,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
}
|
||||
if extra:
|
||||
base.update(extra)
|
||||
return AttackerIdentity(**base)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ja4h_hashes_round_trip(repo) -> None:
|
||||
value = json.dumps(["GE11nn0000_02_abc_000", "GE20nn0000_04_def_000"])
|
||||
row = _identity({"ja4h_hashes": value})
|
||||
async with repo._session() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
async with repo._session() as session:
|
||||
fetched = await session.get(AttackerIdentity, row.uuid)
|
||||
assert fetched is not None
|
||||
assert fetched.ja4h_hashes == value
|
||||
assert json.loads(fetched.ja4h_hashes) == json.loads(value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ja4_quic_hashes_round_trip(repo) -> None:
|
||||
value = json.dumps(["q13d0310h2_002f_0403_h3"])
|
||||
row = _identity({"ja4_quic_hashes": value})
|
||||
async with repo._session() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
async with repo._session() as session:
|
||||
fetched = await session.get(AttackerIdentity, row.uuid)
|
||||
assert fetched is not None
|
||||
assert fetched.ja4_quic_hashes == value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_versions_seen_round_trip(repo) -> None:
|
||||
value = "h1\nh2\nh3"
|
||||
row = _identity({"http_versions_seen": value})
|
||||
async with repo._session() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
async with repo._session() as session:
|
||||
fetched = await session.get(AttackerIdentity, row.uuid)
|
||||
assert fetched is not None
|
||||
assert fetched.http_versions_seen == value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_columns_nullable_when_not_set(repo) -> None:
|
||||
row = _identity() # no fp columns set
|
||||
async with repo._session() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
async with repo._session() as session:
|
||||
fetched = await session.get(AttackerIdentity, row.uuid)
|
||||
assert fetched is not None
|
||||
assert fetched.ja4h_hashes is None
|
||||
assert fetched.ja4_quic_hashes is None
|
||||
assert fetched.http_versions_seen is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_columns_unaffected(repo) -> None:
|
||||
ja3 = json.dumps(["abc123"])
|
||||
row = _identity({"ja3_hashes": ja3, "ja4h_hashes": json.dumps(["fp1"])})
|
||||
async with repo._session() as session:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
async with repo._session() as session:
|
||||
fetched = await session.get(AttackerIdentity, row.uuid)
|
||||
assert fetched is not None
|
||||
assert fetched.ja3_hashes == ja3
|
||||
assert fetched.ja4h_hashes == json.dumps(["fp1"])
|
||||
assert fetched.ja4_quic_hashes is None
|
||||
Reference in New Issue
Block a user