Skip to content

Commit 3d2ad2b

Browse files
committed
feat: add STARTTLS detection for FTP, LMTP, NNTP, Sieve; fix FTP hang on AUTH TLS rejection
- _fingerprint_banner now detects ftp/lmtp (via 220 banner content), nntp (200/201), and sieve ("IMPLEMENTATION"/"SIEVE" capability lines) - _probe_ftp pre-checks AUTH TLS over raw socket before invoking openssl, preventing a hang when servers reply 500 instead of 234 - FTP greeting/response reads use loop accumulation (TCP segment safety) - dataclasses.replace() used instead of post-construction mutation - Empty AUTH TLS response reports "(no response)" in error message - All docstrings updated (assessor, checker, cli, models, tls_utils) - 230 tests, 100% coverage
1 parent 1f97163 commit 3d2ad2b

8 files changed

Lines changed: 448 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,34 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
99

1010
## [Unreleased]
1111

12+
### Changed
13+
- **`_probe_ftp` greeting/response reads are now loop-safe** — replaced single
14+
`sock.recv(1024)` calls with chunk-accumulation loops that read until `\n` or
15+
1024 bytes, guarding against TCP segmentation on slow links.
16+
- **`_probe_ftp` uses `dataclasses.replace()`** instead of post-construction
17+
field mutation when stamping `detected_starttls="ftp"` onto the openssl result.
18+
- **Empty AUTH TLS response** now reports `"(no response)"` in the error message
19+
instead of a trailing bare colon.
20+
- **`_fingerprint_banner` docstring** documents the FTP keyword-detection
21+
limitation (FileZilla) and the NNTP `200`/`201` heuristic caveat.
22+
23+
### Fixed
24+
- **FTP probe no longer hangs on servers that reject AUTH TLS** — `openssl
25+
s_client -starttls ftp` does not exit cleanly when the server replies with
26+
e.g. `500 AUTH not understood`; it hangs until the subprocess timeout fires.
27+
A new `_probe_ftp` function now sends `AUTH TLS` over a raw socket first;
28+
if the server responds with anything other than `234`, the error is returned
29+
immediately without invoking `openssl`.
30+
31+
### Added
32+
- **Extended STARTTLS protocol detection**`_fingerprint_banner` now
33+
auto-detects FTP (`220 … FTP …`), LMTP (`220 … LMTP …`), NNTP (`200 `/`201 `),
34+
and ManageSieve (`"IMPLEMENTATION"` / `"SIEVE"` / `"STARTTLS"` capability
35+
lines) from server banners, dispatching to the correct `openssl -starttls`
36+
mode automatically. Protocols that send no opening banner (XMPP, LDAP,
37+
MySQL, PostgreSQL) remain unsupported in auto-detect mode by design, keeping
38+
the probe protocol-agnostic.
39+
1240
---
1341

1442
## [0.4.0] — 2026-04-29

README.md

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@
22

33
> Validate post-quantum cryptography readiness of internet-exposed TLS, STARTTLS, and SSH services — from the command line or as a Python library.
44
5-
**quantumvalidator** actively probes HTTPS, SMTP/STARTTLS, IMAP/STARTTLS, POP3/STARTTLS, and SSH
6-
endpoints to detect whether a PQC hybrid key exchange (ML-KEM) was negotiated, returning a binary
7-
**SAFE** / **UNSAFE** verdict aligned with NSA CNSA 2.0, BSI TR-02102-2 (TLS), and BSI TR-02102-4 (SSH).
5+
**quantumvalidator** actively probes HTTPS, SMTP/STARTTLS, IMAP/STARTTLS, POP3/STARTTLS,
6+
FTP/STARTTLS, LMTP/STARTTLS, NNTP/STARTTLS, ManageSieve/STARTTLS, and SSH endpoints to detect
7+
whether a PQC hybrid key exchange (ML-KEM) was negotiated, returning a binary **SAFE** / **UNSAFE**
8+
verdict aligned with NSA CNSA 2.0, BSI TR-02102-2 (TLS), and BSI TR-02102-4 (SSH).
89

910
```bash
1011
$ quantumvalidator check cloudflare.com
1112
```
1213

1314
![Python](https://img.shields.io/badge/python-%3E%3D3.11-blue)
14-
![Tests](https://img.shields.io/badge/tests-206%20passing-brightgreen)
15+
![Tests](https://img.shields.io/badge/tests-230%20passing-brightgreen)
1516
![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)
1617
![License](https://img.shields.io/badge/license-GPLv3-lightgrey)
1718

@@ -126,16 +127,32 @@ quantumvalidator check example.com --output report.html
126127

127128
### STARTTLS probe (auto-detected)
128129

130+
Banner fingerprinting selects the correct `-starttls` mode automatically.
131+
129132
```bash
130-
# SMTP/STARTTLS — detected automatically from server banner
133+
# SMTP/STARTTLS — detected from "220 … ESMTP …" banner
131134
quantumvalidator check smtp.gmail.com --port 587
132135
quantumvalidator check smtp.gmail.com -p 587 # short form
136+
quantumvalidator check mx1.example.com -p 25
133137

134-
# IMAP/STARTTLS
138+
# IMAP/STARTTLS — detected from "* OK …" banner
135139
quantumvalidator check mail.example.com -p 143
136140

137-
# SMTP on port 25
138-
quantumvalidator check mx1.example.com -p 25
141+
# POP3/STARTTLS — detected from "+OK …" banner
142+
quantumvalidator check mail.example.com -p 110
143+
144+
# FTP/STARTTLS (AUTH TLS) — detected from "220 … FTP …" banner
145+
# Note: servers that do not support AUTH TLS return an immediate error.
146+
quantumvalidator check ftp.example.com -p 21
147+
148+
# LMTP/STARTTLS — detected from "220 … LMTP …" banner
149+
quantumvalidator check lmtp.example.com -p 24
150+
151+
# NNTP/STARTTLS — detected from "200 …" / "201 …" banner
152+
quantumvalidator check news.example.com -p 119
153+
154+
# ManageSieve/STARTTLS — detected from '"IMPLEMENTATION" …' capability line
155+
quantumvalidator check sieve.example.com -p 4190
139156
```
140157

141158
### SSH probe (auto-detected)
@@ -185,7 +202,7 @@ report = assess("cloudflare.com")
185202
print(report.verdict) # Verdict.SAFE or Verdict.UNSAFE
186203
print(report.is_safe) # True / False
187204
print(report.target) # "cloudflare.com"
188-
print(report.detected_starttls) # None (raw TLS) | "smtp" | "imap" | "pop3"
205+
print(report.detected_starttls) # None (raw TLS) | "smtp" | "imap" | "pop3" | "ftp" | "lmtp" | "nntp" | "sieve" | "ssh"
189206
print(report.port) # 443
190207
print(report.tls_version) # "TLSv1.3" or None
191208
print(report.negotiated_group) # "X25519MLKEM768" or None
@@ -244,7 +261,7 @@ quantumvalidator/
244261
│ ├── constants.py PQC_GROUPS, SAFE_GROUPS, PROBE_GROUPS, check_openssl()
245262
│ ├── models.py Verdict, Status, CheckResult, QuantumReport
246263
│ ├── reporter.py print_report() — Rich terminal renderer
247-
│ ├── tls_utils.py probe_tls() — subprocess openssl s_client wrapper
264+
│ ├── tls_utils.py probe_tls() — banner-first probe; openssl s_client + SSH/FTP native handlers
248265
│ └── verdict.py determine_verdict() / build_checks()
249266
├── tests/
250267
│ ├── conftest.py
@@ -280,7 +297,7 @@ pytest tests/test_tls_utils.py
280297
pytest tests/test_assessor.py::TestAssessHttps -v
281298
```
282299

283-
The test suite has **206 tests** and maintains **100% statement coverage**.
300+
The test suite has **230 tests** and maintains **100% statement coverage**.
284301

285302
All network I/O (`openssl s_client` subprocess) is mocked at the `probe_tls` boundary —
286303
no test touches a real server or the internet.

quantumvalidator/assessor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ def assess(
3434
) -> QuantumReport:
3535
"""Probe *target* and assess its post-quantum TLS key exchange readiness.
3636
37-
The probe auto-detects STARTTLS mode (smtp/imap/pop3) via banner
38-
fingerprinting if a raw TLS handshake fails.
37+
The probe auto-detects STARTTLS mode (smtp/imap/pop3/ftp/lmtp/nntp/sieve)
38+
via banner fingerprinting, and SSH via ``SSH-2.0-`` banner.
3939
4040
:param target: Hostname or IP address to probe.
4141
:param port: TCP port override; defaults to 443.

quantumvalidator/checker.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
def check_tls(host: str, port: int, timeout: int = 10) -> TLSProbeResult:
1414
"""Probe *host*:*port* for PQC key exchange support.
1515
16-
The underlying probe auto-detects STARTTLS mode (smtp/imap/pop3) via
17-
banner fingerprinting if a raw TLS handshake fails.
16+
The underlying probe auto-detects STARTTLS mode (smtp/imap/pop3/ftp/lmtp/
17+
nntp/sieve) via banner fingerprinting. Protocols that send no opening banner
18+
(xmpp, ldap, mysql, postgres) cannot be auto-detected.
1819
1920
:param host: Target hostname or IP.
2021
:param port: TCP port.

quantumvalidator/cli.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@
2525
name="quantumvalidator",
2626
help=(
2727
"Validate post-quantum cryptography readiness of any TLS-bearing service. "
28-
"Auto-detects STARTTLS protocols (SMTP, IMAP, POP3) via banner fingerprinting. "
28+
"Auto-detects STARTTLS protocols (SMTP, IMAP, POP3, FTP, LMTP, NNTP, Sieve) "
29+
"via banner fingerprinting, and SSH via SSH-2.0- banner. "
2930
"Checks whether the service negotiates a PQC hybrid key exchange group (ML-KEM) "
30-
"as required by CNSA 2.0 and BSI TR-02102-2."
31+
"as required by CNSA 2.0 and BSI TR-02102-2/TR-02102-4."
3132
),
3233
add_completion=False,
3334
)
@@ -54,9 +55,10 @@ def check(
5455
help="Save report to file (.txt / .svg / .html).",
5556
),
5657
) -> None:
57-
"""Probe *TARGET* for post-quantum TLS key exchange support.
58+
"""Probe *TARGET* for post-quantum TLS/SSH key exchange support.
5859
59-
Auto-detects STARTTLS mode (SMTP, IMAP, POP3) if raw TLS fails.
60+
Auto-detects STARTTLS protocols (SMTP, IMAP, POP3, FTP, LMTP, NNTP, Sieve)
61+
from server banners, and SSH from the SSH-2.0- version string.
6062
"""
6163
from quantumvalidator.assessor import assess
6264

quantumvalidator/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ class QuantumReport:
6464
"""Hostname or IP that was probed."""
6565

6666
detected_starttls: Optional[str]
67-
"""STARTTLS mode auto-detected from server banner ('smtp'/'imap'/'pop3'), or None for raw TLS."""
67+
"""STARTTLS mode detected from server banner (e.g. ``'smtp'``/``'ftp'``/``'nntp'``/``'ssh'``), or ``None`` for raw TLS."""
6868

6969
port: int
7070
"""TCP port used for the probe."""

quantumvalidator/tls_utils.py

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import re
1414
import socket
1515
import subprocess
16-
from dataclasses import dataclass
16+
from dataclasses import dataclass, replace
1717
from typing import Optional
1818

1919
from quantumvalidator.constants import (
@@ -66,7 +66,7 @@ class TLSProbeResult:
6666
error: Optional[str] = None
6767
"""Error message if the probe failed, None on success."""
6868
detected_starttls: Optional[str] = None
69-
"""STARTTLS mode auto-detected from server banner ('smtp'/'imap'/'pop3'), or None."""
69+
"""STARTTLS mode detected from server banner (e.g. ``'smtp'``/``'ftp'``/``'nntp'``/``'ssh'``), or ``None``."""
7070

7171
@property
7272
def ok(self) -> bool:
@@ -112,6 +112,9 @@ def probe_tls(
112112
if detected == "ssh":
113113
return _probe_ssh(host, port, timeout)
114114

115+
if detected == "ftp":
116+
return _probe_ftp(host, port, timeout)
117+
115118
if detected:
116119
logger.info(
117120
"Banner detected '%s' for %s:%d — probing with -starttls %s",
@@ -129,7 +132,7 @@ def _build_cmd(host: str, port: int, starttls: Optional[str]) -> list[str]:
129132
130133
:param host: Target hostname or IP.
131134
:param port: TCP port.
132-
:param starttls: STARTTLS mode (``'smtp'``/``'imap'``/``'pop3'``), or ``None`` for raw TLS.
135+
:param starttls: openssl ``-starttls`` mode (e.g. ``'smtp'``/``'ftp'``/``'xmpp'``), or ``None`` for raw TLS.
133136
:returns: Command list for ``subprocess.run``.
134137
:rtype: list[str]
135138
"""
@@ -161,7 +164,7 @@ def _run_openssl(
161164
162165
:param host: Target hostname or IP.
163166
:param port: TCP port.
164-
:param starttls: STARTTLS mode (``'smtp'``/``'imap'``/``'pop3'``), or ``None`` for raw TLS.
167+
:param starttls: openssl ``-starttls`` mode (e.g. ``'smtp'``/``'ftp'``/``'xmpp'``), or ``None`` for raw TLS.
165168
:param timeout: Connection timeout in seconds.
166169
:returns: Probe result; ``raw_output`` always populated from subprocess output.
167170
:rtype: TLSProbeResult
@@ -362,26 +365,104 @@ def _probe_ssh(host: str, port: int, timeout: int) -> TLSProbeResult:
362365
)
363366

364367

365-
def _fingerprint_banner(output: str) -> Optional[str]:
366-
"""Detect STARTTLS mode from raw openssl output containing a server banner.
368+
def _probe_ftp(host: str, port: int, timeout: int) -> TLSProbeResult:
369+
"""Pre-check AUTH TLS support, then probe FTP STARTTLS for PQC key exchange.
370+
371+
``openssl s_client -starttls ftp`` hangs when the server rejects ``AUTH TLS``
372+
instead of responding with ``234 Proceed with negotiation``. This function
373+
issues ``AUTH TLS`` over a raw socket first; only if the server responds with
374+
``234`` does it dispatch to ``_run_openssl``.
375+
376+
:param host: Target hostname or IP.
377+
:param port: TCP port (typically 21).
378+
:param timeout: Connection and read timeout in seconds.
379+
:returns: Probe result with ``detected_starttls="ftp"``.
380+
:rtype: TLSProbeResult
381+
"""
382+
try:
383+
with socket.create_connection((host, port), timeout=timeout) as sock:
384+
sock.settimeout(timeout)
385+
# Consume the 220 greeting with a loop — a single recv() is not
386+
# guaranteed to return the full line when TCP segments are small.
387+
buf = b""
388+
while b"\n" not in buf and len(buf) < 1024:
389+
chunk = sock.recv(256)
390+
if not chunk:
391+
break
392+
buf += chunk
393+
sock.sendall(b"AUTH TLS\r\n")
394+
# Read the AUTH TLS response, also loop-safe.
395+
resp = b""
396+
while b"\n" not in resp and len(resp) < 1024:
397+
chunk = sock.recv(256)
398+
if not chunk:
399+
break
400+
resp += chunk
401+
raw = resp.decode("utf-8", errors="replace")
402+
except (OSError, socket.timeout) as exc:
403+
logger.warning("FTP AUTH TLS check failed for %s:%d: %s", host, port, exc)
404+
return TLSProbeResult(
405+
host=host, port=port,
406+
tls_version=None, negotiated_group=None,
407+
error=str(exc),
408+
detected_starttls="ftp",
409+
)
367410

368-
Scans all lines for known application-protocol banner prefixes that indicate
369-
the server expects a STARTTLS upgrade rather than direct TLS.
411+
first_line = (raw.strip().splitlines()[0] if raw.strip() else "(no response)").strip()
412+
if not first_line.startswith("234"):
413+
logger.info(
414+
"FTP server %s:%d does not support AUTH TLS: %s", host, port, first_line
415+
)
416+
return TLSProbeResult(
417+
host=host, port=port,
418+
tls_version=None, negotiated_group=None,
419+
error=f"FTP server does not support AUTH TLS: {first_line[:100]}",
420+
detected_starttls="ftp",
421+
)
370422

371-
:param output: Combined stdout+stderr from a failed raw TLS probe.
372-
:returns: Detected STARTTLS mode (``'smtp'``/``'imap'``/``'pop3'``), or ``None``.
423+
logger.info("FTP server %s:%d accepted AUTH TLS — probing TLS", host, port)
424+
return replace(_run_openssl(host, port, starttls="ftp", timeout=timeout), detected_starttls="ftp")
425+
426+
427+
def _fingerprint_banner(output: str) -> Optional[str]:
428+
"""Detect STARTTLS mode from a server's opening banner.
429+
430+
Scans all lines for known application-protocol banner prefixes that
431+
indicate the server expects a STARTTLS upgrade rather than direct TLS.
432+
433+
FTP and LMTP both open with ``"220 "`` like SMTP; they are distinguished
434+
by banner content: LMTP servers include ``"LMTP"``, FTP servers include
435+
``"FTP"`` (without ``"ESMTP"``). FTP servers that omit the word "FTP"
436+
from their greeting (e.g. FileZilla) will fall back to ``"smtp"``.
437+
NNTP is identified by ``"200 "``/``"201 "`` prefixes, which are uncommon
438+
as opening banners outside NNTP but not unique — treat as a best-effort
439+
heuristic. Protocols that send no banner on connect (XMPP, LDAP, MySQL,
440+
PostgreSQL) cannot be auto-detected here.
441+
442+
:param output: Server banner string (raw TCP read before TLS handshake).
443+
:returns: openssl ``-starttls`` mode string, or ``None`` if unrecognised.
373444
:rtype: str | None
374445
"""
375446
for line in output.splitlines():
376447
stripped = line.strip()
377448
if stripped.startswith("220 "):
449+
lower = stripped.lower()
450+
if "lmtp" in lower:
451+
return "lmtp"
452+
if "ftp" in lower and "esmtp" not in lower:
453+
return "ftp"
378454
return "smtp"
379455
if stripped.startswith("* OK"):
380456
return "imap"
381457
if stripped.startswith("+OK"):
382458
return "pop3"
383459
if stripped.startswith("SSH-"):
384460
return "ssh"
461+
if stripped.startswith(("200 ", "201 ")):
462+
return "nntp"
463+
# ManageSieve (RFC 5804): capability lines are double-quoted strings
464+
if stripped.startswith(('"IMPLEMENTATION"', '"SIEVE"', '"STARTTLS"')):
465+
return "sieve"
385466
return None
386467

387468

0 commit comments

Comments
 (0)