merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/prober/osfp/__init__.py
Normal file
0
tests/prober/osfp/__init__.py
Normal file
152
tests/prober/osfp/test_format.py
Normal file
152
tests/prober/osfp/test_format.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Tests for the p0f v2 .fp parser (decnet/prober/osfp/p0f/format.py)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.prober.osfp.p0f.format import P0fParseError, _parse_line, parse_p0f_v2
|
||||
|
||||
|
||||
# ─── Line-parser unit tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_line_minimal_literal() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:2.6.x kernel")
|
||||
assert sig.os == "Linux"
|
||||
assert sig.flavor == "2.6.x kernel"
|
||||
assert sig.ttl == 64
|
||||
assert sig.df is True
|
||||
assert sig.wss.kind == "literal" and sig.wss.value == 5840
|
||||
assert sig.total_len.kind == "literal" and sig.total_len.value == 60
|
||||
assert len(sig.options) == 5
|
||||
# First option: MSS=1460
|
||||
mss_opt = sig.options[0]
|
||||
assert mss_opt.kind == "M"
|
||||
assert mss_opt.value is not None and mss_opt.value.value == 1460
|
||||
assert sig.quirks == frozenset()
|
||||
assert not sig.is_userland
|
||||
|
||||
|
||||
def test_parse_line_wildcard_window() -> None:
|
||||
sig = _parse_line("*:128:1:*:M*,S,T,N,W*:.:Windows:XP SP1+")
|
||||
assert sig.wss.kind == "any"
|
||||
assert sig.total_len.kind == "any"
|
||||
assert sig.options[0].kind == "M"
|
||||
assert sig.options[0].value is not None and sig.options[0].value.kind == "any"
|
||||
|
||||
|
||||
def test_parse_line_mss_multiple_window() -> None:
|
||||
sig = _parse_line("S4:64:1:60:M*,S,T,N,W*:.:Linux:generic")
|
||||
assert sig.wss.kind == "mss_mul" and sig.wss.value == 4
|
||||
|
||||
|
||||
def test_parse_line_mtu_multiple_window() -> None:
|
||||
sig = _parse_line("T3:64:1:60:M*,S,T,N,W*:.:Solaris:10")
|
||||
assert sig.wss.kind == "mtu_mul" and sig.wss.value == 3
|
||||
|
||||
|
||||
def test_parse_line_modulo_window() -> None:
|
||||
sig = _parse_line("%8192:64:1:60:M*,S,T,N,W*:.:Linux:probe")
|
||||
assert sig.wss.kind == "mod" and sig.wss.value == 8192
|
||||
|
||||
|
||||
def test_parse_line_userland_prefix() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M*,S,T,N,W*:.:-nmap:syn stealth")
|
||||
assert sig.is_userland is True
|
||||
assert sig.os == "nmap"
|
||||
|
||||
|
||||
def test_parse_line_combined_prefixes() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M*:.:-@Windows:fuzzy match")
|
||||
assert sig.is_userland is True
|
||||
assert sig.is_approximate is True
|
||||
assert sig.os == "Windows"
|
||||
|
||||
|
||||
def test_parse_line_quirks_non_empty() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M*,S,T,N,W*:PZ:Linux:with quirks")
|
||||
assert sig.quirks == frozenset({"P", "Z"})
|
||||
|
||||
|
||||
def test_parse_line_no_options_sentinel() -> None:
|
||||
sig = _parse_line("5840:64:1:60:.:.:Linux:barebones")
|
||||
assert len(sig.options) == 1
|
||||
assert sig.options[0].kind == "."
|
||||
|
||||
|
||||
def test_parse_line_t0_timestamp_distinct_from_t() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M*,T0:.:Linux:broken timestamps")
|
||||
assert sig.options[1].kind == "T0"
|
||||
|
||||
|
||||
def test_parse_line_unknown_option_number() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M*,?47:.:Weird:stack")
|
||||
unknown = sig.options[1]
|
||||
assert unknown.kind == "?"
|
||||
assert unknown.value is not None and unknown.value.value == 47
|
||||
|
||||
|
||||
def test_parse_line_rejects_too_few_fields() -> None:
|
||||
with pytest.raises(P0fParseError):
|
||||
_parse_line("5840:64:1:60")
|
||||
|
||||
|
||||
def test_parse_line_rejects_bad_df() -> None:
|
||||
with pytest.raises(P0fParseError):
|
||||
_parse_line("5840:64:X:60:M*:.:Linux:bad")
|
||||
|
||||
|
||||
def test_parse_line_rejects_bad_window_token() -> None:
|
||||
with pytest.raises(P0fParseError):
|
||||
_parse_line("Kfoo:64:1:60:M*:.:Linux:bad")
|
||||
|
||||
|
||||
def test_parse_line_rejects_malformed_option() -> None:
|
||||
with pytest.raises(P0fParseError):
|
||||
_parse_line("5840:64:1:60:!!!wat:.:Linux:bad")
|
||||
|
||||
|
||||
# ─── File-level tests ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_file_skips_comments_blanks_bad_lines(tmp_path: Path) -> None:
|
||||
fp = tmp_path / "test.fp"
|
||||
fp.write_text(
|
||||
"# comment\n"
|
||||
"\n"
|
||||
"5840:64:1:60:M1460,S,T,N,W7:.:Linux:2.6.x\n"
|
||||
"# another comment\n"
|
||||
"garbage line that should skip\n"
|
||||
"8192:128:1:48:M1460,N,W0,N,N,S:.:Windows:XP\n"
|
||||
)
|
||||
sigs = parse_p0f_v2(fp)
|
||||
assert len(sigs) == 2
|
||||
assert {s.os for s in sigs} == {"Linux", "Windows"}
|
||||
|
||||
|
||||
def test_parse_vendored_syn_db_fully_loads() -> None:
|
||||
"""The full vendored p0f.fp MUST parse without losing signatures.
|
||||
Upstream inventory: 262 SYN signatures. A regression that drops rows
|
||||
would silently degrade OS-fingerprint coverage."""
|
||||
data = Path(__file__).resolve().parents[3] / "decnet/prober/osfp/p0f/data/p0f.fp"
|
||||
sigs = parse_p0f_v2(data)
|
||||
assert len(sigs) == 262, f"expected 262 SYN sigs, parser returned {len(sigs)}"
|
||||
|
||||
|
||||
def test_parse_vendored_all_four_dbs_fully_load() -> None:
|
||||
"""Same invariant across all four vendored databases."""
|
||||
base = Path(__file__).resolve().parents[3] / "decnet/prober/osfp/p0f/data"
|
||||
expected = {"p0f.fp": 262, "p0fa.fp": 61, "p0fr.fp": 46, "p0fo.fp": 6}
|
||||
for name, want in expected.items():
|
||||
sigs = parse_p0f_v2(base / name)
|
||||
assert len(sigs) == want, f"{name}: expected {want}, got {len(sigs)}"
|
||||
|
||||
|
||||
def test_parse_vendored_specificity_in_range() -> None:
|
||||
"""Every signature's computed specificity must land in [0, 1]."""
|
||||
data = Path(__file__).resolve().parents[3] / "decnet/prober/osfp/p0f/data/p0f.fp"
|
||||
for sig in parse_p0f_v2(data):
|
||||
assert 0.0 <= sig.specificity <= 1.0, (
|
||||
f"{sig.os}/{sig.flavor}: specificity out of range ({sig.specificity})"
|
||||
)
|
||||
177
tests/prober/osfp/test_provider.py
Normal file
177
tests/prober/osfp/test_provider.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""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()
|
||||
140
tests/prober/osfp/test_signature.py
Normal file
140
tests/prober/osfp/test_signature.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Tests for signature matching + scoring."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.prober.osfp.p0f.format import _parse_line
|
||||
|
||||
|
||||
def _obs(**overrides):
|
||||
"""Baseline observation (Linux 2.6 on Ethernet), overridable."""
|
||||
base = {
|
||||
"window": 5840,
|
||||
"ttl": 64,
|
||||
"df": True,
|
||||
"total_len": 60,
|
||||
"options_sig": "M1460,S,T,N,W7",
|
||||
"quirks": frozenset(),
|
||||
"mss": 1460,
|
||||
"wscale": 7,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ─── Match / no-match ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_score_exact_match_is_high() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:2.6.x literal")
|
||||
score = sig.score(_obs())
|
||||
assert score is not None
|
||||
assert score >= 0.9, f"literal-fields signature should score high, got {score}"
|
||||
|
||||
|
||||
def test_score_wildcard_match_is_lower_than_literal() -> None:
|
||||
literal = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:literal")
|
||||
wildcard = _parse_line("*:64:1:*:M*,S,T,N,W*:.:Linux:wildcard")
|
||||
obs = _obs()
|
||||
ls = literal.score(obs)
|
||||
ws = wildcard.score(obs)
|
||||
assert ls is not None and ws is not None
|
||||
assert ls > ws, f"literal ({ls}) should outscore wildcard ({ws})"
|
||||
|
||||
|
||||
def test_score_window_mismatch_returns_none() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:fixed")
|
||||
assert sig.score(_obs(window=64240)) is None
|
||||
|
||||
|
||||
def test_score_ttl_mismatch_returns_none() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:ttl64")
|
||||
assert sig.score(_obs(ttl=128)) is None
|
||||
|
||||
|
||||
def test_score_df_mismatch_returns_none() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:df-required")
|
||||
assert sig.score(_obs(df=False)) is None
|
||||
|
||||
|
||||
def test_score_df_wildcard_on_signature_matches_either() -> None:
|
||||
sig = _parse_line("5840:64:*:60:M1460,S,T,N,W7:.:Linux:any-df")
|
||||
assert sig.score(_obs(df=True)) is not None
|
||||
assert sig.score(_obs(df=False)) is not None
|
||||
|
||||
|
||||
def test_score_df_none_on_observation_is_soft_skip() -> None:
|
||||
"""When the observation lacks df (sniffer doesn't emit it today),
|
||||
a signature with a specific df constraint must still match rather
|
||||
than hard-reject. Rationale in the score() docstring."""
|
||||
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:df-required")
|
||||
assert sig.score(_obs(df=None)) is not None
|
||||
|
||||
|
||||
def test_score_total_len_none_on_observation_is_soft_skip() -> None:
|
||||
"""Same soft-field semantics for total_len — the profiler adapter
|
||||
passes None when the sniffer / prober didn't capture it."""
|
||||
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:len-specific")
|
||||
assert sig.score(_obs(total_len=None)) is not None
|
||||
|
||||
|
||||
def test_score_options_order_mismatch_returns_none() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:ordered")
|
||||
# Same tokens, different order — must NOT match.
|
||||
assert sig.score(_obs(options_sig="S,T,M1460,N,W7")) is None
|
||||
|
||||
|
||||
def test_score_options_missing_token_returns_none() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:5opts")
|
||||
assert sig.score(_obs(options_sig="M1460,S,T,N")) is None
|
||||
|
||||
|
||||
def test_score_quirks_must_match_as_set() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M*,S,T,N,W*:PZ:Linux:with PZ")
|
||||
assert sig.score(_obs(quirks=frozenset({"P", "Z"}))) is not None
|
||||
assert sig.score(_obs(quirks=frozenset({"P"}))) is None # missing Z
|
||||
assert sig.score(_obs(quirks=frozenset({"P", "Z", "I"}))) is None # extra I
|
||||
|
||||
|
||||
def test_score_mss_multiple_window() -> None:
|
||||
# S4 = 4 * MSS. With MSS=1460 → window=5840.
|
||||
sig = _parse_line("S4:64:1:60:M1460,S,T,N,W7:.:Linux:S4")
|
||||
assert sig.score(_obs(window=5840, mss=1460)) is not None
|
||||
# With MSS=536 → S4 expects window=2144
|
||||
assert sig.score(_obs(window=2144, mss=536)) is not None
|
||||
assert sig.score(_obs(window=5840, mss=536)) is None
|
||||
|
||||
|
||||
def test_score_modulo_window() -> None:
|
||||
sig = _parse_line("%8192:64:1:60:M1460,S,T,N,W7:.:Linux:mod8192")
|
||||
assert sig.score(_obs(window=32768)) is not None
|
||||
assert sig.score(_obs(window=40960)) is not None
|
||||
assert sig.score(_obs(window=32769)) is None
|
||||
|
||||
|
||||
def test_score_no_options_sentinel() -> None:
|
||||
sig = _parse_line("5840:64:1:60:.:.:Linux:no-opts")
|
||||
assert sig.score(_obs(options_sig="")) is not None
|
||||
assert sig.score(_obs(options_sig=None)) is not None
|
||||
assert sig.score(_obs(options_sig="M1460")) is None
|
||||
|
||||
|
||||
def test_score_missing_observation_fields_returns_none() -> None:
|
||||
"""A signature that requires a specific window can't match when the
|
||||
observation has no window. This is the safety invariant —
|
||||
sniffer_rollup may call score() with partial data."""
|
||||
sig = _parse_line("5840:64:1:60:M1460,S,T,N,W7:.:Linux:strict")
|
||||
assert sig.score(_obs(window=None)) is None
|
||||
assert sig.score(_obs(ttl=None)) is None
|
||||
|
||||
|
||||
def test_score_option_value_wildcard_matches_any_literal() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M*,S,T,N,W*:.:Linux:wild-mss-wscale")
|
||||
assert sig.score(_obs(options_sig="M1460,S,T,N,W7")) is not None
|
||||
assert sig.score(_obs(options_sig="M536,S,T,N,W2")) is not None
|
||||
|
||||
|
||||
def test_score_option_value_modulo() -> None:
|
||||
sig = _parse_line("5840:64:1:60:M%4,S,T,N,W7:.:Linux:mss-mod-4")
|
||||
assert sig.score(_obs(options_sig="M1460,S,T,N,W7")) is not None # 1460 % 4 == 0
|
||||
assert sig.score(_obs(options_sig="M1461,S,T,N,W7")) is None
|
||||
Reference in New Issue
Block a user