Files
DECNET/tests/prober/osfp/test_provider.py

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()