178 lines
6.3 KiB
Python
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()
|