Files
DECNET/tests/prober/osfp/test_provider.py
anti 8a430bf725 feat(prober/osfp): P0fV2Provider + factory dispatch
- decnet/prober/osfp/p0f/provider.py: P0fV2Provider loads the four
  vendored .fp files into per-context signature lists (syn / synack /
  rst / stray) and matches via highest-specificity score across the
  relevant list. Also auto-picks up p0f-decnet.fp if present (GPL-3.0
  additions land there later, empty for now).
- decnet/prober/osfp/factory.py: get_provider / get_all_providers /
  reset_cache, mirrors decnet/geoip/factory exactly. Env-dispatched
  via DECNET_OSFP_PROVIDERS (default "p0f-v2"). Reserved names
  "nmap-osdb" (pending Fyodor's grant) and "decnet-observed" (our
  future curated DB) raise NotImplementedError — visible on the
  factory surface so a typo doesn't silently fall through.
- decnet/prober/osfp/__init__.py now re-exports the public API so
  callers use `from decnet.prober.osfp import get_provider` without
  reaching into submodules (upholds the provider-subpackage rule).

15 new provider+factory tests covering:
- All four DB contexts load (262/61/46/6 sigs per inventory).
- Known-good Linux 2.6 SYN + Linux 2.2 SYN-ACK match end-to-end.
- Unknown observations / contexts return None, not raise.
- Factory memoises, env override honoured, unsupported names raise.
- Reserved names raise NotImplementedError (not silent None).

`sniffer_rollup` wiring lands in the next commit.
2026-04-24 11:50:46 -04:00

178 lines
6.3 KiB
Python

"""Integration tests for P0fV2Provider against the vendored .fp data."""
from __future__ import annotations
from pathlib import Path
import pytest
from decnet.prober.osfp import factory, get_all_providers, get_provider
from decnet.prober.osfp.base import OsMatch
from decnet.prober.osfp.p0f.provider import P0fV2Provider
@pytest.fixture(autouse=True)
def _reset_factory_cache():
"""Clean singleton between tests so env overrides take effect."""
factory.reset_cache()
yield
factory.reset_cache()
# ─── Provider-level end-to-end ───────────────────────────────────────────────
def test_provider_loads_all_four_contexts() -> None:
p = P0fV2Provider()
counts = p.signature_counts()
assert counts["syn"] == 262, counts
assert counts["synack"] == 61, counts
assert counts["rst"] == 46, counts
assert counts["stray"] == 6, counts
def test_match_known_linux_26_signature() -> None:
"""Linux 2.6 with window=5840, MSS=1460, wscale=7 is in the
vendored p0f.fp — must resolve to a Linux match."""
p = P0fV2Provider()
obs = {
"window": 5840, "ttl": 64, "df": True, "total_len": 60,
"options_sig": "M1460,S,T,N,W7", "quirks": frozenset(),
"mss": 1460, "wscale": 7, "context": "syn",
}
match = p.match(obs)
assert match is not None
assert match.os == "Linux"
assert match.provider == "p0f-v2"
assert match.confidence > 0.5
def test_match_returns_none_for_unmatchable_observation() -> None:
p = P0fV2Provider()
# Ridiculous values with no corresponding signature.
obs = {
"window": 999999, "ttl": 64, "df": True, "total_len": 9999,
"options_sig": "?255,?254", "quirks": frozenset(),
"mss": 9999, "wscale": 99, "context": "syn",
}
assert p.match(obs) is None
def test_match_unknown_context_returns_none() -> None:
p = P0fV2Provider()
obs = {"window": 5840, "ttl": 64, "df": True, "total_len": 60,
"options_sig": "M1460", "quirks": frozenset(),
"mss": 1460, "context": "impossible"}
assert p.match(obs) is None
def test_match_missing_context_defaults_to_syn() -> None:
p = P0fV2Provider()
obs = {
"window": 5840, "ttl": 64, "df": True, "total_len": 60,
"options_sig": "M1460,S,T,N,W7", "quirks": frozenset(),
"mss": 1460, "wscale": 7,
# no 'context' key
}
match = p.match(obs)
assert match is not None
assert match.os == "Linux"
def test_match_synack_context_uses_p0fa() -> None:
"""Sanity: active-probe SYN-ACK observations resolve against the
61-sig p0fa.fp list, not the 262-sig p0f.fp list.
Targeting "S22:64:1:60:M*,S,T,N,W0:AT:Linux:2.2" from p0fa.fp
(ACK quirk + second-timestamp quirk are characteristic of SYN-ACK
responses, distinguishing these sigs from the plain-SYN DB)."""
p = P0fV2Provider()
obs = {
"window": 22 * 1460, "ttl": 64, "df": True, "total_len": 60,
"options_sig": "M1460,S,T,N,W0",
"quirks": frozenset({"A", "T"}), # ACK-nonzero + T2-nonzero
"mss": 1460, "wscale": 0, "context": "synack",
}
match = p.match(obs)
assert match is not None
assert match.os == "Linux"
def test_match_returns_highest_specificity_not_first() -> None:
"""When multiple signatures can fire, the provider must pick the
most-specific one. Proxy for this: a Linux-style observation that
could be caught by an @generic fallback AND a literal-Linux sig must
resolve to the literal one (higher confidence)."""
p = P0fV2Provider()
obs = {
"window": 5840, "ttl": 64, "df": True, "total_len": 60,
"options_sig": "M1460,S,T,N,W7", "quirks": frozenset(),
"mss": 1460, "wscale": 7, "context": "syn",
}
match = p.match(obs)
# An @generic match would carry is_approximate=True on the underlying
# signature — we can't check that through OsMatch directly, but we can
# check confidence: literal-heavy sigs score notably higher than the
# wildcard-heavy @-fallbacks, so a healthy match is ≥ 0.6.
assert match is not None
assert match.confidence >= 0.6
# ─── Factory dispatch ───────────────────────────────────────────────────────
def test_factory_default_is_p0f_v2() -> None:
p = get_provider()
assert p.name == "p0f-v2"
assert isinstance(p, P0fV2Provider)
def test_factory_is_memoised() -> None:
assert get_provider() is get_provider()
def test_factory_get_all_providers_returns_list() -> None:
providers = get_all_providers()
assert len(providers) >= 1
assert providers[0].name == "p0f-v2"
def test_factory_env_override_chain(monkeypatch: pytest.MonkeyPatch) -> None:
"""Multi-provider chain must preserve declared order."""
monkeypatch.setenv("DECNET_OSFP_PROVIDERS", "p0f-v2")
factory.reset_cache()
providers = get_all_providers()
assert [p.name for p in providers] == ["p0f-v2"]
def test_factory_unsupported_name_raises(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DECNET_OSFP_PROVIDERS", "nonexistent-source")
factory.reset_cache()
with pytest.raises(ValueError):
get_provider()
def test_factory_reserved_names_raise_not_implemented(monkeypatch: pytest.MonkeyPatch) -> None:
"""nmap-osdb and decnet-observed are reserved for future work; the
factory must fail loud rather than silently."""
for reserved in ("nmap-osdb", "decnet-observed"):
monkeypatch.setenv("DECNET_OSFP_PROVIDERS", reserved)
factory.reset_cache()
with pytest.raises(NotImplementedError):
get_provider()
# ─── OsMatch surface ────────────────────────────────────────────────────────
def test_osmatch_str_shows_provider() -> None:
match = OsMatch(os="Linux", flavor="2.6", confidence=0.8, provider="p0f-v2")
s = str(match)
assert "Linux" in s and "2.6" in s and "p0f-v2" in s
def test_osmatch_userland_flag_marks_scanner() -> None:
match = OsMatch(os="nmap", flavor="syn-stealth", confidence=0.9,
provider="p0f-v2", is_userland=True)
assert match.is_userland
assert "userland" in str(match).lower()