diff --git a/.gitignore b/.gitignore index 46197aa..2c012e2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ htmlcov/ dist/ build/ *.egg + +# Local Claude Code session artifacts (reviews, plans) +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d6d99..a8dd9d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,42 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- +## [0.7.1] — 2026-08-17 + +Resolves all findings of the post-merge v0.7.0 review (PR #10 comment). + +### Changed +- **Single-probe certificate fetch**: `probe_tls` no longer opens a second + connection for PQC-02. The primary `openssl s_client` probe now runs + without `-brief` — the full output carries the leaf-certificate PEM + alongside the protocol and negotiated-group lines — and `_run_openssl` + extracts the certificate from that same output. One TCP+TLS handshake + saved per TLS/STARTTLS assessment (measured 0.3–0.6 s); also removes the + `-ign_eof`/`QUIT` linger risk from the assessment hot path. + `_parse_openssl_output` now accepts both the `-brief` label + (`Protocol version:`) and the non-brief labels (`Protocol:`, and the + indented `Protocol :` SSL-Session variant), confirmed against + OpenSSL 3.6.3. Report contents are unchanged. +### Removed +- `fetch_certificate()` and `probe_raw()`: with the single-probe fetch they + had no callers left, and each was a second public network path beside + `probe_tls` — the sole I/O boundary the coding guidelines require. Callers + needing certificate details read them from the `probe_tls` result. + +### Fixed +- `--json` output could be **invalid JSON**: the CLI printed it through the + Rich console, which hard-wraps at the terminal width (80 columns when + piped), inserting raw newlines inside JSON strings. Latent since the JSON + flag existed (any reason string longer than the terminal width triggered + it, e.g. the `key_exchange` FAIL reason); v0.7.0's longer + `certificate_key` reasons made it hit on virtually every TLS assessment. + `_print_json` now prints with `soft_wrap=True, markup=False`. Found while + verifying this release; regression-tested with a 400-character reason. +- `certificate_key` check: an RSA key with undeterminable size now renders + `value="RSA-unknown"` instead of `"RSA-None"` (still FAIL). + +--- + ## [0.7.0] — 2026-08-17 ### Added @@ -374,7 +410,8 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- -[Unreleased]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.7.0...HEAD +[Unreleased]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.7.1...HEAD +[0.7.1]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.7.0...v0.7.1 [0.7.0]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.6.4...v0.7.0 [0.6.4]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.6.3...v0.6.4 [0.6.3]: https://github.com/NC3-TestingPlatform/quantumvalidator/compare/v0.6.2...v0.6.3 diff --git a/README.md b/README.md index ae53a59..c5521c7 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ $ quantumvalidator check cloudflare.com ``` ![Python](https://img.shields.io/badge/python-%3E%3D3.11-blue) -![Tests](https://img.shields.io/badge/tests-302%20passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-288%20passing-brightgreen) ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen) ![License](https://img.shields.io/badge/license-GPLv3-lightgrey) @@ -74,10 +74,10 @@ the check table: | `certificate_key` | TLS | RSA < 3072 bits, EC curve outside P-256/P-384/P-521/Brainpool ≥ 256, or DSA | CNSA 2.0, BSI TR-02102-2, NIST SP 800-186, FIPS 186-5 | | `host_key_algorithms` | SSH | `ssh-dss` (DSA, withdrawn) or `ssh-rsa` (SHA-1 signatures) advertised | RFC 8332, FIPS 186-5, BSI TR-02102-4 | -The certificate is retrieved with a second `openssl s_client` probe (the primary probe runs -with `-brief`, which suppresses certificate output) and parsed with -[pyca/cryptography](https://cryptography.io/). The SSH host-key list is read from the same -KEXINIT packet as the KEX algorithms — no extra connection. +The certificate is extracted from the same `openssl s_client` probe used for the +key-exchange check and parsed with [pyca/cryptography](https://cryptography.io/). The SSH +host-key list is read from the same KEXINIT packet as the KEX algorithms. Neither check +opens an extra connection. --- @@ -316,7 +316,7 @@ pytest tests/test_tls_utils.py pytest tests/test_assessor.py::TestAssessHttps -v ``` -The test suite has **302 tests** and maintains **100% statement coverage**. +The test suite has **288 tests** and maintains **100% statement coverage**. All network I/O (`openssl s_client` subprocess) is mocked at the `probe_tls` boundary — no test touches a real server or the internet. diff --git a/pyproject.toml b/pyproject.toml index dfe5961..f21f6c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "quantumvalidator" -version = "0.7.0" +version = "0.7.1" description = "Quantum-safe cryptography validator — TLS, STARTTLS, and SSH post-quantum readiness assessment" readme = "README.md" requires-python = ">=3.11" diff --git a/quantumvalidator/__init__.py b/quantumvalidator/__init__.py index a92a0fe..14ad6ee 100644 --- a/quantumvalidator/__init__.py +++ b/quantumvalidator/__init__.py @@ -8,7 +8,7 @@ try: __version__ = version("quantumvalidator") except PackageNotFoundError: # pragma: no cover - __version__ = "0.7.0" + __version__ = "0.7.1" _logging.getLogger("quantumvalidator").addHandler(_logging.NullHandler()) del _logging diff --git a/quantumvalidator/cli.py b/quantumvalidator/cli.py index ef4f496..92db99c 100644 --- a/quantumvalidator/cli.py +++ b/quantumvalidator/cli.py @@ -173,7 +173,10 @@ def _print_json(report: QuantumReport) -> None: for c in report.checks ], } - console.print(json.dumps(out, indent=2)) + # soft_wrap: Rich otherwise hard-wraps at terminal width (80 when piped), + # inserting raw newlines inside JSON strings — invalid JSON downstream. + # markup=False: JSON brackets must never be parsed as Rich markup tags. + console.print(json.dumps(out, indent=2), soft_wrap=True, markup=False) if __name__ == "__main__": # pragma: no cover diff --git a/quantumvalidator/tls_utils.py b/quantumvalidator/tls_utils.py index 81b194d..622d3e7 100644 --- a/quantumvalidator/tls_utils.py +++ b/quantumvalidator/tls_utils.py @@ -100,89 +100,6 @@ def ok(self) -> bool: ) -def probe_raw( - host: str, - port: int, - *, - starttls: str | None = None, - sni_hostname: str | None = None, - timeout: float = 10.0, -) -> str | None: - """Run ``openssl s_client`` and return the combined stdout+stderr output. - - Unlike :func:`probe_tls`, this function does not parse the output — it - returns the raw text so callers can extract protocol-specific fields such - as ``Max Early Data:`` (TLS 1.3 0-RTT, RFC 8446 §8). - - Returns ``None`` for the following failure conditions (callers cannot - distinguish between them): - - - ``openssl`` binary is not on ``PATH`` - - ``host`` or ``port`` fail validation - - subprocess times out (``timeout + 2`` seconds) - - ``OSError`` from the subprocess (e.g. connection refused) - - :param host: Hostname or IP to connect to. - :param port: TCP port. - :param starttls: openssl ``-starttls`` mode (e.g. ``'smtp'``), or ``None`` - for raw TLS. Must be one of the modes recognised by openssl s_client. - :param sni_hostname: Hostname to send as TLS SNI via ``-servername``, or ``None``. - :param timeout: Connection timeout in seconds; the subprocess is given - ``timeout + 2`` seconds to allow TLS handshake completion. - :returns: Combined stdout+stderr from ``openssl s_client``, or ``None`` on failure. - :rtype: str | None - :raises ValueError: If ``starttls`` is not a recognised openssl STARTTLS mode. - """ - if starttls is not None and starttls not in _VALID_STARTTLS: - raise ValueError( - f"Invalid starttls mode {starttls!r}. " - f"Must be one of: {', '.join(sorted(_VALID_STARTTLS))}" - ) - - ok, _ = check_openssl() - if not ok: - return None - - try: - _validate_target(host, port) - except ValueError: - return None - - try: - addr = ipaddress.ip_address(host) - connect_str = f"[{host}]:{port}" if addr.version == 6 else f"{host}:{port}" - except ValueError: - connect_str = f"{host}:{port}" - - groups_str = ":".join(PROBE_GROUPS) - cmd = [ - OPENSSL_BINARY, - "s_client", - "-connect", connect_str, - "-groups", groups_str, - "-ign_eof", - ] - if starttls is not None: - cmd.extend(["-starttls", starttls]) - if sni_hostname: - cmd.extend(["-servername", sni_hostname]) - - logger.debug( - "probe_raw %s:%d starttls=%s sni=%s — cmd: %s", - host, port, starttls, sni_hostname, " ".join(cmd), - ) - try: - proc = subprocess.run( - cmd, - input=b"QUIT\r\n", - capture_output=True, - timeout=timeout + 2, - ) - return (proc.stdout + proc.stderr).decode("utf-8", errors="replace") - except (subprocess.TimeoutExpired, OSError): - return None - - _PEM_CERT_RE: re.Pattern[str] = re.compile( r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", re.DOTALL, @@ -231,38 +148,6 @@ def _parse_certificate(pem: str) -> CertificateInfo | None: return CertificateInfo(key_type="unknown", key_size=None, curve=None) -def fetch_certificate( - host: str, - port: int, - *, - starttls: str | None = None, - timeout: float = 10.0, -) -> CertificateInfo | None: - """Fetch the leaf certificate of *host*:*port* and return its key details. - - Runs a second ``openssl s_client`` probe without ``-brief`` (via - :func:`probe_raw`) because ``-brief`` suppresses certificate output, then - parses the PEM block with pyca/cryptography. - - :param host: Hostname or IP to connect to. - :param port: TCP port. - :param starttls: openssl ``-starttls`` mode, or ``None`` for raw TLS. - :param timeout: Connection timeout in seconds. - :returns: Certificate key details, or ``None`` if the certificate could - not be retrieved or parsed. - :rtype: CertificateInfo | None - """ - output = probe_raw(host, port, starttls=starttls, timeout=timeout) - if output is None: - logger.warning("Certificate fetch failed for %s:%d", host, port) - return None - pem = _extract_pem_cert(output) - if pem is None: - logger.warning("No certificate in s_client output for %s:%d", host, port) - return None - return _parse_certificate(pem) - - def probe_tls( host: str, port: int, @@ -302,23 +187,17 @@ def probe_tls( return _probe_ssh(host, port, timeout) if detected == "ftp": - result = _probe_ftp(host, port, timeout) - else: - if detected: - logger.info( - "Banner detected '%s' for %s:%d — probing with -starttls %s", - detected, host, port, detected, - ) - result = _run_openssl(host, port, starttls=detected, timeout=timeout) - if detected: - result.detected_starttls = detected - - # PQC-02: fetch the leaf certificate with a second, non--brief probe. - # Failure here never fails the assessment — certificate stays None. - if result.ok: - result.certificate = fetch_certificate( - host, port, starttls=result.detected_starttls, timeout=timeout + return _probe_ftp(host, port, timeout) + + if detected: + logger.info( + "Banner detected '%s' for %s:%d — probing with -starttls %s", + detected, host, port, detected, ) + + result = _run_openssl(host, port, starttls=detected, timeout=timeout) + if detected: + result.detected_starttls = detected return result @@ -337,10 +216,12 @@ def _build_cmd(host: str, port: int, starttls: str | None) -> list[str]: connect_str = f"[{host}]:{port}" if addr.version == 6 else f"{host}:{port}" except ValueError: connect_str = f"{host}:{port}" + # No -brief: the full output carries the leaf-certificate PEM (PQC-02) + # alongside the protocol and negotiated-group lines, so a single probe + # serves both the key-exchange check and the certificate check. cmd = [ OPENSSL_BINARY, "s_client", - "-brief", "-groups", groups_str, "-connect", connect_str, ] @@ -361,7 +242,8 @@ def _run_openssl( :param port: TCP port. :param starttls: openssl ``-starttls`` mode (e.g. ``'smtp'``/``'ftp'``/``'xmpp'``), or ``None`` for raw TLS. :param timeout: Connection timeout in seconds. - :returns: Probe result; ``raw_output`` always populated from subprocess output. + :returns: Probe result; ``certificate`` populated when the output + contains a parseable leaf-certificate PEM. :rtype: TLSProbeResult """ cmd = _build_cmd(host, port, starttls) @@ -411,15 +293,24 @@ def _run_openssl( error=error, ) + # PQC-02: the same (non--brief) output carries the leaf-certificate PEM. + # Parse failure leaves certificate None — never an error. + pem = _extract_pem_cert(combined) + certificate = _parse_certificate(pem) if pem else None + if certificate is None: + logger.warning("No parseable certificate in s_client output for %s:%d", host, port) + logger.info( - "Probe complete for %s:%d — version=%s group=%s", + "Probe complete for %s:%d — version=%s group=%s cert=%s", host, port, tls_version, negotiated_group, + certificate.key_type if certificate else None, ) return TLSProbeResult( host=host, port=port, tls_version=tls_version, negotiated_group=negotiated_group, + certificate=certificate, ) @@ -686,12 +577,20 @@ def _fingerprint_banner(output: str) -> str | None: return None +_PROTOCOL_LINE_RE: re.Pattern[str] = re.compile(r"^Protocol(?: version)?\s*:\s*(\S+)") + + def _parse_openssl_output(output: str) -> tuple[str | None, str | None]: - """Parse ``openssl s_client -brief`` output. + """Parse ``openssl s_client`` output (with or without ``-brief``). + + Confirmed output formats (OpenSSL 3.6.3, 2026-08-17): + -brief: Protocol version: TLSv1.3 + no -brief: Protocol: TLSv1.3 (summary line) + Protocol : TLSv1.2 (SSL-Session block, indented) + both: Negotiated TLS1.3 group: X25519MLKEM768 - Confirmed output format (OpenSSL 3.6, 2026-04-28): - Protocol version: TLSv1.3 - Negotiated TLS1.3 group: X25519MLKEM768 + ``ALPN protocol:`` lines do not match — the pattern is anchored at the + start of the stripped line. :param output: Combined stdout+stderr from the subprocess. :returns: ``(tls_version, negotiated_group)`` — either may be None. @@ -702,9 +601,12 @@ def _parse_openssl_output(output: str) -> tuple[str | None, str | None]: for line in output.splitlines(): stripped = line.strip() - if stripped.startswith("Protocol version:"): - tls_version = stripped.split(":", 1)[1].strip() - elif stripped.startswith("Negotiated TLS1.3 group:"): + if tls_version is None: + m = _PROTOCOL_LINE_RE.match(stripped) + if m: + tls_version = m.group(1) + continue + if stripped.startswith("Negotiated TLS1.3 group:"): negotiated_group = stripped.split(":", 1)[1].strip() if tls_version and negotiated_group: break diff --git a/quantumvalidator/verdict.py b/quantumvalidator/verdict.py index 43ad519..2f11f69 100644 --- a/quantumvalidator/verdict.py +++ b/quantumvalidator/verdict.py @@ -156,7 +156,11 @@ def _build_certificate_check(certificate: CertificateInfo | None) -> CheckResult ) if certificate.key_type == "RSA": - value = f"RSA-{certificate.key_size}" + value = ( + f"RSA-{certificate.key_size}" + if certificate.key_size is not None + else "RSA-unknown" + ) if certificate.key_size is not None and certificate.key_size >= RSA_MIN_KEY_SIZE: return CheckResult( name="certificate_key", diff --git a/tests/test_cli.py b/tests/test_cli.py index 45dd247..abfe0de 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -228,6 +228,19 @@ def test_json_verdict_is_string(self): result = runner.invoke(app, ["check", "--json", "example.com"]) data = json.loads(result.output) assert isinstance(data["verdict"], str) + + def test_json_long_strings_not_wrapped(self): + # Regression: Rich hard-wraps at terminal width unless soft_wrap is + # set, inserting raw newlines inside JSON strings — invalid JSON. + long_reason = "R" * 400 # longer than any terminal width in CI + report = _make_safe_report() + report.checks.append( + CheckResult("certificate_key", Status.FAIL, "RSA-1024", long_reason, None) + ) + with _patch_assess(report): + result = runner.invoke(app, ["check", "--json", "example.com"]) + data = json.loads(result.output) + assert data["checks"][-1]["reason"] == long_reason assert data["verdict"] == "SAFE" def test_json_checks_have_required_keys(self): diff --git a/tests/test_tls_utils.py b/tests/test_tls_utils.py index 40b9905..5eb0474 100644 --- a/tests/test_tls_utils.py +++ b/tests/test_tls_utils.py @@ -21,8 +21,6 @@ _probe_ssh, _read_server_banner, _read_ssh_packet, - fetch_certificate, - probe_raw, probe_tls, ) @@ -156,6 +154,40 @@ "---\n" ) +# Full single-probe output (no -brief), confirmed against OpenSSL 3.6.3 +# (2026-08-17): PEM + summary lines in one output. +_FULL_PQC_OUTPUT = ( + "Connecting to 192.0.2.1\n" + "CONNECTED(00000003)\n" + "---\n" + "Certificate chain\n" + " 0 s:CN=test.example.com\n" + "---\n" + "Server certificate\n" + f"{_RSA2048_PEM}\n" + "subject=CN=test.example.com\n" + "---\n" + "Negotiated TLS1.3 group: X25519MLKEM768\n" + "---\n" + "New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384\n" + "Protocol: TLSv1.3\n" + "---\n" +) + +_FULL_TLS12_OUTPUT = ( + "CONNECTED(00000003)\n" + "---\n" + "Server certificate\n" + f"{_RSA2048_PEM}\n" + "---\n" + "New, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256\n" + "Protocol: TLSv1.2\n" + "SSL-Session:\n" + " Protocol : TLSv1.2\n" + " Cipher : ECDHE-RSA-AES128-GCM-SHA256\n" + "---\n" +) + def _make_sock_ctx(data: bytes = b"") -> MagicMock: """Return a MagicMock context-manager that mimics socket.create_connection.""" @@ -292,6 +324,29 @@ def test_secp256r1mlkem768_group(self): _, group = _parse_openssl_output(output) assert group == "SecP256r1MLKEM768" + def test_non_brief_summary_protocol_line(self): + # OpenSSL 3.6.3 without -brief: "Protocol: TLSv1.3" + version, group = _parse_openssl_output(_FULL_PQC_OUTPUT) + assert version == "TLSv1.3" + assert group == "X25519MLKEM768" + + def test_non_brief_session_block_protocol_line(self): + # SSL-Session block variant: " Protocol : TLSv1.2" + output = "SSL-Session:\n Protocol : TLSv1.2\n" + version, group = _parse_openssl_output(output) + assert version == "TLSv1.2" + assert group is None + + def test_alpn_protocol_line_not_matched(self): + output = "ALPN protocol: h2\n" + version, _ = _parse_openssl_output(output) + assert version is None + + def test_first_protocol_line_wins(self): + output = "Protocol: TLSv1.3\nSSL-Session:\n Protocol : TLSv1.2\n" + version, _ = _parse_openssl_output(output) + assert version == "TLSv1.3" + # --------------------------------------------------------------------------- # _build_cmd @@ -303,7 +358,8 @@ def test_https_cmd_structure(self): cmd = _build_cmd("example.com", 443, None) assert cmd[0] == OPENSSL_BINARY assert "s_client" in cmd - assert "-brief" in cmd + # No -brief: the full output must carry the certificate PEM (PQC-02). + assert "-brief" not in cmd assert "-connect" in cmd assert "example.com:443" in cmd @@ -463,25 +519,39 @@ def capture(*a, **kw): monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", capture) probe_tls("example.com", 443) - # First call is the -brief probe (empty stdin); second is the - # certificate fetch via probe_raw (sends QUIT). - assert captured_inputs[0] == b"" - assert captured_inputs[1] == b"QUIT\r\n" + # Single probe, empty stdin so s_client exits at EOF (no -ign_eof). + assert captured_inputs == [b""] - def test_certificate_attached_on_success(self, monkeypatch): - outputs = [_PQC_OUTPUT, _RAW_CERT_OUTPUT] + def test_certificate_attached_from_single_probe(self, monkeypatch): + calls: list = [] - def fake_run(*a, **kw): - return _make_proc(stdout=outputs.pop(0)) + def fake_run(cmd, **kw): + calls.append(list(cmd)) + return _make_proc(stdout=_FULL_PQC_OUTPUT) monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", fake_run) result = probe_tls("example.com", 443) assert result.ok + assert len(calls) == 1 # one probe serves KEX and certificate checks + assert result.tls_version == "TLSv1.3" + assert result.negotiated_group == "X25519MLKEM768" + assert result.certificate == CertificateInfo( + key_type="RSA", key_size=2048, curve=None + ) + + def test_tls12_certificate_attached(self, monkeypatch): + monkeypatch.setattr( + "quantumvalidator.tls_utils.subprocess.run", + lambda *a, **kw: _make_proc(stdout=_FULL_TLS12_OUTPUT), + ) + result = probe_tls("example.com", 443) + assert result.ok + assert result.tls_version == "TLSv1.2" assert result.certificate == CertificateInfo( key_type="RSA", key_size=2048, curve=None ) - def test_certificate_none_when_fetch_has_no_pem(self, monkeypatch): + def test_certificate_none_when_output_has_no_pem(self, monkeypatch): monkeypatch.setattr( "quantumvalidator.tls_utils.subprocess.run", lambda *a, **kw: _make_proc(stdout=_PQC_OUTPUT), @@ -490,7 +560,7 @@ def test_certificate_none_when_fetch_has_no_pem(self, monkeypatch): assert result.ok assert result.certificate is None - def test_no_certificate_fetch_on_probe_error(self, monkeypatch): + def test_no_certificate_on_probe_error(self, monkeypatch): calls: list = [] def fake_run(cmd, **kw): @@ -713,8 +783,8 @@ def test_sieve_banner_embedded_in_output(self): class TestBannerFirstProbe: - """Banner-first probe: one socket read, one -brief openssl call, and on - success one more openssl call (no -brief) for the certificate fetch.""" + """Banner-first probe: one socket read, one openssl call (no -brief) that + serves both the key-exchange and certificate checks — two connections.""" def test_no_banner_uses_raw_tls(self, monkeypatch): calls: list = [] @@ -730,10 +800,9 @@ def fake_run(cmd, **kw): ) result = probe_tls("cloudflare.com", 443) assert result.ok - assert len(calls) == 2 + assert len(calls) == 1 assert "-starttls" not in calls[0] - assert "-brief" in calls[0] - assert "-brief" not in calls[1] # certificate fetch needs cert output + assert "-brief" not in calls[0] # full output carries the cert PEM assert result.detected_starttls is None def test_smtp_banner_single_starttls_probe(self, monkeypatch): @@ -749,10 +818,9 @@ def fake_run(cmd, **kw): lambda *a, **kw: _make_sock_ctx(b"220 smtp.gmail.com ESMTP\r\n"), ) result = probe_tls("smtp.gmail.com", 587) - assert len(calls) == 2 + assert len(calls) == 1 assert "-starttls" in calls[0] assert "smtp" in calls[0] - assert "-starttls" in calls[1] # certificate fetch reuses the mode assert result.detected_starttls == "smtp" assert result.ok @@ -769,7 +837,7 @@ def fake_run(cmd, **kw): lambda *a, **kw: _make_sock_ctx(b"* OK Dovecot ready.\r\n"), ) result = probe_tls("mail.example.com", 143) - assert len(calls) == 2 + assert len(calls) == 1 assert result.detected_starttls == "imap" assert result.ok @@ -786,7 +854,7 @@ def fake_run(cmd, **kw): lambda *a, **kw: _make_sock_ctx(b"+OK Dovecot ready.\r\n"), ) result = probe_tls("mail.example.com", 110) - assert len(calls) == 2 + assert len(calls) == 1 assert result.detected_starttls == "pop3" assert result.ok @@ -858,7 +926,7 @@ def fake_socket(*a, **kw): monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", fake_run) monkeypatch.setattr("quantumvalidator.tls_utils.socket.create_connection", fake_socket) result = probe_tls("ftp.example.com", 21) - assert len(calls) == 2 + assert len(calls) == 1 assert "-starttls" in calls[0] assert "ftp" in calls[0] assert result.detected_starttls == "ftp" @@ -899,7 +967,7 @@ def fake_run(cmd, **kw): lambda *a, **kw: _make_sock_ctx(b"220 mail.example.com LMTP Postfix\r\n"), ) result = probe_tls("lmtp.example.com", 24) - assert len(calls) == 2 + assert len(calls) == 1 assert "-starttls" in calls[0] assert "lmtp" in calls[0] assert result.detected_starttls == "lmtp" @@ -918,7 +986,7 @@ def fake_run(cmd, **kw): lambda *a, **kw: _make_sock_ctx(b"200 news.example.com InterNetNews ready\r\n"), ) result = probe_tls("news.example.com", 119) - assert len(calls) == 2 + assert len(calls) == 1 assert "-starttls" in calls[0] assert "nntp" in calls[0] assert result.detected_starttls == "nntp" @@ -937,7 +1005,7 @@ def fake_run(cmd, **kw): lambda *a, **kw: _make_sock_ctx(b'"IMPLEMENTATION" "Dovecot Pigeonhole"\r\n'), ) result = probe_tls("sieve.example.com", 4190) - assert len(calls) == 2 + assert len(calls) == 1 assert "-starttls" in calls[0] assert "sieve" in calls[0] assert result.detected_starttls == "sieve" @@ -1287,156 +1355,7 @@ def fake_recv(n: int) -> bytes: # --------------------------------------------------------------------------- -# probe_raw -# --------------------------------------------------------------------------- - - -def _make_raw_proc(stdout: bytes = b"", stderr: bytes = b"", returncode: int = 0): - proc = MagicMock() - proc.stdout = stdout - proc.stderr = stderr - proc.returncode = returncode - return proc - - -class TestProbeRaw: - def test_returns_combined_output_on_success(self, monkeypatch): - monkeypatch.setattr( - "quantumvalidator.tls_utils.check_openssl", - lambda: (True, "openssl 3.0.0"), - ) - stdout = b"Connecting to 1.2.3.4\nCONNECTION ESTABLISHED\n" - stderr = b"depth=0 CN=example.com\n" - monkeypatch.setattr( - "quantumvalidator.tls_utils.subprocess.run", - lambda *a, **kw: _make_raw_proc(stdout, stderr), - ) - result = probe_raw("example.com", 25) - assert result == (stdout + stderr).decode("utf-8", errors="replace") - - def test_returns_none_when_openssl_missing(self, monkeypatch): - monkeypatch.setattr( - "quantumvalidator.tls_utils.check_openssl", - lambda: (False, "openssl not found"), - ) - assert probe_raw("example.com", 25) is None - - @pytest.fixture - def cmd_capture(self, monkeypatch): - """Monkeypatch subprocess.run and check_openssl; return captured call data.""" - monkeypatch.setattr( - "quantumvalidator.tls_utils.check_openssl", - lambda: (True, "openssl 3.0.0"), - ) - captured: dict = {} - - def fake_run(cmd, **kw): - captured["cmd"] = cmd - captured["kwargs"] = kw - return _make_raw_proc() - - monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", fake_run) - return captured - - def test_returns_none_on_timeout(self, monkeypatch): - monkeypatch.setattr( - "quantumvalidator.tls_utils.check_openssl", - lambda: (True, "openssl 3.0.0"), - ) - - def _raise(*a, **kw): - raise subprocess.TimeoutExpired(cmd="openssl", timeout=12) - - monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", _raise) - assert probe_raw("example.com", 25) is None - - def test_returns_none_on_oserror(self, monkeypatch): - monkeypatch.setattr( - "quantumvalidator.tls_utils.check_openssl", - lambda: (True, "openssl 3.0.0"), - ) - - def _raise(*a, **kw): - raise OSError("connection refused") - - monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", _raise) - assert probe_raw("example.com", 25) is None - - def test_raises_valueerror_for_invalid_starttls(self): - with pytest.raises(ValueError, match="Invalid starttls mode"): - probe_raw("example.com", 25, starttls="ftp2") - - def test_returns_none_for_invalid_port(self, monkeypatch): - # _validate_target raises ValueError for port 0; probe_raw catches it. - # Mock check_openssl so the test exercises _validate_target regardless - # of whether openssl is installed on the CI machine. - monkeypatch.setattr( - "quantumvalidator.tls_utils.check_openssl", - lambda: (True, "openssl 3.0.0"), - ) - assert probe_raw("example.com", 0) is None - - def test_starttls_smtp_in_cmd(self, cmd_capture): - probe_raw("example.com", 25, starttls="smtp") - assert "-starttls" in cmd_capture["cmd"] - assert "smtp" in cmd_capture["cmd"] - - def test_sni_hostname_adds_servername(self, cmd_capture): - probe_raw("1.2.3.4", 25, sni_hostname="mail.example.com") - assert "-servername" in cmd_capture["cmd"] - assert "mail.example.com" in cmd_capture["cmd"] - - def test_no_servername_when_sni_none(self, cmd_capture): - probe_raw("example.com", 25) - assert "-servername" not in cmd_capture["cmd"] - - def test_brief_not_in_cmd(self, cmd_capture): - probe_raw("example.com", 25) - assert "-brief" not in cmd_capture["cmd"] - - def test_ign_eof_in_cmd(self, cmd_capture): - probe_raw("example.com", 25) - assert "-ign_eof" in cmd_capture["cmd"] - - def test_groups_in_cmd(self, cmd_capture): - probe_raw("example.com", 25) - assert "-groups" in cmd_capture["cmd"] - groups_idx = cmd_capture["cmd"].index("-groups") - groups_val = cmd_capture["cmd"][groups_idx + 1] - assert all(g in groups_val for g in PROBE_GROUPS) - - def test_ipv6_bracket_notation(self, cmd_capture): - probe_raw("::1", 25) - connect_idx = cmd_capture["cmd"].index("-connect") - connect_val = cmd_capture["cmd"][connect_idx + 1] - assert connect_val.startswith("[::1]:") - - def test_input_is_quit_crlf(self, cmd_capture): - probe_raw("example.com", 25) - assert cmd_capture["kwargs"].get("input") == b"QUIT\r\n" - - def test_no_starttls_flag_when_none(self, cmd_capture): - probe_raw("example.com", 443) - assert "-starttls" not in cmd_capture["cmd"] - - def test_non_utf8_output_decoded_with_replace(self, monkeypatch): - monkeypatch.setattr( - "quantumvalidator.tls_utils.check_openssl", - lambda: (True, "openssl 3.0.0"), - ) - raw_bytes = b"ok \xff\xfe output" - monkeypatch.setattr( - "quantumvalidator.tls_utils.subprocess.run", - lambda *a, **kw: _make_raw_proc(stdout=raw_bytes), - ) - result = probe_raw("example.com", 443) - assert result is not None - assert "ok" in result - - - -# --------------------------------------------------------------------------- -# _extract_pem_cert / _parse_certificate / fetch_certificate (PQC-02) +# _extract_pem_cert / _parse_certificate (PQC-02) # --------------------------------------------------------------------------- @@ -1508,39 +1427,3 @@ def test_unknown_key_type(self, monkeypatch): ) info = _parse_certificate(_RSA2048_PEM) assert info == CertificateInfo(key_type="unknown", key_size=None, curve=None) - - -class TestFetchCertificate: - def test_returns_key_details(self, monkeypatch): - monkeypatch.setattr( - "quantumvalidator.tls_utils.subprocess.run", - lambda *a, **kw: _make_raw_proc(stdout=_RAW_CERT_OUTPUT.encode()), - ) - info = fetch_certificate("example.com", 443) - assert info == CertificateInfo(key_type="RSA", key_size=2048, curve=None) - - def test_probe_failure_returns_none(self, monkeypatch): - def raise_oserror(*a, **kw): - raise OSError("Network unreachable") - - monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", raise_oserror) - assert fetch_certificate("example.com", 443) is None - - def test_output_without_pem_returns_none(self, monkeypatch): - monkeypatch.setattr( - "quantumvalidator.tls_utils.subprocess.run", - lambda *a, **kw: _make_raw_proc(stdout=_PQC_OUTPUT.encode()), - ) - assert fetch_certificate("example.com", 443) is None - - def test_starttls_mode_passed_through(self, monkeypatch): - captured: dict = {} - - def capture(cmd, **kw): - captured["cmd"] = list(cmd) - return _make_raw_proc(stdout=_RAW_CERT_OUTPUT.encode()) - - monkeypatch.setattr("quantumvalidator.tls_utils.subprocess.run", capture) - fetch_certificate("mail.example.com", 587, starttls="smtp") - assert "-starttls" in captured["cmd"] - assert "smtp" in captured["cmd"] diff --git a/tests/test_verdict.py b/tests/test_verdict.py index a27cc72..95f94ff 100644 --- a/tests/test_verdict.py +++ b/tests/test_verdict.py @@ -161,6 +161,7 @@ def test_rsa_1024_fails(self): def test_rsa_unknown_size_fails(self): check = self._cert_check(CertificateInfo("RSA", None, None)) assert check.status == Status.FAIL + assert check.value == "RSA-unknown" def test_ec_p256_passes(self): check = self._cert_check(CertificateInfo("EC", 256, "secp256r1"))