- test_evidence_shape.py: replace broken (command, BehavioralLifter)
pairing with correct (http_fingerprint, HttpFingerprintLifter) case;
expand _LIFTER_CASES to 5-tuples with per-lifter payloads and rule
factories; wire StubRuleStore + _index.install() per lifter; remove
xfail marker — all 4 parametrized cases now pass
- factory.py: add _span() helper gated on _telemetry._ENABLED; wrap
each per-lifter dispatch in _tag_one() that opens a
ttp.lifter.{name} child span per call
- http_fingerprint_lifter.py: add missing name = "http_fingerprint"
- test_tracing.py: replace pytest.fail() stubs in
test_lifter_child_spans_emitted and test_no_pii_canary_in_span_attributes
with real test bodies; remove xfail markers
127 lines
4.2 KiB
Python
127 lines
4.2 KiB
Python
"""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."""
|
|
|
|
name = "http_fingerprint"
|
|
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
|