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).
This commit is contained in:
Andrew Pimenov 2026-08-12 21:48:36 +03:00
parent 84c3dffbce
commit 99876103f8
14 changed files with 301 additions and 15 deletions

View file

@ -141,6 +141,10 @@ The AS prefix benchmark (`probes/prefix_benchmark.py`) gets the same treatment f
**Speedtest server geo-sanity compares countries, not coordinates.** The original design called for a haversine distance in km between the client and the speedtest server, which needs both ends' lat/lon. Live inspection of the bundled Ookla CLI's own JSON output (`speedtest.exe --format=json`) showed its `server` object carries `location` (a city name) and `country`, never `lat`/`lon` — that's the documented schema, not a version quirk. `speed_findings()` compares `SpeedResult.server_country` against the client's own geolocated country instead: coarser than a distance, but real, always available when a tier reports a country, and it still catches the case that matters (the test hit a server in another country entirely). **Speedtest server geo-sanity compares countries, not coordinates.** The original design called for a haversine distance in km between the client and the speedtest server, which needs both ends' lat/lon. Live inspection of the bundled Ookla CLI's own JSON output (`speedtest.exe --format=json`) showed its `server` object carries `location` (a city name) and `country`, never `lat`/`lon` — that's the documented schema, not a version quirk. `speed_findings()` compares `SpeedResult.server_country` against the client's own geolocated country instead: coarser than a distance, but real, always available when a tier reports a country, and it still catches the case that matters (the test hit a server in another country entirely).
**TLS certificate handshake tries verified first, only degrades to `CERT_NONE` on a validation failure.** `probes/tls_rtt.py::tls_context(verify: bool)` defaults to a real, unmodified `create_default_context()` — a change from the tool's original always-`CERT_NONE` posture. `measure_tls()` attempts a verified connection first; only on `ssl.SSLCertVerificationError` does it retry unverified (re-timing the retry, since the failed attempt's duration isn't representative of a real connection). This is the only way to get `subject`/`issuer`/`notAfter` at all: Python's `getpeercert()` returns `{}` for an unvalidated chain, populating the parsed dict only when the certificate actually verified — `getpeercert(binary_form=True)` (the raw DER, hashed into `cert_sha256`) is the one thing available either way. The fingerprint is the SHA-256 of the **full DER certificate, not the SPKI** — extracting just the subject-public-key-info needs the `cryptography` package, a new dependency this feature doesn't need; the report footnote tells a user comparing against an externally-pinned value to compute it the same way (`openssl x509 -outform DER | sha256sum`).
**Pinning is configured, not flagged.** `tls.pinned_fingerprints` (host → expected SHA-256, capped at 32 entries, each normalized and hex-validated by a pydantic validator) follows the same posture as `dpi_check.ports`: raising the list requires editing and redeploying the config, not a `--pin` CLI argument on a diagnostic tool that anyone could point at anything.
**Python 3.14 is the floor, not 3.10.** The project moved off `>=3.10` when this batch of probes was added; none of the new code actually depends on a 3.11+-only construct (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — the two-sequential-connection TLS design above was chosen independently of what the interpreter allows. The version floor moved because there was no longer a reason to hold it back, not because a specific new feature required it. **Python 3.14 is the floor, not 3.10.** The project moved off `>=3.10` when this batch of probes was added; none of the new code actually depends on a 3.11+-only construct (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — the two-sequential-connection TLS design above was chosen independently of what the interpreter allows. The version floor moved because there was no longer a reason to hold it back, not because a specific new feature required it.
## Storage ## Storage
@ -159,4 +163,4 @@ Five sections back the L4-L7 probes, all opt-in at the CLI level and all with a
## Tests ## Tests
`uv run pytest -q`. Roughly 758 tests covering the parsers, normalizers, verdict engine, DNSBL decoding, throughput math, ping statistics, jitter percentiles, serialization and the report diff. Glue is deliberately untested: live HTTP, real ICMP, Typer wiring, `psutil`/`ctypes` calls and the `--watch` timing loop. Traceroute fixtures live in `tests/fixtures/traceroute/{windows,linux,darwin}/` and include a cp866-encoded Russian Windows sample, because that encoding is exactly what breaks naive parsers. DoH response fixtures (RFC 8484 JSON form) live in `tests/fixtures/api/doh_*.json` alongside the other provider fixtures. `uv run pytest -q`. Roughly 777 tests covering the parsers, normalizers, verdict engine, DNSBL decoding, throughput math, ping statistics, jitter percentiles, serialization and the report diff. Glue is deliberately untested: live HTTP, real ICMP, Typer wiring, `psutil`/`ctypes` calls and the `--watch` timing loop. Traceroute fixtures live in `tests/fixtures/traceroute/{windows,linux,darwin}/` and include a cp866-encoded Russian Windows sample, because that encoding is exactly what breaks naive parsers. DoH response fixtures (RFC 8484 JSON form) live in `tests/fixtures/api/doh_*.json` alongside the other provider fixtures.

View file

@ -141,6 +141,10 @@ src/netsleuth/
**Geo-sanity сервера speedtest сравнивает страны, а не координаты.** Изначальный дизайн предполагал расстояние по haversine в км между клиентом и сервером speedtest, для чего нужны lat/lon обеих сторон. Живая проверка собственного JSON-вывода бандл-бинарника Ookla CLI (`speedtest.exe --format=json`) показала, что его объект `server` несёт `location` (название города) и `country`, но никогда `lat`/`lon` — это документированная схема, а не особенность версии. `speed_findings()` вместо этого сравнивает `SpeedResult.server_country` с собственной геолоцированной страной клиента: грубее расстояния, но реально, всегда доступно, когда уровень каскада сообщает страну, и всё равно ловит именно тот случай, что важен — тест попал на сервер совсем в другой стране. **Geo-sanity сервера speedtest сравнивает страны, а не координаты.** Изначальный дизайн предполагал расстояние по haversine в км между клиентом и сервером speedtest, для чего нужны lat/lon обеих сторон. Живая проверка собственного JSON-вывода бандл-бинарника Ookla CLI (`speedtest.exe --format=json`) показала, что его объект `server` несёт `location` (название города) и `country`, но никогда `lat`/`lon` — это документированная схема, а не особенность версии. `speed_findings()` вместо этого сравнивает `SpeedResult.server_country` с собственной геолоцированной страной клиента: грубее расстояния, но реально, всегда доступно, когда уровень каскада сообщает страну, и всё равно ловит именно тот случай, что важен — тест попал на сервер совсем в другой стране.
**TLS-рукопожатие сертификата сначала пробует проверенное соединение, деградирует до `CERT_NONE` только при ошибке валидации.** `probes/tls_rtt.py::tls_context(verify: bool)` по умолчанию — настоящий, немодифицированный `create_default_context()`, что меняет изначальную позицию инструмента «всегда `CERT_NONE`». `measure_tls()` сначала пробует проверенное соединение; только при `ssl.SSLCertVerificationError` повторяет без проверки (перезамеряя время повторной попытки, поскольку длительность неудачной попытки не отражает реальное соединение). Это единственный способ вообще получить `subject`/`issuer`/`notAfter`: `getpeercert()` в Python возвращает `{}` для непровалидированной цепочки, заполняя разобранный словарь, только если сертификат действительно прошёл проверку — `getpeercert(binary_form=True)` (сырой DER, хешируемый в `cert_sha256`) доступен в любом случае. Отпечаток — это SHA-256 **полного DER-сертификата, не SPKI** — извлечение только subject-public-key-info требует пакет `cryptography`, новую зависимость, которая этой фиче не нужна; сноска в отчёте говорит пользователю, сравнивающему с внешне закреплённым значением, считать его так же (`openssl x509 -outform DER | sha256sum`).
**Пиннинг конфигурируется, а не флагуется.** `tls.pinned_fingerprints` (хост → ожидаемый SHA-256, capped на 32 записи, каждая нормализована и провалидирована как hex pydantic-валидатором) следует той же позиции, что `dpi_check.ports`: поднять список можно только правкой и передеплоем конфига, а не аргументом `--pin` в диагностическом инструменте, который кто угодно может направить куда угодно.
**Python 3.14 — минимальная версия, не 3.10.** Проект ушёл с `>=3.10` одновременно с этим набором проб; ни один новый код на деле не зависит от конструкций только для 3.11+ (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — схема TLS с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции. **Python 3.14 — минимальная версия, не 3.10.** Проект ушёл с `>=3.10` одновременно с этим набором проб; ни один новый код на деле не зависит от конструкций только для 3.11+ (`ssl.SSLObject.start_tls()`, `asyncio.timeout()`, `datetime.UTC`) — схема TLS с двумя последовательными соединениями выше выбрана независимо от того, что позволяет интерпретатор. Планка версии сдвинулась потому, что не осталось причин её удерживать, а не потому, что это потребовалось для конкретной новой функции.
## Хранение ## Хранение
@ -159,4 +163,4 @@ src/netsleuth/
## Тесты ## Тесты
`uv run pytest -q`. Около 758 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров. `uv run pytest -q`. Около 777 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.

View file

@ -65,7 +65,7 @@ None of this is required — netsleuth degrades gracefully without any of it —
- **Reputation** — locally cached FireHOL blocklists (no query leaves your machine), Shodan InternetDB exposure, and optional classic DNSBL zones. - **Reputation** — locally cached FireHOL blocklists (no query leaves your machine), Shodan InternetDB exposure, and optional classic DNSBL zones.
- **Latency & path** — per-host ping statistics with real jitter/loss/mdev/p95/p99, and a traceroute that never needs administrator rights. The first hop is diagnosed separately from the rest of the route, so loss or slowness there is correctly attributed to your own router/Wi-Fi rather than the ISP. Every hop is annotated with its ASN, AS name and country, and flags a route that detours through a third country on the way to the destination. - **Latency & path** — per-host ping statistics with real jitter/loss/mdev/p95/p99, and a traceroute that never needs administrator rights. The first hop is diagnosed separately from the rest of the route, so loss or slowness there is correctly attributed to your own router/Wi-Fi rather than the ISP. Every hop is annotated with its ASN, AS name and country, and flags a route that detours through a third country on the way to the destination.
- **Bandwidth** — a cascading speedtest (Ookla binary → Cloudflare → fast.com → optional M-Lab NDT7) with separate download and upload bufferbloat grades, and a flag when the test server landed in a different country than you. - **Bandwidth** — a cascading speedtest (Ookla binary → Cloudflare → fast.com → optional M-Lab NDT7) with separate download and upload bufferbloat grades, and a flag when the test server landed in a different country than you.
- **TLS handshake RTT** (`--tls`) — TCP RTT, TLS 1.3 handshake time and time-to-first-byte to reference services, flagging servers whose handshake is disproportionately slow relative to the network (CPU-bound TLS termination, not a network problem). - **TLS handshake RTT** (`--tls`) — TCP RTT, TLS 1.3 handshake time and time-to-first-byte to reference services, flagging servers whose handshake is disproportionately slow relative to the network (CPU-bound TLS termination, not a network problem). Also captures each certificate's SHA-256 fingerprint, issuer and days until expiry, flags a validation failure, and checks against an optional pinned fingerprint (`tls.pinned_fingerprints` in `config.yaml`) to catch a corporate/ISP TLS-intercepting middlebox or a genuine MITM.
- **DNS: system vs DoH** (`--dns-advanced`) — compares your system resolver's answers and latency against Cloudflare/Google/Quad9 DoH, and probes a bogus resolver IP to detect a transparent DNS proxy intercepting port 53. - **DNS: system vs DoH** (`--dns-advanced`) — compares your system resolver's answers and latency against Cloudflare/Google/Quad9 DoH, and probes a bogus resolver IP to detect a transparent DNS proxy intercepting port 53.
- **Path diversity / Anycast** (`--path-diversity`) — reads Cloudflare's `CF-RAY` edge code to find which PoP is actually serving your traffic, and flags an international routing loop when it's in a different country than you are. - **Path diversity / Anycast** (`--path-diversity`) — reads Cloudflare's `CF-RAY` edge code to find which PoP is actually serving your traffic, and flags an international routing loop when it's in a different country than you are.
- **AS prefix benchmark** (`--prefix-bench`) — pings the first host of a handful of your own AS's announced prefixes to find the lowest-latency PoP. Capped at 256 prefixes and off by default — this samples your own network, it does not scan it. - **AS prefix benchmark** (`--prefix-bench`) — pings the first host of a handful of your own AS's announced prefixes to find the lowest-latency PoP. Capped at 256 prefixes and off by default — this samples your own network, it does not scan it.

View file

@ -65,7 +65,7 @@ netsleuth
- **Репутация** — локально кэшируемые списки FireHOL (ни один запрос не покидает машину), экспозиция по Shodan InternetDB и опционально классические зоны DNSBL. - **Репутация** — локально кэшируемые списки FireHOL (ни один запрос не покидает машину), экспозиция по Shodan InternetDB и опционально классические зоны DNSBL.
- **Задержки и маршрут** — статистика пингов по каждому хосту с настоящими джиттером/потерями/mdev/p95/p99 и трассировка, которой никогда не нужны права администратора. Первый хоп диагностируется отдельно от остального маршрута, поэтому потери или медлительность там правильно относятся к вашему роутеру/Wi-Fi, а не к провайдеру. Каждый хоп размечен своим ASN, именем AS и страной, а маршрут, уходящий крюком через третью страну, отмечается отдельно. - **Задержки и маршрут** — статистика пингов по каждому хосту с настоящими джиттером/потерями/mdev/p95/p99 и трассировка, которой никогда не нужны права администратора. Первый хоп диагностируется отдельно от остального маршрута, поэтому потери или медлительность там правильно относятся к вашему роутеру/Wi-Fi, а не к провайдеру. Каждый хоп размечен своим ASN, именем AS и страной, а маршрут, уходящий крюком через третью страну, отмечается отдельно.
- **Скорость** — каскадный замер (бинарник Ookla → Cloudflare → fast.com → опционально M-Lab NDT7) с раздельными оценками bufferbloat на приём и передачу и отметкой, если сервер замера оказался в другой стране. - **Скорость** — каскадный замер (бинарник Ookla → Cloudflare → fast.com → опционально M-Lab NDT7) с раздельными оценками bufferbloat на приём и передачу и отметкой, если сервер замера оказался в другой стране.
- **TLS handshake RTT** (`--tls`) — TCP RTT, время TLS 1.3-рукопожатия и время до первого байта к эталонным сервисам; отдельно отмечает сервер, чьё рукопожатие непропорционально медленнее сети (упирается в CPU TLS-терминатора, а не в сеть). - **TLS handshake RTT** (`--tls`) — TCP RTT, время TLS 1.3-рукопожатия и время до первого байта к эталонным сервисам; отдельно отмечает сервер, чьё рукопожатие непропорционально медленнее сети (упирается в CPU TLS-терминатора, а не в сеть). Также фиксирует SHA-256-отпечаток сертификата, издателя и дни до истечения, отмечает провал валидации и сверяет с опциональным закреплённым отпечатком (`tls.pinned_fingerprints` в `config.yaml`), чтобы поймать корпоративный/провайдерский TLS-перехватывающий middlebox или настоящий MITM.
- **DNS: системный vs DoH** (`--dns-advanced`) — сравнивает ответы и задержку системного резолвера с Cloudflare/Google/Quad9 DoH, а также проверяет фиктивный IP резолвера на признаки прозрачного DNS-прокси, перехватывающего 53 порт. - **DNS: системный vs DoH** (`--dns-advanced`) — сравнивает ответы и задержку системного резолвера с Cloudflare/Google/Quad9 DoH, а также проверяет фиктивный IP резолвера на признаки прозрачного DNS-прокси, перехватывающего 53 порт.
- **Диверсификация маршрута / Anycast** (`--path-diversity`) — по заголовку Cloudflare `CF-RAY` определяет, какая точка (PoP) реально обслуживает трафик, и отмечает международную петлю маршрута, если она в другой стране, чем клиент. - **Диверсификация маршрута / Anycast** (`--path-diversity`) — по заголовку Cloudflare `CF-RAY` определяет, какая точка (PoP) реально обслуживает трафик, и отмечает международную петлю маршрута, если она в другой стране, чем клиент.
- **Бенчмарк префиксов AS** (`--prefix-bench`) — пингует первый хост нескольких анонсируемых префиксов вашей собственной AS, чтобы найти точку с наименьшей задержкой. Ограничено 256 префиксами и выключено по умолчанию — это выборочная проба своей же сети, а не её сканирование. - **Бенчмарк префиксов AS** (`--prefix-bench`) — пингует первый хост нескольких анонсируемых префиксов вашей собственной AS, чтобы найти точку с наименьшей задержкой. Ограничено 256 префиксами и выключено по умолчанию — это выборочная проба своей же сети, а не её сканирование.

View file

@ -82,6 +82,10 @@ tls:
port: 443 port: 443
concurrency: 4 concurrency: 4
ttfb_path: "/" ttfb_path: "/"
verify: true
# host -> expected SHA-256 of the full DER certificate (openssl x509 -outform DER
# | sha256sum). Colons/case are normalized. Capped at 32 entries in code.
pinned_fingerprints: {}
# Opt-in via --prefix-bench. Pings the first host of a handful of your own AS's # Opt-in via --prefix-bench. Pings the first host of a handful of your own AS's
# announced prefixes to find the lowest-latency PoP. max_prefixes is capped at 256 # announced prefixes to find the lowest-latency PoP. max_prefixes is capped at 256

View file

@ -292,6 +292,8 @@ async def _tls_section(settings: Settings, options: Options) -> ModuleResult:
timeout=settings.timeouts.http_seconds, timeout=settings.timeouts.http_seconds,
concurrency=cfg.concurrency, concurrency=cfg.concurrency,
source_ip=options.bind.ipv4 if options.bind else None, source_ip=options.bind.ipv4 if options.bind else None,
verify=cfg.verify,
pins=cfg.pinned_fingerprints,
) )
return ModuleResult(name="tls", status="ok", data=results) return ModuleResult(name="tls", status="ok", data=results)

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -129,6 +130,9 @@ class Thresholds(BaseModel):
first_hop_ms: Band = Band(good=5.0, warn=25.0) first_hop_ms: Band = Band(good=5.0, warn=25.0)
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
class TlsConfig(BaseModel): class TlsConfig(BaseModel):
targets: list[HostSpec] = Field( targets: list[HostSpec] = Field(
default_factory=lambda: [ default_factory=lambda: [
@ -139,6 +143,23 @@ class TlsConfig(BaseModel):
port: int = 443 port: int = 443
concurrency: int = 4 concurrency: int = 4
ttfb_path: str = "/" ttfb_path: str = "/"
verify: bool = True
pinned_fingerprints: dict[str, str] = Field(default_factory=dict)
@field_validator("pinned_fingerprints")
@classmethod
def _normalize_pins(cls, value: dict[str, str]) -> dict[str, str]:
if len(value) > 32:
raise ValueError("tls.pinned_fingerprints may list at most 32 entries")
normalized: dict[str, str] = {}
for host, fingerprint in value.items():
cleaned = fingerprint.replace(":", "").strip().lower()
if not _SHA256_HEX_RE.match(cleaned):
raise ValueError(
f"tls.pinned_fingerprints[{host!r}] is not a 64-character hex SHA-256 digest: {fingerprint!r}"
)
normalized[host] = cleaned
return normalized
class PrefixBenchConfig(BaseModel): class PrefixBenchConfig(BaseModel):

View file

@ -730,6 +730,9 @@ def _tls(report: dict, lang: str = "en") -> list[str]:
_t(lang, "TTFB (ms)", "TTFB (мс)"), _t(lang, "TTFB (ms)", "TTFB (мс)"),
_t(lang, "Version", "Версия"), _t(lang, "Version", "Версия"),
_t(lang, "Cipher", "Шифр"), _t(lang, "Cipher", "Шифр"),
_t(lang, "Cert SHA-256", "Отпечаток SHA-256"),
_t(lang, "Issuer", "Издатель"),
_t(lang, "Expires in (days)", "Истекает через (дней)"),
], ],
[ [
[ [
@ -740,17 +743,27 @@ def _tls(report: dict, lang: str = "en") -> list[str]:
_num(r.get("ttfb_ms")), _num(r.get("ttfb_ms")),
r.get("tls_version") or "—", r.get("tls_version") or "—",
r.get("cipher") or "—", r.get("cipher") or "—",
(r.get("cert_sha256") or "—")[:16] + ("…" if r.get("cert_sha256") else ""),
r.get("cert_issuer") or "—",
_num(r.get("cert_days_remaining")),
] ]
for r in results for r in results
], ],
lang, lang,
) )
mismatches = [r for r in results if r.get("pin_verdict") == "mismatch"]
if mismatches:
lines += [""] + [
f"- ⚠️ {r.get('label')}: {_t(lang, 'pinned fingerprint mismatch', 'отпечаток не совпадает с закреплённым')}"
for r in mismatches
]
errored = [r for r in results if r.get("error")] errored = [r for r in results if r.get("error")]
if errored: if errored:
lines += [""] + [f"- {r.get('label')}: {r.get('error')}" for r in errored] lines += [""] + [f"- {r.get('label')}: {r.get('error')}" for r in errored]
lines += [ lines += [
"", "",
f"> {_t(lang, 'TLS handshake time is derived by subtracting a separate TCP-only baseline connection from a second, full TLS connection.', 'Время TLS-рукопожатия получено вычитанием отдельного базового TCP-соединения из времени второго, полного TLS-соединения.')}", f"> {_t(lang, 'TLS handshake time is derived by subtracting a separate TCP-only baseline connection from a second, full TLS connection.', 'Время TLS-рукопожатия получено вычитанием отдельного базового TCP-соединения из времени второго, полного TLS-соединения.')}",
f"> {_t(lang, 'The certificate fingerprint is the SHA-256 of the full DER certificate, not the SPKI — compute it as `openssl x509 -outform DER | sha256sum` to compare against an externally-pinned value.', 'Отпечаток сертификата — это SHA-256 полного DER-сертификата, а не SPKI — считайте его как `openssl x509 -outform DER | sha256sum`, чтобы сравнить с внешне закреплённым значением.')}",
] ]
return lines + [""] return lines + [""]

View file

@ -615,6 +615,56 @@ def tls_findings(results: list[TlsResult], t: Thresholds) -> list[Finding]:
advice_ru="Медленный TTFB при быстром рукопожатии указывает на обработку на стороне сервера, а не на сеть.", advice_ru="Медленный TTFB при быстром рукопожатии указывает на обработку на стороне сервера, а не на сеть.",
) )
) )
if r.pin_verdict == "mismatch":
findings.append(
Finding(
id=f"tls.cert_pin_mismatch.{r.label}",
severity="crit",
title=f"Certificate for {r.host} does not match the pinned fingerprint",
detail=f"Observed SHA-256 {r.cert_sha256}, which does not match the configured pin.",
metric="cert_sha256",
value=r.cert_sha256,
advice="A corporate/ISP TLS-intercepting middlebox or a genuine MITM; "
"compare against the fingerprint seen from another network before assuming the worst.",
title_ru=f"Сертификат {r.host} не совпадает с закреплённым отпечатком",
detail_ru=f"Наблюдаемый SHA-256 {r.cert_sha256} не совпадает с настроенным отпечатком.",
advice_ru="Корпоративный/провайдерский TLS-перехватывающий middlebox или настоящий MITM; "
"сравните с отпечатком, увиденным из другой сети, прежде чем делать выводы.",
)
)
elif r.cert_verified is False and r.pin_verdict == "unpinned":
findings.append(
Finding(
id=f"tls.cert_unverified.{r.label}",
severity="warn",
title=f"Certificate chain for {r.host} did not validate",
detail=f"Issuer: {r.cert_issuer or 'unknown'}.",
metric="cert_issuer",
value=r.cert_issuer,
advice="Often the issuer of an interception appliance rather than the real service's CA; "
"pin the expected fingerprint in tls.pinned_fingerprints to escalate this to a hard alert.",
title_ru=f"Цепочка сертификатов {r.host} не прошла проверку",
detail_ru=f"Издатель: {r.cert_issuer or 'неизвестен'}.",
advice_ru="Часто это издатель перехватывающего устройства, а не настоящий CA сервиса; "
"закрепите ожидаемый отпечаток в tls.pinned_fingerprints, чтобы превратить это в жёсткий алерт.",
)
)
if r.cert_days_remaining is not None and 0 <= r.cert_days_remaining < 14:
findings.append(
Finding(
id=f"tls.cert_expiring.{r.label}",
severity="warn",
title=f"Certificate for {r.host} expires soon",
detail=f"{r.cert_days_remaining} day(s) remaining ({r.cert_not_after}).",
metric="cert_days_remaining",
value=r.cert_days_remaining,
threshold=14,
advice="Renew before it lapses; an expired certificate breaks the service for every client.",
title_ru=f"Сертификат {r.host} скоро истекает",
detail_ru=f"Осталось {r.cert_days_remaining} дн. (до {r.cert_not_after}).",
advice_ru="Продлите до истечения; просроченный сертификат сломает сервис для всех клиентов.",
)
)
return findings return findings

View file

@ -364,6 +364,12 @@ class TlsResult:
cipher: str | None = None cipher: str | None = None
alpn: str | None = None alpn: str | None = None
cert_verified: bool | None = None cert_verified: bool | None = None
cert_sha256: str | None = None
cert_subject: str | None = None
cert_issuer: str | None = None
cert_not_after: str | None = None
cert_days_remaining: int | None = None
pin_verdict: str = "unpinned"
error: str | None = None error: str | None = None

View file

@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import ssl import ssl
import time import time
from datetime import datetime, timezone
from netsleuth.models import TlsResult from netsleuth.models import TlsResult
from netsleuth.probes.latency import tcp_connect_rtt from netsleuth.probes.latency import tcp_connect_rtt
@ -23,14 +25,44 @@ def cpu_bound_ratio(tls_handshake_ms: float | None, tcp_rtt_ms: float | None) ->
return tls_handshake_ms / tcp_rtt_ms return tls_handshake_ms / tcp_rtt_ms
def tls_context() -> ssl.SSLContext: def tls_context(verify: bool = True) -> ssl.SSLContext:
ctx = ssl.create_default_context() ctx = ssl.create_default_context()
if not verify:
ctx.check_hostname = False ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE ctx.verify_mode = ssl.CERT_NONE
ctx.set_alpn_protocols(["h2", "http/1.1"]) ctx.set_alpn_protocols(["h2", "http/1.1"])
return ctx return ctx
def fingerprint_verdict(host: str, actual_sha256: str | None, pins: dict[str, str]) -> str:
pin = pins.get(host)
if not pin:
return "unpinned"
if actual_sha256 is None:
return "mismatch"
normalized_pin = pin.replace(":", "").strip().lower()
normalized_actual = actual_sha256.replace(":", "").strip().lower()
return "match" if normalized_pin == normalized_actual else "mismatch"
def cert_name(rdn_sequence: tuple | None) -> str | None:
if not rdn_sequence:
return None
parts = [f"{key}={value}" for rdn in rdn_sequence for key, value in rdn]
return ", ".join(parts) or None
def days_remaining(not_after: str | None, now: datetime | None = None) -> int | None:
if not not_after:
return None
try:
expires_ts = ssl.cert_time_to_seconds(not_after)
except ValueError:
return None
expires = datetime.fromtimestamp(expires_ts, tz=timezone.utc)
return (expires - (now or datetime.now(timezone.utc))).days
async def measure_tls( async def measure_tls(
label: str, label: str,
host: str, host: str,
@ -38,21 +70,40 @@ async def measure_tls(
port: int = 443, port: int = 443,
timeout: float = 3.0, timeout: float = 3.0,
source_ip: str | None = None, source_ip: str | None = None,
verify: bool = True,
pins: dict[str, str] | None = None,
) -> TlsResult: ) -> TlsResult:
result = TlsResult(label=label, host=host, port=port) result = TlsResult(label=label, host=host, port=port)
pins = pins or {}
try: try:
tcp_rtt_ms = await tcp_connect_rtt(host, port=port, timeout=timeout, source_ip=source_ip) tcp_rtt_ms = await tcp_connect_rtt(host, port=port, timeout=timeout, source_ip=source_ip)
result.tcp_rtt_ms = tcp_rtt_ms result.tcp_rtt_ms = tcp_rtt_ms
local_addr = (source_ip, 0) if source_ip else None local_addr = (source_ip, 0) if source_ip else None
began = time.perf_counter()
reader, writer = await asyncio.wait_for( async def _connect(do_verify: bool):
return await asyncio.wait_for(
asyncio.open_connection( asyncio.open_connection(
host, port, ssl=tls_context(), server_hostname=host, local_addr=local_addr host, port, ssl=tls_context(verify=do_verify), server_hostname=host, local_addr=local_addr
), ),
timeout=timeout, timeout=timeout,
) )
began = time.perf_counter()
cert_verified = verify
try:
reader, writer = await _connect(verify)
except ssl.SSLCertVerificationError:
if not verify:
raise
# The failed handshake's time is not representative of a real connection;
# only the successful (unverified) attempt's duration feeds tls_handshake_ms.
began = time.perf_counter()
reader, writer = await _connect(False)
cert_verified = False
tls_total_ms = (time.perf_counter() - began) * 1000.0 tls_total_ms = (time.perf_counter() - began) * 1000.0
result.cert_verified = cert_verified
result.resolved_ip = (writer.get_extra_info("peername") or (None,))[0]
ssl_object = writer.get_extra_info("ssl_object") ssl_object = writer.get_extra_info("ssl_object")
if ssl_object is not None: if ssl_object is not None:
@ -60,6 +111,18 @@ async def measure_tls(
cipher = ssl_object.cipher() cipher = ssl_object.cipher()
result.cipher = cipher[0] if cipher else None result.cipher = cipher[0] if cipher else None
result.alpn = ssl_object.selected_alpn_protocol() result.alpn = ssl_object.selected_alpn_protocol()
der = ssl_object.getpeercert(binary_form=True)
if der:
result.cert_sha256 = hashlib.sha256(der).hexdigest()
result.pin_verdict = fingerprint_verdict(host, result.cert_sha256, pins)
if cert_verified:
# getpeercert() only returns the parsed dict for a chain that
# actually validated -- under CERT_NONE it is always {}.
parsed = ssl_object.getpeercert() or {}
result.cert_subject = cert_name(parsed.get("subject"))
result.cert_issuer = cert_name(parsed.get("issuer"))
result.cert_not_after = parsed.get("notAfter")
result.cert_days_remaining = days_remaining(result.cert_not_after)
request = f"HEAD / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n".encode() request = f"HEAD / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n".encode()
began_ttfb = time.perf_counter() began_ttfb = time.perf_counter()
@ -89,11 +152,15 @@ async def tls_fanout(
timeout: float, timeout: float,
concurrency: int, concurrency: int,
source_ip: str | None = None, source_ip: str | None = None,
verify: bool = True,
pins: dict[str, str] | None = None,
) -> list[TlsResult]: ) -> list[TlsResult]:
semaphore = asyncio.Semaphore(concurrency) semaphore = asyncio.Semaphore(concurrency)
async def _bounded(label: str, host: str) -> TlsResult: async def _bounded(label: str, host: str) -> TlsResult:
async with semaphore: async with semaphore:
return await measure_tls(label, host, port=port, timeout=timeout, source_ip=source_ip) return await measure_tls(
label, host, port=port, timeout=timeout, source_ip=source_ip, verify=verify, pins=pins
)
return list(await asyncio.gather(*(_bounded(label, host) for label, host in targets))) return list(await asyncio.gather(*(_bounded(label, host) for label, host in targets)))

View file

@ -191,6 +191,39 @@ def test_dpi_check_concurrency_is_capped_as_a_rate_limit(tmp_path):
load_settings(config_path=path, env_file=tmp_path / "nope.env") load_settings(config_path=path, env_file=tmp_path / "nope.env")
def test_tls_pinned_fingerprints_accepts_valid_hex_and_normalizes_case_and_colons(tmp_path):
path = tmp_path / "config.yaml"
fingerprint_with_colons = ":".join(["AB"] * 32)
path.write_text(
f'tls:\n pinned_fingerprints: {{"example.com": "{fingerprint_with_colons}"}}\n',
encoding="utf-8",
)
s = load_settings(config_path=path, env_file=tmp_path / "nope.env")
assert s.tls.pinned_fingerprints["example.com"] == "ab" * 32
def test_tls_pinned_fingerprints_rejects_a_short_value(tmp_path):
path = tmp_path / "config.yaml"
path.write_text('tls:\n pinned_fingerprints: {"example.com": "aabbcc"}\n', encoding="utf-8")
with pytest.raises(ValidationError, match="64-character hex"):
load_settings(config_path=path, env_file=tmp_path / "nope.env")
def test_tls_pinned_fingerprints_rejects_non_hex_characters(tmp_path):
path = tmp_path / "config.yaml"
path.write_text('tls:\n pinned_fingerprints: {"example.com": "' + "zz" * 32 + '"}\n', encoding="utf-8")
with pytest.raises(ValidationError, match="64-character hex"):
load_settings(config_path=path, env_file=tmp_path / "nope.env")
def test_tls_pinned_fingerprints_are_capped_at_32_entries(tmp_path):
path = tmp_path / "config.yaml"
entries = ", ".join(f'"host{i}.example.com": "{"aa" * 32}"' for i in range(33))
path.write_text("tls:\n pinned_fingerprints: {" + entries + "}\n", encoding="utf-8")
with pytest.raises(ValidationError, match="at most 32"):
load_settings(config_path=path, env_file=tmp_path / "nope.env")
def test_captive_portal_check_urls_are_capped(tmp_path): def test_captive_portal_check_urls_are_capped(tmp_path):
path = tmp_path / "config.yaml" path = tmp_path / "config.yaml"
path.write_text( path.write_text(

View file

@ -99,6 +99,37 @@ 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) == [] 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: 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 = dict(prefix="203.0.113.0/24", probe_ip="203.0.113.1", avg_ms=10.0, reachable=True)
base.update(kw) base.update(kw)

View file

@ -2,9 +2,13 @@ from __future__ import annotations
import socket import socket
import ssl import ssl
from datetime import datetime, timezone
from netsleuth.probes.tls_rtt import ( from netsleuth.probes.tls_rtt import (
cert_name,
cpu_bound_ratio, cpu_bound_ratio,
days_remaining,
fingerprint_verdict,
measure_tls, measure_tls,
split_timings, split_timings,
tls_context, tls_context,
@ -51,13 +55,60 @@ def test_cpu_bound_ratio_is_none_when_tcp_rtt_is_zero():
assert cpu_bound_ratio(30.0, 0.0) is None assert cpu_bound_ratio(30.0, 0.0) is None
def test_tls_context_disables_hostname_checking_for_diagnostics(): def test_tls_context_verifies_by_default():
ctx = tls_context() ctx = tls_context()
assert isinstance(ctx, ssl.SSLContext) assert isinstance(ctx, ssl.SSLContext)
assert ctx.check_hostname is True
assert ctx.verify_mode == ssl.CERT_REQUIRED
def test_tls_context_disables_verification_when_asked():
ctx = tls_context(verify=False)
assert ctx.check_hostname is False assert ctx.check_hostname is False
assert ctx.verify_mode == ssl.CERT_NONE assert ctx.verify_mode == ssl.CERT_NONE
def test_fingerprint_verdict_matches_case_and_colon_insensitively():
pins = {"example.com": "AA:BB:CC:DD"}
assert fingerprint_verdict("example.com", "aabbccdd", pins) == "match"
def test_fingerprint_verdict_mismatch():
pins = {"example.com": "aabbccdd"}
assert fingerprint_verdict("example.com", "11223344", pins) == "mismatch"
def test_fingerprint_verdict_unpinned_when_host_has_no_pin():
assert fingerprint_verdict("example.com", "aabbccdd", {}) == "unpinned"
def test_fingerprint_verdict_mismatch_when_actual_is_none():
assert fingerprint_verdict("example.com", None, {"example.com": "aabbccdd"}) == "mismatch"
def test_cert_name_flattens_the_rdn_sequence():
subject = ((("commonName", "example.com"),), (("organizationName", "Example Inc"),))
assert cert_name(subject) == "commonName=example.com, organizationName=Example Inc"
def test_cert_name_handles_empty_input():
assert cert_name(None) is None
assert cert_name(()) is None
def test_days_remaining_computes_from_a_fixed_now():
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
assert days_remaining("Jan 11 00:00:00 2026 GMT", now=now) == 10
def test_days_remaining_handles_a_malformed_string():
assert days_remaining("not a date") is None
def test_days_remaining_handles_missing_input():
assert days_remaining(None) is None
async def test_measure_tls_reports_an_error_result_for_a_closed_port_without_raising(): async def test_measure_tls_reports_an_error_result_for_a_closed_port_without_raising():
port = _closed_port() port = _closed_port()
result = await measure_tls("dead", "127.0.0.1", port=port, timeout=1.0) result = await measure_tls("dead", "127.0.0.1", port=port, timeout=1.0)