netsleuth/tests/test_interpret_l7.py
Andrew Pimenov 99876103f8 verify TLS certificates and fingerprint them for MITM detection
tls_context() now defaults to real certificate verification instead of the
tool's original always-CERT_NONE posture; measure_tls() only falls back to
unverified on ssl.SSLCertVerificationError, retiming the retry since the
failed attempt's duration isn't representative. This is also the only way
to get subject/issuer/notAfter at all - getpeercert() returns {} for a
chain that never validated.

Every TLS result now carries cert_sha256 (SHA-256 of the full DER cert, not
the SPKI - that would need the cryptography package, a dependency this
doesn't need), cert_issuer, cert_days_remaining, and the long-dead
cert_verified field finally gets written. New tls.pinned_fingerprints
config (host -> expected hash, capped and hex-validated) drives three new
findings: a pin mismatch is crit, an unpinned validation failure is warn,
an expiring cert is warn.

Live-tested with --tls against real Cloudflare/Google endpoints - verified
handshake, correct issuer and expiry both times.

Part of plans/zesty-zooming-wilkinson.md (gitignored).
2026-08-12 21:48:36 +03:00

302 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import re
import pytest
from netsleuth.config import Thresholds
from netsleuth.interpret import (
dns_advanced_findings,
dpi_findings,
path_diversity_findings,
prefix_findings,
tls_findings,
)
from netsleuth.models import (
AnycastHop,
DnsAdvanced,
DpiCheckResult,
PathDiversity,
PortProbe,
PrefixBenchmark,
PrefixProbe,
ResolverProbe,
TlsResult,
)
_CYRILLIC = re.compile(r"[а-яА-ЯёЁ]")
def has_cyrillic(text: str) -> bool:
return bool(_CYRILLIC.search(text))
@pytest.fixture()
def thresholds() -> Thresholds:
return Thresholds()
def tls(**kw) -> TlsResult:
base = dict(label="cloudflare", host="cloudflare.com", port=443, tcp_rtt_ms=20.0, tls_handshake_ms=25.0, ttfb_ms=50.0)
base.update(kw)
return TlsResult(**base)
def test_healthy_tls_result_produces_no_findings(thresholds):
assert tls_findings([tls()], thresholds) == []
def test_tls_error_produces_a_labeled_unreachable_finding(thresholds):
findings = tls_findings([tls(tcp_rtt_ms=None, tls_handshake_ms=None, ttfb_ms=None, error="timeout")], thresholds)
assert len(findings) == 1
assert findings[0].id == "tls.unreachable.cloudflare"
assert findings[0].severity == "warn"
assert has_cyrillic(findings[0].title_ru)
assert has_cyrillic(findings[0].detail_ru)
def test_slow_handshake_above_threshold_produces_a_warn_finding(thresholds):
findings = tls_findings([tls(tls_handshake_ms=350.0)], thresholds)
ids = [f.id for f in findings]
assert "tls.handshake_slow.cloudflare" in ids
def test_fast_handshake_below_threshold_produces_no_handshake_finding(thresholds):
findings = tls_findings([tls(tls_handshake_ms=10.0)], thresholds)
ids = [f.id for f in findings]
assert "tls.handshake_slow.cloudflare" not in ids
def test_slow_ttfb_above_threshold_produces_a_finding(thresholds):
findings = tls_findings([tls(ttfb_ms=900.0)], thresholds)
ids = [f.id for f in findings]
assert "tls.ttfb_slow.cloudflare" in ids
def test_server_cpu_bound_fires_when_handshake_dwarfs_tcp_rtt(thresholds):
findings = tls_findings([tls(tcp_rtt_ms=20.0, tls_handshake_ms=80.0)], thresholds)
ids = [f.id for f in findings]
assert "tls.server_cpu_bound.cloudflare" in ids
def test_server_cpu_bound_does_not_fire_on_sub_millisecond_loopback_timings(thresholds):
# ratio is huge (10x) but absolute handshake time is trivial - not a real CPU-bound server.
findings = tls_findings([tls(tcp_rtt_ms=1.0, tls_handshake_ms=10.0)], thresholds)
ids = [f.id for f in findings]
assert "tls.server_cpu_bound.cloudflare" not in ids
def test_two_tls_targets_get_distinct_finding_ids_so_dedupe_keeps_both(thresholds):
findings = tls_findings(
[tls(label="a", host="a.test", tls_handshake_ms=350.0), tls(label="b", host="b.test", tls_handshake_ms=350.0)],
thresholds,
)
ids = {f.id for f in findings if f.id.startswith("tls.handshake_slow")}
assert ids == {"tls.handshake_slow.a", "tls.handshake_slow.b"}
def test_tls_findings_do_not_crash_on_a_result_with_no_measurements(thresholds):
assert tls_findings([tls(tcp_rtt_ms=None, tls_handshake_ms=None, ttfb_ms=None)], thresholds) == []
def test_pin_mismatch_fires_a_crit_finding(thresholds):
findings = tls_findings([tls(pin_verdict="mismatch", cert_sha256="a" * 64)], thresholds)
assert [f.id for f in findings] == ["tls.cert_pin_mismatch.cloudflare"]
assert findings[0].severity == "crit"
def test_unverified_cert_without_a_pin_fires_a_warn_finding(thresholds):
findings = tls_findings([tls(cert_verified=False, cert_issuer="Corp Intercept CA")], thresholds)
assert [f.id for f in findings] == ["tls.cert_unverified.cloudflare"]
assert findings[0].severity == "warn"
assert "Corp Intercept CA" in findings[0].detail
def test_a_verified_unpinned_cert_produces_neither_pin_nor_unverified_findings(thresholds):
findings = tls_findings([tls(cert_verified=True, pin_verdict="unpinned")], thresholds)
ids = [f.id for f in findings]
assert "tls.cert_pin_mismatch.cloudflare" not in ids
assert "tls.cert_unverified.cloudflare" not in ids
def test_expiring_cert_fires_a_warn_finding(thresholds):
findings = tls_findings([tls(cert_days_remaining=5, cert_not_after="Jun 1 12:00:00 2026 GMT")], thresholds)
assert [f.id for f in findings] == ["tls.cert_expiring.cloudflare"]
assert findings[0].severity == "warn"
def test_cert_with_plenty_of_days_left_does_not_fire(thresholds):
findings = tls_findings([tls(cert_days_remaining=120)], thresholds)
assert "tls.cert_expiring.cloudflare" not in [f.id for f in findings]
def prefix_probe(**kw) -> PrefixProbe:
base = dict(prefix="203.0.113.0/24", probe_ip="203.0.113.1", avg_ms=10.0, reachable=True)
base.update(kw)
return PrefixProbe(**base)
def bench(**kw) -> PrefixBenchmark:
base = dict(asn="AS64500", prefixes_announced=10, prefixes_probed=2, method="icmp")
base.update(kw)
return PrefixBenchmark(**base)
def test_empty_prefix_benchmark_produces_no_findings(thresholds):
assert prefix_findings(bench(results=[]), thresholds) == []
def test_high_spread_produces_a_finding(thresholds):
results = [prefix_probe(prefix="a/24", avg_ms=5.0, reachable=True), prefix_probe(prefix="b/24", avg_ms=200.0, reachable=True)]
findings = prefix_findings(bench(results=results, best="a/24", worst="b/24", spread_ms=195.0), thresholds)
ids = [f.id for f in findings]
assert "prefix.spread" in ids
assert findings[[f.id for f in findings].index("prefix.spread")].severity == "crit"
def test_low_spread_produces_no_spread_finding(thresholds):
results = [prefix_probe(prefix="a/24", avg_ms=10.0), prefix_probe(prefix="b/24", avg_ms=15.0)]
findings = prefix_findings(bench(results=results, spread_ms=5.0), thresholds)
assert "prefix.spread" not in [f.id for f in findings]
def test_mostly_unreachable_prefixes_produce_an_info_finding_not_a_warning(thresholds):
results = [prefix_probe(reachable=False, avg_ms=None) for _ in range(8)] + [prefix_probe(reachable=True)] * 2
findings = prefix_findings(bench(results=results), thresholds)
hit = [f for f in findings if f.id == "prefix.mostly_unreachable"]
assert len(hit) == 1
assert hit[0].severity == "info"
def test_mostly_reachable_prefixes_produce_no_unreachable_finding(thresholds):
results = [prefix_probe(reachable=True)] * 10
findings = prefix_findings(bench(results=results, spread_ms=0.0), thresholds)
assert "prefix.mostly_unreachable" not in [f.id for f in findings]
def dpi_result(**kw) -> DpiCheckResult:
base = dict(target="203.0.113.9", resolved_ip="203.0.113.9", consented=True, verdict="clean", rationale="clean", rationale_ru="чисто")
base.update(kw)
return DpiCheckResult(**base)
def test_clean_dpi_verdict_produces_no_findings():
assert dpi_findings(dpi_result(verdict="clean")) == []
def test_reset_injection_verdict_produces_a_crit_finding():
findings = dpi_findings(dpi_result(verdict="reset_injection", rationale="reset seen", rationale_ru="сброс обнаружен"))
assert len(findings) == 1
assert findings[0].id == "dpi.reset_injection"
assert findings[0].severity == "crit"
assert has_cyrillic(findings[0].detail_ru)
def test_partial_filtering_verdict_produces_a_warn_finding():
findings = dpi_findings(dpi_result(verdict="partial_filtering", rationale="mixed", rationale_ru="смешанно"))
assert findings[0].severity == "warn"
assert findings[0].id == "dpi.partial_filtering"
def test_unreachable_verdict_produces_only_an_info_finding_not_an_accusation():
findings = dpi_findings(dpi_result(verdict="unreachable", rationale="or offline", rationale_ru="или выключен"))
assert findings[0].severity == "info"
assert findings[0].id == "dpi.unreachable"
def resolver_probe(**kw) -> ResolverProbe:
base = dict(name="system", kind="system", query_name="example.com", answers=["1.2.3.4"], elapsed_ms=20.0)
base.update(kw)
return ResolverProbe(**base)
def dns_adv(**kw) -> DnsAdvanced:
base = dict(probes=[], note="ok", note_ru="ок")
base.update(kw)
return DnsAdvanced(**base)
def test_no_transparent_proxy_and_normal_timing_produce_no_findings(thresholds):
adv = dns_adv(transparent_proxy=False, system_avg_ms=20.0, doh_avg_ms=25.0)
assert dns_advanced_findings(adv, thresholds) == []
def test_transparent_proxy_detected_produces_a_warn_finding(thresholds):
adv = dns_adv(transparent_proxy=True, transparent_proxy_detail="responded")
findings = dns_advanced_findings(adv, thresholds)
ids = [f.id for f in findings]
assert "dns.transparent_proxy" in ids
assert [f for f in findings if f.id == "dns.transparent_proxy"][0].severity == "warn"
def test_slow_system_resolver_compared_to_doh_produces_a_finding(thresholds):
adv = dns_adv(transparent_proxy=False, system_avg_ms=200.0, doh_avg_ms=20.0)
findings = dns_advanced_findings(adv, thresholds)
assert "dns.system_slow" in [f.id for f in findings]
def test_system_resolver_slower_measurement_but_still_under_threshold_produces_nothing(thresholds):
adv = dns_adv(transparent_proxy=False, system_avg_ms=25.0, doh_avg_ms=20.0)
assert dns_advanced_findings(adv, thresholds) == []
def test_cdn_style_divergence_without_a_suspicious_flag_produces_no_poisoning_finding(thresholds):
adv = dns_adv(divergences=["example.com: system=['1.1.1.1'] vs cloudflare=['104.16.1.1'] (suspicious=False)"])
findings = dns_advanced_findings(adv, thresholds)
assert not any(f.id.startswith("dns.poisoned_answer") for f in findings)
def test_suspicious_divergence_produces_a_crit_poisoning_finding_named_after_the_domain(thresholds):
adv = dns_adv(divergences=["example.com: system=['192.0.2.1'] vs cloudflare=['1.1.1.1'] (suspicious=True)"])
findings = dns_advanced_findings(adv, thresholds)
hit = [f for f in findings if f.id == "dns.poisoned_answer.example.com"]
assert len(hit) == 1
assert hit[0].severity == "crit"
assert has_cyrillic(hit[0].title_ru)
def test_dns_advanced_findings_do_not_crash_on_all_none_fields(thresholds):
assert dns_advanced_findings(dns_adv(), thresholds) == []
def anycast_hop(**kw) -> AnycastHop:
base = dict(target="cloudflare.com", source="cf_ray")
base.update(kw)
return AnycastHop(**base)
def test_no_international_loop_produces_no_loop_finding():
pd = PathDiversity(client_country="RU", hops=[anycast_hop()], international_loop=False)
assert "anycast.international_loop" not in [f.id for f in path_diversity_findings(pd)]
def test_international_loop_produces_a_warn_finding():
pd = PathDiversity(
client_country="RU",
hops=[anycast_hop(edge_country="DE")],
international_loop=True,
detour_countries=["DE"],
note="detour",
note_ru="крюк через Германию",
)
findings = path_diversity_findings(pd)
hit = [f for f in findings if f.id == "anycast.international_loop"]
assert len(hit) == 1
assert hit[0].severity == "warn"
assert has_cyrillic(hit[0].detail_ru)
def test_edge_far_from_client_produces_an_info_finding_named_after_the_target():
pd = PathDiversity(client_country="RU", hops=[anycast_hop(target="slow.test", client_rtt_ms=150.0, edge_rtt_ms=10.0)])
findings = path_diversity_findings(pd)
assert "anycast.edge_far.slow.test" in [f.id for f in findings]
def test_edge_close_to_client_produces_no_edge_far_finding():
pd = PathDiversity(client_country="RU", hops=[anycast_hop(target="fast.test", client_rtt_ms=12.0, edge_rtt_ms=10.0)])
findings = path_diversity_findings(pd)
assert not any(f.id.startswith("anycast.edge_far") for f in findings)
def test_path_diversity_findings_do_not_crash_on_empty_hops():
assert path_diversity_findings(PathDiversity()) == []