Skip to content

Commit 676fb40

Browse files
authored
feat: add probe_raw() public function
Adds tls_utils.probe_raw(host, port, *, starttls, sni_hostname, timeout) — a public function that runs openssl s_client without -brief, with -ign_eof and a QUIT\r\n probe, returning raw combined stdout+stderr for callers to parse (Max Early Data:, Negotiated TLS1.3 group:). Exported from the package root as quantumvalidator.probe_raw. Adds 15 new unit tests (245 total). Bumps version to 0.6.0.
1 parent 7de593c commit 676fb40

6 files changed

Lines changed: 278 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,32 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
1111

1212
---
1313

14+
## [0.6.0] — 2026-06-19
15+
16+
### Added
17+
- `tls_utils.probe_raw(host, port, *, starttls, sni_hostname, timeout)` — public
18+
function that runs `openssl s_client` and returns the raw combined
19+
stdout+stderr as a string (or `None` on failure). Unlike `probe_tls`, it
20+
omits `-brief`, adds `-ign_eof`, and sends `QUIT\r\n` so callers can parse
21+
protocol-specific fields such as `Max Early Data:` (TLS 1.3 0-RTT) and
22+
`Negotiated TLS1.3 group:`. Exported from the package root as
23+
`quantumvalidator.probe_raw`.
24+
- 15 new unit tests for `probe_raw` in `tests/test_tls_utils.py` (245 total).
25+
26+
### Fixed
27+
- `tls_utils.probe_raw`: docstring now explicitly documents the ``timeout + 2``
28+
buffer passed to the subprocess, which gives the TLS handshake time to
29+
complete before Python terminates the process.
30+
- `tests/test_tls_utils.py`: `TestProbeRaw.test_returns_none_for_invalid_port`
31+
now monkeypatches `check_openssl` to return `(True, ...)` so the test
32+
exercises `_validate_target` deterministically regardless of whether
33+
`openssl` is installed on the test machine.
34+
- `__init__.py`: added `# noqa: E402` to the `probe_raw` re-export to
35+
suppress the false-positive E402 lint warning (the import intentionally
36+
follows the logging teardown block).
37+
38+
---
39+
1440
## [0.5.2] — 2026-05-15
1541

1642
### Added
@@ -173,7 +199,8 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html
173199

174200
---
175201

176-
[Unreleased]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.5.2...HEAD
202+
[Unreleased]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.6.0...HEAD
203+
[0.6.0]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.5.2...v0.6.0
177204
[0.5.2]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.5.1...v0.5.2
178205
[0.5.1]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.5.0...v0.5.1
179206
[0.5.0]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.4.0...v0.5.0

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ $ quantumvalidator check cloudflare.com
1212
```
1313

1414
![Python](https://img.shields.io/badge/python-%3E%3D3.11-blue)
15-
![Tests](https://img.shields.io/badge/tests-230%20passing-brightgreen)
15+
![Tests](https://img.shields.io/badge/tests-245%20passing-brightgreen)
1616
![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)
1717
![License](https://img.shields.io/badge/license-GPLv3-lightgrey)
1818

@@ -297,7 +297,7 @@ pytest tests/test_tls_utils.py
297297
pytest tests/test_assessor.py::TestAssessHttps -v
298298
```
299299

300-
The test suite has **230 tests** and maintains **100% statement coverage**.
300+
The test suite has **245 tests** and maintains **100% statement coverage**.
301301

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

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "quantumvalidator"
7-
version = "0.5.2"
7+
version = "0.6.0"
88
description = "Quantum-safe cryptography validator — TLS, STARTTLS, and SSH post-quantum readiness assessment"
99
readme = "README.md"
1010
requires-python = ">=3.11"

quantumvalidator/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
try:
66
__version__ = version("quantumvalidator")
77
except PackageNotFoundError: # pragma: no cover
8-
__version__ = "0.5.2"
8+
__version__ = "0.6.0"
99

1010
import logging as _logging
1111

1212
_logging.getLogger("quantumvalidator").addHandler(_logging.NullHandler())
1313
del _logging
1414

15-
__all__ = ["__version__"]
15+
from quantumvalidator.tls_utils import probe_raw as probe_raw # noqa: F401, E402
16+
17+
__all__ = ["__version__", "probe_raw"]

quantumvalidator/tls_utils.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,76 @@ def ok(self) -> bool:
7373
return self.error is None
7474

7575

76+
def probe_raw(
77+
host: str,
78+
port: int,
79+
*,
80+
starttls: str | None = None,
81+
sni_hostname: str | None = None,
82+
timeout: float = 10.0,
83+
) -> str | None:
84+
"""Run ``openssl s_client`` and return the combined stdout+stderr output.
85+
86+
Unlike :func:`probe_tls`, this function does not parse the output — it
87+
returns the raw text so callers can extract protocol-specific fields such
88+
as ``Max Early Data:`` (TLS 1.3 0-RTT, RFC 8446 §8).
89+
90+
Returns ``None`` when ``openssl`` is not on PATH, the host/port are
91+
invalid, or the subprocess fails (timeout, OSError).
92+
93+
:param host: Hostname or IP to connect to.
94+
:param port: TCP port.
95+
:param starttls: openssl ``-starttls`` mode (e.g. ``'smtp'``), or ``None`` for raw TLS.
96+
:param sni_hostname: Hostname to send as TLS SNI via ``-servername``, or ``None``.
97+
:param timeout: Connection timeout in seconds; the subprocess is given
98+
``timeout + 2`` seconds to allow TLS handshake completion.
99+
:returns: Combined stdout+stderr from ``openssl s_client``, or ``None`` on failure.
100+
:rtype: str | None
101+
"""
102+
ok, _ = check_openssl()
103+
if not ok:
104+
return None
105+
106+
try:
107+
_validate_target(host, port)
108+
except ValueError:
109+
return None
110+
111+
try:
112+
addr = ipaddress.ip_address(host)
113+
connect_str = f"[{host}]:{port}" if addr.version == 6 else f"{host}:{port}"
114+
except ValueError:
115+
connect_str = f"{host}:{port}"
116+
117+
groups_str = ":".join(PROBE_GROUPS)
118+
cmd = [
119+
OPENSSL_BINARY,
120+
"s_client",
121+
"-connect", connect_str,
122+
"-groups", groups_str,
123+
"-ign_eof",
124+
]
125+
if starttls:
126+
cmd.extend(["-starttls", starttls])
127+
if sni_hostname:
128+
cmd.extend(["-servername", sni_hostname])
129+
130+
logger.debug(
131+
"probe_raw %s:%d starttls=%s sni=%s — cmd: %s",
132+
host, port, starttls, sni_hostname, " ".join(cmd),
133+
)
134+
try:
135+
proc = subprocess.run(
136+
cmd,
137+
input=b"QUIT\r\n",
138+
capture_output=True,
139+
timeout=timeout + 2,
140+
)
141+
return (proc.stdout + proc.stderr).decode("utf-8", errors="replace")
142+
except (subprocess.TimeoutExpired, OSError):
143+
return None
144+
145+
76146
def probe_tls(
77147
host: str,
78148
port: int,

tests/test_tls_utils.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
_probe_ssh,
1919
_read_server_banner,
2020
_read_ssh_packet,
21+
probe_raw,
2122
probe_tls,
2223
)
2324

@@ -1128,3 +1129,175 @@ def fake_recv(n: int) -> bytes:
11281129
assert "Could not parse SSH KEXINIT" in result.error
11291130
assert result.tls_version == "SSHv2"
11301131

1132+
1133+
# ---------------------------------------------------------------------------
1134+
# probe_raw
1135+
# ---------------------------------------------------------------------------
1136+
1137+
1138+
def _make_raw_proc(stdout: bytes = b"", stderr: bytes = b"", returncode: int = 0):
1139+
proc = MagicMock()
1140+
proc.stdout = stdout
1141+
proc.stderr = stderr
1142+
proc.returncode = returncode
1143+
return proc
1144+
1145+
1146+
class TestProbeRaw:
1147+
def test_returns_combined_output_on_success(self, monkeypatch):
1148+
stdout = b"Connecting to 1.2.3.4\nCONNECTION ESTABLISHED\n"
1149+
stderr = b"depth=0 CN=example.com\n"
1150+
monkeypatch.setattr(
1151+
"quantumvalidator.tls_utils.subprocess.run",
1152+
lambda *a, **kw: _make_raw_proc(stdout, stderr),
1153+
)
1154+
result = probe_raw("example.com", 25)
1155+
assert result == (stdout + stderr).decode("utf-8", errors="replace")
1156+
1157+
def test_returns_none_when_openssl_missing(self, monkeypatch):
1158+
monkeypatch.setattr(
1159+
"quantumvalidator.tls_utils.check_openssl",
1160+
lambda: (False, "openssl not found"),
1161+
)
1162+
assert probe_raw("example.com", 25) is None
1163+
1164+
def test_returns_none_on_timeout(self, monkeypatch):
1165+
def _raise(*a, **kw):
1166+
raise subprocess.TimeoutExpired(cmd="openssl", timeout=12)
1167+
1168+
monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", _raise)
1169+
assert probe_raw("example.com", 25) is None
1170+
1171+
def test_returns_none_on_oserror(self, monkeypatch):
1172+
def _raise(*a, **kw):
1173+
raise OSError("connection refused")
1174+
1175+
monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", _raise)
1176+
assert probe_raw("example.com", 25) is None
1177+
1178+
def test_returns_none_for_invalid_port(self, monkeypatch):
1179+
# _validate_target raises ValueError for port 0; probe_raw catches it.
1180+
# Mock check_openssl so the test exercises _validate_target regardless
1181+
# of whether openssl is installed on the CI machine.
1182+
monkeypatch.setattr(
1183+
"quantumvalidator.tls_utils.check_openssl",
1184+
lambda: (True, "openssl 3.0.0"),
1185+
)
1186+
assert probe_raw("example.com", 0) is None
1187+
1188+
def test_starttls_smtp_in_cmd(self, monkeypatch):
1189+
captured = {}
1190+
1191+
def fake_run(cmd, **kw):
1192+
captured["cmd"] = cmd
1193+
return _make_raw_proc()
1194+
1195+
monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", fake_run)
1196+
probe_raw("example.com", 25, starttls="smtp")
1197+
assert "-starttls" in captured["cmd"]
1198+
assert "smtp" in captured["cmd"]
1199+
1200+
def test_sni_hostname_adds_servername(self, monkeypatch):
1201+
captured = {}
1202+
1203+
def fake_run(cmd, **kw):
1204+
captured["cmd"] = cmd
1205+
return _make_raw_proc()
1206+
1207+
monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", fake_run)
1208+
probe_raw("1.2.3.4", 25, sni_hostname="mail.example.com")
1209+
assert "-servername" in captured["cmd"]
1210+
assert "mail.example.com" in captured["cmd"]
1211+
1212+
def test_no_servername_when_sni_none(self, monkeypatch):
1213+
captured = {}
1214+
1215+
def fake_run(cmd, **kw):
1216+
captured["cmd"] = cmd
1217+
return _make_raw_proc()
1218+
1219+
monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", fake_run)
1220+
probe_raw("example.com", 25)
1221+
assert "-servername" not in captured["cmd"]
1222+
1223+
def test_brief_not_in_cmd(self, monkeypatch):
1224+
captured = {}
1225+
1226+
def fake_run(cmd, **kw):
1227+
captured["cmd"] = cmd
1228+
return _make_raw_proc()
1229+
1230+
monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", fake_run)
1231+
probe_raw("example.com", 25)
1232+
assert "-brief" not in captured["cmd"]
1233+
1234+
def test_ign_eof_in_cmd(self, monkeypatch):
1235+
captured = {}
1236+
1237+
def fake_run(cmd, **kw):
1238+
captured["cmd"] = cmd
1239+
return _make_raw_proc()
1240+
1241+
monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", fake_run)
1242+
probe_raw("example.com", 25)
1243+
assert "-ign_eof" in captured["cmd"]
1244+
1245+
def test_groups_in_cmd(self, monkeypatch):
1246+
captured = {}
1247+
1248+
def fake_run(cmd, **kw):
1249+
captured["cmd"] = cmd
1250+
return _make_raw_proc()
1251+
1252+
monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", fake_run)
1253+
probe_raw("example.com", 25)
1254+
assert "-groups" in captured["cmd"]
1255+
groups_idx = captured["cmd"].index("-groups")
1256+
groups_val = captured["cmd"][groups_idx + 1]
1257+
assert all(g in groups_val for g in PROBE_GROUPS)
1258+
1259+
def test_ipv6_bracket_notation(self, monkeypatch):
1260+
captured = {}
1261+
1262+
def fake_run(cmd, **kw):
1263+
captured["cmd"] = cmd
1264+
return _make_raw_proc()
1265+
1266+
monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", fake_run)
1267+
probe_raw("::1", 25)
1268+
connect_idx = captured["cmd"].index("-connect")
1269+
connect_val = captured["cmd"][connect_idx + 1]
1270+
assert connect_val.startswith("[::1]:")
1271+
1272+
def test_input_is_quit_crlf(self, monkeypatch):
1273+
captured = {}
1274+
1275+
def fake_run(cmd, **kw):
1276+
captured["kwargs"] = kw
1277+
return _make_raw_proc()
1278+
1279+
monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", fake_run)
1280+
probe_raw("example.com", 25)
1281+
assert captured["kwargs"].get("input") == b"QUIT\r\n"
1282+
1283+
def test_no_starttls_flag_when_none(self, monkeypatch):
1284+
captured = {}
1285+
1286+
def fake_run(cmd, **kw):
1287+
captured["cmd"] = cmd
1288+
return _make_raw_proc()
1289+
1290+
monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", fake_run)
1291+
probe_raw("example.com", 443)
1292+
assert "-starttls" not in captured["cmd"]
1293+
1294+
def test_non_utf8_output_decoded_with_replace(self, monkeypatch):
1295+
raw_bytes = b"ok \xff\xfe output"
1296+
monkeypatch.setattr(
1297+
"quantumvalidator.tls_utils.subprocess.run",
1298+
lambda *a, **kw: _make_raw_proc(stdout=raw_bytes),
1299+
)
1300+
result = probe_raw("example.com", 443)
1301+
assert result is not None
1302+
assert "ok" in result
1303+

0 commit comments

Comments
 (0)