feat(profiler): wire p0f-v2 matcher into sniffer_rollup priority chain

The ~30-signature hand-rolled p0f-lite table in decnet/sniffer/p0f.py
misses most real-world attackers (yesterday's SLOW SCAN being a
textbook case — 9 hours of events, 19 hits, os_guess = NULL). The
375-sig vendored p0f v2 DB was already there; this commit actually
calls it.

New resolution chain in sniffer_rollup:

  1. Enabled OS-fingerprint providers (p0f-v2 default, via
     DECNET_OSFP_PROVIDERS) tried in declared order. Provider with
     highest-confidence match across all enabled sources wins.
  2. Modal os_guess label from the sniffer's hand-rolled p0f.py.
     Kept as fallback because v2's DB predates post-2006 kernels.
  3. TTL bucket (linux / windows / embedded). Coarse but never wrong.

Wiring details:

- _match_via_osfp_providers: never raises — factory / provider
  failures collapse to None and the chain falls through to the
  old modal-label / TTL path. A corrupt .fp file or misconfigured
  DECNET_OSFP_PROVIDERS must never wedge a profile rebuild.
- tcp_fp_context tracks whether the LATEST tcp_fp snapshot came
  from a passive SYN ('syn' → p0f.fp) or an active prober probe
  ('synack' → p0fa.fp). Routes to the right sig list.
- initial-TTL normalisation via decnet.sniffer.p0f.initial_ttl.
  Observation's TTL may be N hops below the OS's initial; v2
  signatures match on the canonical bucket.

Soft-field semantics on Signature.score(): df and total_len are now
skip-checked when the observation is missing them. Sniffer doesn't
currently emit either SD field; a literal-constraint sig
shouldn't hard-reject a match solely because of upstream
incompleteness. Hard fields (window, ttl, options_sig, quirks)
still hard-reject on absent/mismatched input — those are the real
discriminators. Promote df / total_len back to hard the moment the
sniffer starts emitting them.

+2 integration tests on TestSnifferRollup, +2 soft-field tests on
test_signature. Full regression: 166 tests across tests/prober/osfp
+ tests/profiler all green.
This commit is contained in:
2026-04-24 11:56:50 -04:00
parent 8a430bf725
commit ec1079e78b
4 changed files with 172 additions and 17 deletions

View File

@@ -510,6 +510,50 @@ class TestSnifferRollup:
r = sniffer_rollup(events)
assert r["ssh_client_banners"] == []
# ─── p0f v2 provider wiring (DEBT — unblocks SLOW SCAN attackers) ─────
def test_p0f_v2_provider_beats_ttl_fallback(self):
"""When the sniffer emits os_guess='unknown' (hand-rolled table
didn't match) but the TCP quirks DO match a vendored p0f v2
signature, the new priority chain must promote the richer
v2 match above the coarse TTL bucket.
Target: Linux 2.6 sig with window=5840, ttl=64, options
M1460,S,T,N,W7 — 262-sig p0f.fp has this explicitly."""
events = [
_mk(0, event_type="tcp_syn_fingerprint",
fields={
"os_guess": "unknown", # hand-rolled had no match
"ttl": "64",
"window": "5840",
"mss": "1460",
"wscale": "7",
"options_sig": "M1460,S,T,N,W7",
}),
]
r = sniffer_rollup(events)
# Old chain would collapse to the "linux" TTL bucket. New chain
# must surface the Linux 2.6-specific match from p0f v2.
assert r["os_guess"] is not None
assert r["os_guess"].startswith("Linux")
assert r["os_guess"] != "linux", (
"resolved to the coarse TTL-bucket fallback; p0f-v2 match "
f"should have taken priority. Got: {r['os_guess']!r}"
)
def test_p0f_v2_match_falls_back_when_no_tcp_fp(self):
"""If the event has no window / mss / options_sig (e.g. a
non-fingerprint event or a malformed sniffer row), p0f-v2 must
return None and the chain must still resolve to the modal
label / TTL fallback the old code used."""
events = [
_mk(0, event_type="tcp_syn_fingerprint",
fields={"os_guess": "linux", "ttl": "64"}),
]
r = sniffer_rollup(events)
# Modal os_guess path: the label "linux" still wins.
assert r["os_guess"] == "linux"
# ─── build_behavior_record (composite) ──────────────────────────────────────