feat(pr2): HTTP/2+HTTP/3 fingerprint extractors — JA4H, H2 SETTINGS, JA4-QUIC
This commit is contained in:
@@ -40,6 +40,7 @@ KNOWN_SOURCE_KINDS: Final[frozenset[str]] = frozenset({
|
||||
"payload",
|
||||
"session",
|
||||
"http_request",
|
||||
"http_fingerprint",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ def get_tagger() -> Tagger:
|
||||
)
|
||||
from decnet.ttp.impl.credential_lifter import CredentialLifter
|
||||
from decnet.ttp.impl.email_lifter import EmailLifter
|
||||
from decnet.ttp.impl.http_fingerprint_lifter import HttpFingerprintLifter
|
||||
from decnet.ttp.impl.identity_lifter import IdentityLifter
|
||||
from decnet.ttp.impl.intel_lifter import IntelLifter
|
||||
from decnet.ttp.impl.rule_engine import RuleEngineTagger
|
||||
@@ -158,6 +159,7 @@ def get_tagger() -> Tagger:
|
||||
EmailLifter(store),
|
||||
IdentityLifter(store),
|
||||
CredentialLifter(store),
|
||||
HttpFingerprintLifter(store),
|
||||
])
|
||||
raise ValueError(
|
||||
f"Unknown tagger: {name!r}. Known: {_KNOWN}"
|
||||
|
||||
125
decnet/ttp/impl/http_fingerprint_lifter.py
Normal file
125
decnet/ttp/impl/http_fingerprint_lifter.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""HTTP fingerprint lifter — JA4H / H2-settings / H3-settings / JA4-QUIC tagger.
|
||||
|
||||
Reads ``http_fingerprint`` source-kind events and emits Reconnaissance
|
||||
techniques when fingerprint patterns match known scanner or attacker-tooling
|
||||
profiles.
|
||||
|
||||
Covered techniques:
|
||||
* T1592.002 — Gather Victim Host Information: Software (scanner-JA4H match)
|
||||
* T1046 — Network Service Discovery (h2/h3 protocol probing)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Final
|
||||
|
||||
from decnet.ttp.base import TaggerEvent, TolerantTagger
|
||||
from decnet.ttp.impl._emit import emit_tags
|
||||
from decnet.ttp.impl._rule_index import RuleIndex
|
||||
from decnet.ttp.impl._state import is_active
|
||||
from decnet.ttp.store.base import RuleStore
|
||||
from decnet.web.db.models.ttp import TTPTag
|
||||
|
||||
|
||||
Predicate = Callable[
|
||||
[dict[str, Any], dict[str, Any]],
|
||||
"dict[str, Any] | None",
|
||||
]
|
||||
|
||||
# Known scanner JA4H prefixes. The method+version+cookie+referer component
|
||||
# (first segment before the first underscore) is stable across tool versions
|
||||
# while the header hash varies with User-Agent spoofing. Matching on the
|
||||
# prefix catches deliberate UA spoofing that forgets to shuffle header order.
|
||||
_SCANNER_JA4H_PREFIXES: Final[frozenset[str]] = frozenset({
|
||||
"GE11nn0000", # curl default (no cookie, no referer, no lang)
|
||||
"GE20nn0000", # curl --http2
|
||||
"GE30nn0000", # curl --http3
|
||||
"GE11nn0000", # wget
|
||||
"GE11nn0000", # python-requests (no lang header)
|
||||
})
|
||||
|
||||
# h2/h3 probing without a browser User-Agent is a service-discovery tell.
|
||||
_H2_PROBE_PROTOCOLS: Final[frozenset[str]] = frozenset({"h2", "h2c", "h3"})
|
||||
|
||||
|
||||
def _p_scanner_ja4h(
|
||||
spec: dict[str, Any], payload: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
ja4h: str = payload.get("ja4h", "")
|
||||
if not ja4h:
|
||||
return None
|
||||
prefix = ja4h.split("_")[0] if "_" in ja4h else ja4h[:10]
|
||||
catalogues_raw = spec.get("catalogues", list(_SCANNER_JA4H_PREFIXES))
|
||||
catalogues = set(catalogues_raw) if isinstance(catalogues_raw, list) else _SCANNER_JA4H_PREFIXES
|
||||
if prefix not in catalogues:
|
||||
return None
|
||||
return {
|
||||
"kind": "ja4h",
|
||||
"hash": ja4h,
|
||||
"protocol": payload.get("protocol", "h1"),
|
||||
"client_ip": payload.get("client_ip", ""),
|
||||
"seen_at": payload.get("seen_at", ""),
|
||||
"raw": None,
|
||||
}
|
||||
|
||||
|
||||
def _p_h2_h3_probe(
|
||||
_spec: dict[str, Any], payload: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
fp_type: str = payload.get("fingerprint_type", "")
|
||||
if fp_type not in ("http2_settings", "http3_settings"):
|
||||
return None
|
||||
protocol = "h2" if fp_type == "http2_settings" else "h3"
|
||||
return {
|
||||
"kind": fp_type,
|
||||
"hash": "",
|
||||
"protocol": protocol,
|
||||
"client_ip": payload.get("client_ip", ""),
|
||||
"seen_at": payload.get("seen_at", ""),
|
||||
"raw": payload.get("settings"),
|
||||
}
|
||||
|
||||
|
||||
def _p_quic_probe(
|
||||
_spec: dict[str, Any], payload: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
ja4q: str = payload.get("ja4_quic", "")
|
||||
if not ja4q:
|
||||
return None
|
||||
return {
|
||||
"kind": "ja4_quic",
|
||||
"hash": ja4q,
|
||||
"protocol": "h3",
|
||||
"client_ip": payload.get("client_ip", ""),
|
||||
"seen_at": payload.get("seen_at", ""),
|
||||
"raw": None,
|
||||
}
|
||||
|
||||
|
||||
_PREDICATES: Final[dict[str, Predicate]] = {
|
||||
"HFP-0001": _p_scanner_ja4h,
|
||||
"HFP-0002": _p_h2_h3_probe,
|
||||
"HFP-0003": _p_quic_probe,
|
||||
}
|
||||
|
||||
|
||||
class HttpFingerprintLifter(TolerantTagger):
|
||||
"""Tags HTTP-layer fingerprint events with MITRE ATT&CK techniques."""
|
||||
|
||||
HANDLES: frozenset[str] = frozenset({"http_fingerprint"})
|
||||
|
||||
def __init__(self, store: RuleStore) -> None:
|
||||
self._index = RuleIndex()
|
||||
|
||||
async def _tag_impl(self, event: TaggerEvent) -> list[TTPTag]:
|
||||
payload = event.payload if isinstance(event.payload, dict) else {}
|
||||
tags: list[TTPTag] = []
|
||||
for rule_id, predicate in _PREDICATES.items():
|
||||
rule = self._index.get(rule_id)
|
||||
if rule is None or not is_active(rule.state):
|
||||
continue
|
||||
evidence = predicate(rule.match_spec, payload)
|
||||
if evidence is None:
|
||||
continue
|
||||
tags.extend(emit_tags(rule, event, evidence))
|
||||
return tags
|
||||
Reference in New Issue
Block a user