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

@@ -151,29 +151,38 @@ class Signature:
def score(self, obs: dict[str, Any]) -> Optional[float]:
"""Return a confidence in [0, 1] on match, or None if any field
rejects the observation."""
rejects the observation.
Soft-field semantics: ``df`` and ``total_len`` are treated as
"skip check when observation is missing" — the sniffer doesn't
currently emit either, and a literal-constraint sig shouldn't
reject a match solely because the observation is upstream-
incomplete. Hard fields (``window``, ``ttl``, ``options_sig``,
``quirks``) still hard-reject on absent or mismatched input —
those are the real discriminators."""
mss = obs.get("mss")
# Window
# Window (hard)
if not self.wss.matches(obs.get("window"), mss):
return None
# TTL — initial-TTL bucket must match exactly. The profiler is
# expected to have rounded the observed TTL up to the nearest
# bucket already via decnet.sniffer.p0f.initial_ttl.
# bucket already via decnet.sniffer.p0f.initial_ttl. (hard)
obs_ttl = obs.get("ttl")
if obs_ttl is None or obs_ttl != self.ttl:
return None
# DF (None on the sig side = wildcard)
# DF (soft — skip when unknown)
if self.df is not None:
obs_df = obs.get("df")
if obs_df is None or bool(obs_df) != self.df:
if obs_df is not None and bool(obs_df) != self.df:
return None
# Total length
if not self.total_len.matches(obs.get("total_len")):
# Total length (soft — skip when unknown)
obs_total = obs.get("total_len")
if obs_total is not None and not self.total_len.matches(obs_total):
return None
# Options
# Options (hard)
if not _options_match(self.options, obs.get("options_sig")):
return None
# Quirks — must match as a set.
# Quirks — must match as a set. (hard)
obs_quirks = obs.get("quirks") or frozenset()
if not isinstance(obs_quirks, frozenset):
obs_quirks = frozenset(obs_quirks)