From 172a8d37ec2f4bc0b92f43c976de25dec2fc29f0 Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Tue, 18 Aug 2026 12:36:29 +0200 Subject: [PATCH 1/4] feat: continue header assessment when TLS certificate verification fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Certificate failures (self-signed, expired, wrong host) previously aborted the scan with exit 2. assess() now retries the same https:// URL once with verification disabled and flags the report with the new HeadersReport.tls_verified field, surfaced as a warning banner in the terminal/file reports and as "tls_verified" in --json output. Certificate posture is tlsvalidator's domain; the module already grades headers fetched over plain HTTP, so an unverified TLS channel is no weaker than what is accepted elsewhere. The grade is unaffected. TLS errors still never trigger the http:// fallback, and if the unverified retry also fails the original TLS error is raised as before. fetch_headers() suppresses urllib3's InsecureRequestWarning for unverified fetches — the condition is reported explicitly instead. --- CHANGELOG.md | 26 ++++++- README.md | 4 +- docs/SECURITY_VERDICT.md | 8 +++ headersvalidator/__init__.py | 2 +- headersvalidator/assessor.py | 48 ++++++++++--- headersvalidator/cli.py | 1 + headersvalidator/http_utils.py | 30 ++++++--- headersvalidator/models.py | 7 ++ headersvalidator/reporter.py | 13 ++++ pyproject.toml | 2 +- tests/test_assessor.py | 119 +++++++++++++++++++++++++++++++-- tests/test_cli.py | 8 +++ tests/test_models.py | 14 ++++ tests/test_reporter.py | 23 +++++++ 14 files changed, 274 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb8a39..f392b72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,29 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- +## [0.4.0] — 2026-08-18 + +### Added +- `HeadersReport.tls_verified` field (default `True`): `False` when an HTTPS + response was fetched without certificate verification — automatic fallback + after a failed verification, or an explicit `verify_tls=False` / + `--no-tls-verify`. Exposed in the `--json` output and rendered as a warning + banner in the terminal report and in file exports. + +### Changed +- `assess()` no longer aborts on TLS certificate verification failures + (self-signed, expired, wrong host, …). It retries the same `https://` URL + once with verification disabled and flags the report with + `tls_verified=False` instead of exiting with code 2. Certificate posture + is tlsvalidator's domain; a bad certificate no longer blocks header + assessment. If even the unverified retry fails, the original TLS error is + raised as before. TLS errors still never trigger the `http://` fallback. +- `fetch_headers()` suppresses urllib3's `InsecureRequestWarning` for + unverified fetches — the unverified channel is already reported explicitly, + so the per-request stderr warning added only noise. + +--- + ## [0.3.2] — 2026-08-12 ### Removed @@ -310,7 +333,8 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html --- -[Unreleased]: https://github.com/NC3-TestingPlatform/headersvalidator/compare/v0.3.2...HEAD +[Unreleased]: https://github.com/NC3-TestingPlatform/headersvalidator/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/NC3-TestingPlatform/headersvalidator/compare/v0.3.2...v0.4.0 [0.3.2]: https://github.com/NC3-TestingPlatform/headersvalidator/compare/v0.3.1...v0.3.2 [0.3.1]: https://github.com/NC3-TestingPlatform/headersvalidator/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/NC3-TestingPlatform/headersvalidator/compare/v0.2.3...v0.3.0 diff --git a/README.md b/README.md index dec17b3..599ea36 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ $ headersvalidator check example.com ``` ![Python](https://img.shields.io/badge/python-%3E%3D3.11-blue) -![Tests](https://img.shields.io/badge/tests-421%20passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-435%20passing-brightgreen) ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen) ![License](https://img.shields.io/badge/license-GPLv3-lightgrey) @@ -302,7 +302,7 @@ pytest tests/test_checker.py pytest tests/test_checker.py::TestHSTS -v ``` -The test suite has **421 tests** and maintains **100% statement coverage**. +The test suite has **435 tests** and maintains **100% statement coverage**. All HTTP network I/O (`requests.head`, `requests.get`) is mocked at the `fetch_headers` boundary — no test touches a real server or the internet. diff --git a/docs/SECURITY_VERDICT.md b/docs/SECURITY_VERDICT.md index adbb7cc..143be2a 100644 --- a/docs/SECURITY_VERDICT.md +++ b/docs/SECURITY_VERDICT.md @@ -42,6 +42,14 @@ Total penalty is mapped to a letter grade: | 41 – 60 | **D** | Poor — significant security exposure | | > 60 | **F** | Critical — immediate remediation required | +> **TLS certificate failures do not affect the grade.** When certificate +> verification fails (self-signed, expired, wrong host, …), headersvalidator +> retries once without verification, completes the header assessment, and +> flags the report (`tls_verified: false`, plus a warning banner). Certificate +> posture is assessed and graded by **tlsvalidator**, not here — this module +> grades only the headers themselves, exactly as it already does for targets +> served over plain HTTP. + --- ## Severity levels explained diff --git a/headersvalidator/__init__.py b/headersvalidator/__init__.py index 2368b37..4542507 100644 --- a/headersvalidator/__init__.py +++ b/headersvalidator/__init__.py @@ -5,7 +5,7 @@ try: __version__ = version("headersvalidator") except PackageNotFoundError: # pragma: no cover – only when package not installed - __version__ = "0.3.2" + __version__ = "0.4.0" # NullHandler so library users who have not configured logging # do not see "No handler found" warnings (PEP 3118 / logging HOWTO). diff --git a/headersvalidator/assessor.py b/headersvalidator/assessor.py index 3a58b9b..30ffd0a 100644 --- a/headersvalidator/assessor.py +++ b/headersvalidator/assessor.py @@ -50,10 +50,15 @@ def assess( :param url: Target URL. Scheme is optional; ``https://`` is assumed. If the HTTPS connection is refused (port 443 closed), the function automatically retries over ``http://`` so that plain-HTTP→HTTPS - redirect chains are followed. TLS errors are never silently retried. + redirect chains are followed. If TLS certificate verification fails + (self-signed, expired, wrong host, …), the fetch is retried once + without verification and the report is flagged with + ``tls_verified=False`` — certificate posture is tlsvalidator's + domain; a bad certificate must not block header assessment. :param timeout: Per-request socket timeout in seconds. :param verify_tls: If ``False``, TLS certificate errors are ignored - (useful for internal or self-signed hosts). + (useful for internal or self-signed hosts). The report is flagged + with ``tls_verified=False`` when the target is HTTPS. :param user_agent: Override the default headersvalidator User-Agent string. :param progress_cb: Optional callable invoked with a short status string at key milestones during the assessment (for CLI spinner integration). @@ -69,19 +74,43 @@ def assess( progress_cb(f"Fetching headers for {url} …") # ---- Network I/O (single point — easy to mock in tests) ---------- - # If https:// fails with a connection error (port closed, not an TLS error), + # If https:// fails with a connection error (port closed, not a TLS error), # retry with http:// so that plain-HTTP→HTTPS redirect chains are followed. + # If certificate verification fails, retry once without verification and + # flag the report — the module already grades headers fetched over plain + # HTTP, so an unverified TLS channel is no weaker than what is accepted + # elsewhere, and certificate posture belongs to tlsvalidator. + tls_verified = True try: response = fetch_headers( url, timeout=timeout, verify_tls=verify_tls, user_agent=user_agent ) + if not verify_tls and url.startswith("https://"): + tls_verified = False + except requests.exceptions.SSLError as ssl_exc: + # SSLError must be caught before ConnectionError (its parent class): + # a TLS failure means the host is reachable, so an http:// fallback + # would assess the wrong endpoint. + if not verify_tls: + # Verification was already off — retrying identically is futile. + raise + logger.warning( + "TLS certificate verification failed for %s — " + "retrying without verification: %s", + url, + ssl_exc, + ) + if progress_cb: + progress_cb("Certificate verification failed — retrying unverified …") + try: + response = fetch_headers( + url, timeout=timeout, verify_tls=False, user_agent=user_agent + ) + except requests.exceptions.RequestException: + raise ssl_exc # re-raise the original TLS error + tls_verified = False except requests.exceptions.ConnectionError as exc: - # SSLError is a subclass of ConnectionError — do NOT fall back for TLS - # errors; those indicate an active connection that failed at the TLS - # layer and should be surfaced as-is. - if isinstance(exc, requests.exceptions.SSLError) or not url.startswith( - "https://" - ): + if not url.startswith("https://"): raise http_url = "http://" + url[len("https://") :] logger.info( @@ -113,6 +142,7 @@ def assess( status_code=response.status_code, final_url=response.url, results=results, + tls_verified=tls_verified, ) logger.info( diff --git a/headersvalidator/cli.py b/headersvalidator/cli.py index 37ec1ea..2d6255f 100644 --- a/headersvalidator/cli.py +++ b/headersvalidator/cli.py @@ -248,6 +248,7 @@ def _print_json(report) -> None: "status_code": report.status_code, "status": report.status.value, "score": report.score, + "tls_verified": report.tls_verified, "results": [ { "name": r.name, diff --git a/headersvalidator/http_utils.py b/headersvalidator/http_utils.py index e819739..668b932 100644 --- a/headersvalidator/http_utils.py +++ b/headersvalidator/http_utils.py @@ -8,10 +8,12 @@ from __future__ import annotations import logging +import warnings import requests from requests import Response from requests.exceptions import RequestException +from urllib3.exceptions import InsecureRequestWarning logger = logging.getLogger("headersvalidator") @@ -56,23 +58,29 @@ def fetch_headers( logger.debug("HEAD %s (timeout=%.1fs)", url, timeout) try: - response = requests.head( - url, - headers=headers, - timeout=timeout, - allow_redirects=True, - verify=verify_tls, - ) - if response.status_code == 405: - logger.debug("HEAD returned 405; retrying with GET %s", url) - response = requests.get( + with warnings.catch_warnings(): + if not verify_tls: + # An unverified fetch is a deliberate, flagged decision + # (assessor fallback or --no-tls-verify); urllib3's per-request + # InsecureRequestWarning on stderr adds nothing to that. + warnings.simplefilter("ignore", InsecureRequestWarning) + response = requests.head( url, headers=headers, timeout=timeout, allow_redirects=True, verify=verify_tls, - stream=True, # Don't download the body ) + if response.status_code == 405: + logger.debug("HEAD returned 405; retrying with GET %s", url) + response = requests.get( + url, + headers=headers, + timeout=timeout, + allow_redirects=True, + verify=verify_tls, + stream=True, # Don't download the body + ) logger.info( "Fetched %s → %s (final URL: %s)", url, diff --git a/headersvalidator/models.py b/headersvalidator/models.py index eeda795..e8d36df 100644 --- a/headersvalidator/models.py +++ b/headersvalidator/models.py @@ -93,6 +93,13 @@ class HeadersReport: results: list[HeaderResult] = field(default_factory=list) """One HeaderResult per evaluated header.""" + tls_verified: bool = True + """False when an HTTPS response was fetched without certificate + verification — automatic fallback after a failed verification, or an + explicit ``verify_tls=False``. Headers received over an unverified + channel could in principle be attacker-influenced; the grade is not + affected (certificate posture is tlsvalidator's domain).""" + # ------------------------------------------------------------------ # Aggregate convenience properties # ------------------------------------------------------------------ diff --git a/headersvalidator/reporter.py b/headersvalidator/reporter.py index 8dc2f74..f720a4e 100644 --- a/headersvalidator/reporter.py +++ b/headersvalidator/reporter.py @@ -48,6 +48,13 @@ VerdictSeverity.INFO: "dim", } +# Plain-text body of the unverified-TLS warning banner; also used to size +# file exports so the banner is never wrapped. +_TLS_UNVERIFIED_NOTICE = ( + "⚠ TLS certificate verification failed or was disabled — " + "headers were fetched over an unverified HTTPS connection." +) + # Grade letter → Rich colour string _GRADE_STYLE: dict[str, str] = { "A+": "bold bright_green", @@ -80,6 +87,11 @@ def print_full_report(report: HeadersReport, console: Console | None = None) -> f" [dim]→ redirected to[/dim] [cyan]{report.final_url}[/cyan]", highlight=False, ) + if not report.tls_verified: + con.print( + f" [bold yellow]{_TLS_UNVERIFIED_NOTICE}[/bold yellow]", + highlight=False, + ) con.print() _print_results_table(report, con) actions = extract_verdict_actions(report) @@ -287,6 +299,7 @@ def save_report(path: str, report: HeadersReport) -> None: header_floor = max( len(f"HTTP Headers Report — {report.url}"), len(f" → redirected to {report.final_url}"), + len(f" {_TLS_UNVERIFIED_NOTICE}") if not report.tls_verified else 0, ) + 4 width = max(content_width, header_floor) file_console = Console(record=True, highlight=False, width=width, file=StringIO()) diff --git a/pyproject.toml b/pyproject.toml index 3f6b77d..89f8c17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "headersvalidator" -version = "0.3.2" +version = "0.4.0" description = "HTTP response header validator — RFC 9110, RFC 9111, OWASP, IANA" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_assessor.py b/tests/test_assessor.py index 0bbdb6c..41e9718 100644 --- a/tests/test_assessor.py +++ b/tests/test_assessor.py @@ -192,19 +192,23 @@ def mock_fetch(url, **kwargs): assert calls == ["https://example.com", "http://example.com"] assert report is not None - def test_ssl_error_is_not_retried(self, monkeypatch): - """SSLError must not trigger an http:// fallback — surface the TLS error.""" + def test_ssl_error_is_not_retried_over_http(self, monkeypatch): + """SSLError must not trigger an http:// fallback — the host is + reachable; the retry stays on https:// with verification off.""" calls = [] - def mock_fetch(url, **kwargs): - calls.append(url) + def mock_fetch(url, verify_tls=True, **kwargs): + calls.append((url, verify_tls)) raise requests.exceptions.SSLError("cert verify failed") monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) with pytest.raises(requests.exceptions.SSLError): assess("https://example.com") - # Only the HTTPS attempt — no http:// retry. - assert calls == ["https://example.com"] + # Verified attempt, then one unverified retry — never http://. + assert calls == [ + ("https://example.com", True), + ("https://example.com", False), + ] def test_https_connection_error_http_also_fails_raises_original(self, monkeypatch): """When both HTTPS and HTTP fail, the original HTTPS error is re-raised.""" @@ -238,6 +242,109 @@ def mock_fetch(url, **kwargs): assert calls == ["http://example.com"] +# --------------------------------------------------------------------------- +# Unverified-TLS fallback on certificate failure +# --------------------------------------------------------------------------- + + +class TestAssessTlsVerifyFallback: + def test_cert_failure_retries_unverified_and_flags_report(self, monkeypatch): + """Certificate failure → one unverified https:// retry, flagged report.""" + response = make_response(SECURE_HEADERS) + calls = [] + + def mock_fetch(url, verify_tls=True, **kwargs): + calls.append((url, verify_tls)) + if verify_tls: + raise requests.exceptions.SSLError("self-signed certificate") + return response + + monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) + report = assess("https://example.com") + assert calls == [ + ("https://example.com", True), + ("https://example.com", False), + ] + assert report.tls_verified is False + + def test_verified_fetch_reports_tls_verified_true(self, monkeypatch): + report = _mock_assess(monkeypatch, SECURE_HEADERS) + assert report.tls_verified is True + + def test_unverified_retry_failure_raises_original_ssl_error(self, monkeypatch): + """If even the unverified retry fails, the original TLS error surfaces.""" + ssl_error = requests.exceptions.SSLError("cert verify failed") + + def mock_fetch(url, verify_tls=True, **kwargs): + if verify_tls: + raise ssl_error + raise requests.ConnectionError("connection dropped") + + monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) + with pytest.raises(requests.exceptions.SSLError) as exc_info: + assess("https://example.com") + assert exc_info.value is ssl_error + + def test_ssl_error_with_verify_tls_false_not_retried(self, monkeypatch): + """A TLS failure with verification already off cannot be retried away.""" + calls = [] + + def mock_fetch(url, **kwargs): + calls.append(url) + raise requests.exceptions.SSLError("handshake failure") + + monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) + with pytest.raises(requests.exceptions.SSLError): + assess("https://example.com", verify_tls=False) + assert calls == ["https://example.com"] + + def test_explicit_verify_tls_false_flags_https_report(self, monkeypatch): + response = make_response(SECURE_HEADERS) + monkeypatch.setattr( + "headersvalidator.assessor.fetch_headers", lambda *a, **kw: response + ) + report = assess("https://example.com", verify_tls=False) + assert report.tls_verified is False + + def test_explicit_verify_tls_false_http_url_not_flagged(self, monkeypatch): + """No TLS involved for an http:// target — nothing to flag.""" + response = make_response(SECURE_HEADERS, url="http://example.com") + monkeypatch.setattr( + "headersvalidator.assessor.fetch_headers", lambda *a, **kw: response + ) + report = assess("http://example.com", verify_tls=False) + assert report.tls_verified is True + + def test_http_port_fallback_report_not_flagged(self, monkeypatch): + """The port-closed HTTPS→HTTP fallback makes no TLS claim.""" + response = make_response(SECURE_HEADERS, url="http://example.com") + + def mock_fetch(url, **kwargs): + if url.startswith("https://"): + raise requests.ConnectionError("refused") + return response + + monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) + report = assess("https://example.com") + assert report.tls_verified is True + + def test_progress_cb_reports_fallback(self, monkeypatch): + response = make_response(SECURE_HEADERS) + + def mock_fetch(url, verify_tls=True, **kwargs): + if verify_tls: + raise requests.exceptions.SSLError("self-signed certificate") + return response + + monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) + messages = [] + assess("https://example.com", progress_cb=messages.append) + assert messages == [ + "Fetching headers for https://example.com …", + "Certificate verification failed — retrying unverified …", + ] + + # --------------------------------------------------------------------------- # Specific header scenarios end-to-end # --------------------------------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py index e7d73fb..7969b84 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -152,6 +152,14 @@ def test_json_result_fields(self): ): assert key in first_result, f"Missing key {key!r} in result" + def test_json_includes_tls_verified(self): + report = _make_report(Status.PASS) + report.tls_verified = False + with _patch_assess(report): + result = runner.invoke(app, ["check", "--json", "https://example.com"]) + data = json.loads(result.output) + assert data["tls_verified"] is False + def test_json_status_values_are_strings(self): report = _make_report(Status.PASS) with _patch_assess(report): diff --git a/tests/test_models.py b/tests/test_models.py index 656037d..c2f4407 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -68,6 +68,20 @@ def _report(self, *status_pairs) -> HeadersReport: results=results, ) + def test_tls_verified_defaults_true(self): + report = self._report(("H1", Status.PASS)) + assert report.tls_verified is True + + def test_tls_verified_explicit_false(self): + report = HeadersReport( + url="https://ex.com", + status_code=200, + final_url="https://ex.com", + results=[self._result("H1", Status.PASS)], + tls_verified=False, + ) + assert report.tls_verified is False + def test_status_all_pass(self): report = self._report(("H1", Status.PASS), ("H2", Status.PASS)) assert report.status == Status.PASS diff --git a/tests/test_reporter.py b/tests/test_reporter.py index 6ef7f46..45f29f4 100644 --- a/tests/test_reporter.py +++ b/tests/test_reporter.py @@ -115,6 +115,17 @@ def test_no_redirect_line_when_final_url_matches(self): output = _capture(print_full_report, report) assert "redirected to" not in output + def test_shows_banner_when_tls_unverified(self): + report = _make_report(("X-Frame-Options", Status.PASS)) + report.tls_verified = False + output = _capture(print_full_report, report) + assert "unverified HTTPS connection" in output + + def test_no_banner_when_tls_verified(self): + report = _make_report(("X-Frame-Options", Status.PASS)) + output = _capture(print_full_report, report) + assert "unverified" not in output + # --------------------------------------------------------------------------- # print_results_table @@ -306,6 +317,18 @@ def test_saves_svg(self, tmp_path): assert "X-Frame-Options" in content assert " Date: Tue, 18 Aug 2026 12:45:25 +0200 Subject: [PATCH 2/4] fix: derive tls_verified from the final post-redirect URL verify=False propagates across the redirect chain, so the flag must reflect the channel the graded headers actually arrived on: an unverified http:// request landing on https:// is now flagged, and an unverified https:// retry landing on http:// is not. Also fix MD022 blank lines in the 0.4.0 CHANGELOG section. (CodeRabbit review) --- CHANGELOG.md | 2 ++ README.md | 4 ++-- headersvalidator/assessor.py | 14 +++++++++----- tests/test_assessor.py | 24 ++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f392b72..dc0c145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ## [0.4.0] — 2026-08-18 ### Added + - `HeadersReport.tls_verified` field (default `True`): `False` when an HTTPS response was fetched without certificate verification — automatic fallback after a failed verification, or an explicit `verify_tls=False` / @@ -21,6 +22,7 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html banner in the terminal report and in file exports. ### Changed + - `assess()` no longer aborts on TLS certificate verification failures (self-signed, expired, wrong host, …). It retries the same `https://` URL once with verification disabled and flags the report with diff --git a/README.md b/README.md index 599ea36..7ed5a87 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ $ headersvalidator check example.com ``` ![Python](https://img.shields.io/badge/python-%3E%3D3.11-blue) -![Tests](https://img.shields.io/badge/tests-435%20passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-437%20passing-brightgreen) ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen) ![License](https://img.shields.io/badge/license-GPLv3-lightgrey) @@ -302,7 +302,7 @@ pytest tests/test_checker.py pytest tests/test_checker.py::TestHSTS -v ``` -The test suite has **435 tests** and maintains **100% statement coverage**. +The test suite has **437 tests** and maintains **100% statement coverage**. All HTTP network I/O (`requests.head`, `requests.get`) is mocked at the `fetch_headers` boundary — no test touches a real server or the internet. diff --git a/headersvalidator/assessor.py b/headersvalidator/assessor.py index 30ffd0a..c46c832 100644 --- a/headersvalidator/assessor.py +++ b/headersvalidator/assessor.py @@ -58,7 +58,8 @@ def assess( :param timeout: Per-request socket timeout in seconds. :param verify_tls: If ``False``, TLS certificate errors are ignored (useful for internal or self-signed hosts). The report is flagged - with ``tls_verified=False`` when the target is HTTPS. + with ``tls_verified=False`` when the final response is served over + HTTPS. :param user_agent: Override the default headersvalidator User-Agent string. :param progress_cb: Optional callable invoked with a short status string at key milestones during the assessment (for CLI spinner integration). @@ -80,13 +81,11 @@ def assess( # flag the report — the module already grades headers fetched over plain # HTTP, so an unverified TLS channel is no weaker than what is accepted # elsewhere, and certificate posture belongs to tlsvalidator. - tls_verified = True + unverified = not verify_tls # the successful fetch ran without verification try: response = fetch_headers( url, timeout=timeout, verify_tls=verify_tls, user_agent=user_agent ) - if not verify_tls and url.startswith("https://"): - tls_verified = False except requests.exceptions.SSLError as ssl_exc: # SSLError must be caught before ConnectionError (its parent class): # a TLS failure means the host is reachable, so an http:// fallback @@ -108,7 +107,7 @@ def assess( ) except requests.exceptions.RequestException: raise ssl_exc # re-raise the original TLS error - tls_verified = False + unverified = True except requests.exceptions.ConnectionError as exc: if not url.startswith("https://"): raise @@ -128,6 +127,11 @@ def assess( except requests.exceptions.RequestException: raise exc # re-raise the original HTTPS error + # verify=False propagates across the redirect chain, so judge the channel + # the graded headers actually arrived on: flag only when the *final* + # response was served over HTTPS without certificate verification. + tls_verified = not (unverified and str(response.url).startswith("https://")) + # ---- Extract normalised headers ---------------------------------- headers = extract_headers(response) logger.debug("Received %d headers from %s", len(headers), response.url) diff --git a/tests/test_assessor.py b/tests/test_assessor.py index 41e9718..0e7f397 100644 --- a/tests/test_assessor.py +++ b/tests/test_assessor.py @@ -315,6 +315,30 @@ def test_explicit_verify_tls_false_http_url_not_flagged(self, monkeypatch): report = assess("http://example.com", verify_tls=False) assert report.tls_verified is True + def test_unverified_http_redirecting_to_https_is_flagged(self, monkeypatch): + """verify=False propagates across redirects: an http:// request that + lands on https:// delivered headers over an unverified TLS channel.""" + response = make_response(SECURE_HEADERS, url="https://example.com/") + monkeypatch.setattr( + "headersvalidator.assessor.fetch_headers", lambda *a, **kw: response + ) + report = assess("http://example.com", verify_tls=False) + assert report.tls_verified is False + + def test_unverified_https_redirecting_to_http_not_flagged(self, monkeypatch): + """A cert-failure retry that ends on http:// makes no TLS claim — + the final URL in the report already shows the plain-HTTP channel.""" + response = make_response(SECURE_HEADERS, url="http://example.com/") + + def mock_fetch(url, verify_tls=True, **kwargs): + if verify_tls: + raise requests.exceptions.SSLError("self-signed certificate") + return response + + monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) + report = assess("https://example.com") + assert report.tls_verified is True + def test_http_port_fallback_report_not_flagged(self, monkeypatch): """The port-closed HTTPS→HTTP fallback makes no TLS claim.""" response = make_response(SECURE_HEADERS, url="http://example.com") From 1317096491e9af6771b0cff074d1a59c7bd68791 Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Tue, 18 Aug 2026 15:46:42 +0200 Subject: [PATCH 3/4] feat!: probe without TLS certificate verification by default Per review of the fallback approach: a verified-first attempt costs an extra request against every broken-cert host. The probe now sends a single unverified request per target; invalid certificates cannot block header assessment and the report carries tls_verified=False (final post-redirect URL semantics unchanged). verify_tls=True becomes an explicit opt-in for strict verification and aborts on TLS errors as before. CLI gains --tls-verify; --no-tls-verify stays valid as the explicit default. --- CHANGELOG.md | 30 +++++---- README.md | 12 ++-- docs/SECURITY_VERDICT.md | 14 ++--- headersvalidator/assessor.py | 58 +++++++----------- headersvalidator/cli.py | 13 +++- headersvalidator/http_utils.py | 9 +-- headersvalidator/models.py | 10 +-- headersvalidator/reporter.py | 2 +- tests/test_assessor.py | 107 ++++++++------------------------- tests/test_cli.py | 26 ++++++++ 10 files changed, 124 insertions(+), 157 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc0c145..92eef7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,21 +15,27 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ### Added -- `HeadersReport.tls_verified` field (default `True`): `False` when an HTTPS - response was fetched without certificate verification — automatic fallback - after a failed verification, or an explicit `verify_tls=False` / - `--no-tls-verify`. Exposed in the `--json` output and rendered as a warning - banner in the terminal report and in file exports. +- `HeadersReport.tls_verified` field (default `True`): `False` when the final + response was served over HTTPS without certificate verification (the + default probe mode). Exposed in the `--json` output and rendered as a + warning banner in the terminal report and in file exports. The flag is + derived from the final post-redirect URL, so an unverified `http://` + request that lands on `https://` is flagged and a probe that ends on + plain HTTP is not. +- CLI: `--tls-verify` flag to opt into strict certificate verification + (TLS errors then abort with exit code 2, as before). `--no-tls-verify` + remains valid and states the default explicitly. ### Changed -- `assess()` no longer aborts on TLS certificate verification failures - (self-signed, expired, wrong host, …). It retries the same `https://` URL - once with verification disabled and flags the report with - `tls_verified=False` instead of exiting with code 2. Certificate posture - is tlsvalidator's domain; a bad certificate no longer blocks header - assessment. If even the unverified retry fails, the original TLS error is - raised as before. TLS errors still never trigger the `http://` fallback. +- The header probe no longer verifies TLS certificates by default + (`assess(..., verify_tls=False)`): a single request per target, and + self-signed, expired, or wrong-host certificates cannot block header + assessment (previously any certificate failure aborted with exit code 2). + Certificate posture is tlsvalidator's domain; the grade is unaffected and + the report carries `tls_verified=False` instead. With `verify_tls=True` + TLS errors still abort and are never silently retried, and TLS errors + never trigger the `http://` fallback. - `fetch_headers()` suppresses urllib3's `InsecureRequestWarning` for unverified fetches — the unverified channel is already reported explicitly, so the per-request stderr warning added only noise. diff --git a/README.md b/README.md index 7ed5a87..b9b3946 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ $ headersvalidator check example.com ``` ![Python](https://img.shields.io/badge/python-%3E%3D3.11-blue) -![Tests](https://img.shields.io/badge/tests-437%20passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-435%20passing-brightgreen) ![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen) ![License](https://img.shields.io/badge/license-GPLv3-lightgrey) @@ -109,8 +109,10 @@ headersvalidator check https://example.com # Adjust request timeout (seconds, default 10) headersvalidator check example.com --timeout 15 -# Skip TLS certificate verification (internal hosts) -headersvalidator check https://intranet.local --no-tls-verify +# The probe does not verify TLS certificates by default (single request; +# self-signed or invalid certificates cannot block the assessment, and the +# report is flagged). Opt into strict verification with: +headersvalidator check https://example.com --tls-verify # Output as JSON (for CI/CD pipelines) headersvalidator check example.com --json @@ -151,7 +153,7 @@ from headersvalidator.reporter import print_full_report report = assess( "example.com", timeout=10.0, # optional: request timeout in seconds - verify_tls=True, # optional: set False to skip TLS verification + verify_tls=False, # optional: default False — set True for strict verification ) print_full_report(report) @@ -302,7 +304,7 @@ pytest tests/test_checker.py pytest tests/test_checker.py::TestHSTS -v ``` -The test suite has **437 tests** and maintains **100% statement coverage**. +The test suite has **435 tests** and maintains **100% statement coverage**. All HTTP network I/O (`requests.head`, `requests.get`) is mocked at the `fetch_headers` boundary — no test touches a real server or the internet. diff --git a/docs/SECURITY_VERDICT.md b/docs/SECURITY_VERDICT.md index 143be2a..05612c4 100644 --- a/docs/SECURITY_VERDICT.md +++ b/docs/SECURITY_VERDICT.md @@ -42,13 +42,13 @@ Total penalty is mapped to a letter grade: | 41 – 60 | **D** | Poor — significant security exposure | | > 60 | **F** | Critical — immediate remediation required | -> **TLS certificate failures do not affect the grade.** When certificate -> verification fails (self-signed, expired, wrong host, …), headersvalidator -> retries once without verification, completes the header assessment, and -> flags the report (`tls_verified: false`, plus a warning banner). Certificate -> posture is assessed and graded by **tlsvalidator**, not here — this module -> grades only the headers themselves, exactly as it already does for targets -> served over plain HTTP. +> **TLS certificate posture does not affect the grade.** The probe does not +> verify certificates by default (a single request per target, so self-signed +> or invalid certificates cannot block the assessment); the report is flagged +> instead (`tls_verified: false`, plus a warning banner). Certificate posture +> is assessed and graded by **tlsvalidator**, not here — this module grades +> only the headers themselves, exactly as it already does for targets served +> over plain HTTP. --- diff --git a/headersvalidator/assessor.py b/headersvalidator/assessor.py index c46c832..3c2d6d4 100644 --- a/headersvalidator/assessor.py +++ b/headersvalidator/assessor.py @@ -36,7 +36,7 @@ def assess( url: str, *, timeout: float = HTTP_TIMEOUT, - verify_tls: bool = True, + verify_tls: bool = False, user_agent: str | None = None, progress_cb: Callable[[str], None] | None = None, ) -> HeadersReport: @@ -50,16 +50,15 @@ def assess( :param url: Target URL. Scheme is optional; ``https://`` is assumed. If the HTTPS connection is refused (port 443 closed), the function automatically retries over ``http://`` so that plain-HTTP→HTTPS - redirect chains are followed. If TLS certificate verification fails - (self-signed, expired, wrong host, …), the fetch is retried once - without verification and the report is flagged with - ``tls_verified=False`` — certificate posture is tlsvalidator's - domain; a bad certificate must not block header assessment. + redirect chains are followed. :param timeout: Per-request socket timeout in seconds. - :param verify_tls: If ``False``, TLS certificate errors are ignored - (useful for internal or self-signed hosts). The report is flagged - with ``tls_verified=False`` when the final response is served over - HTTPS. + :param verify_tls: Certificate verification is **off by default**: one + probe per target, and a self-signed or otherwise invalid certificate + cannot block header assessment (certificate posture is tlsvalidator's + domain). The report is flagged with ``tls_verified=False`` whenever + the final response was served over HTTPS without verification. + Pass ``True`` for strict verification — TLS errors then abort the + assessment and are never silently retried. :param user_agent: Override the default headersvalidator User-Agent string. :param progress_cb: Optional callable invoked with a short status string at key milestones during the assessment (for CLI spinner integration). @@ -75,41 +74,26 @@ def assess( progress_cb(f"Fetching headers for {url} …") # ---- Network I/O (single point — easy to mock in tests) ---------- - # If https:// fails with a connection error (port closed, not a TLS error), - # retry with http:// so that plain-HTTP→HTTPS redirect chains are followed. - # If certificate verification fails, retry once without verification and - # flag the report — the module already grades headers fetched over plain + # The probe does not verify TLS certificates by default: one request per + # target, and a self-signed or otherwise invalid certificate cannot block + # header assessment. The module already grades headers fetched over plain # HTTP, so an unverified TLS channel is no weaker than what is accepted # elsewhere, and certificate posture belongs to tlsvalidator. + # If https:// fails with a connection error (port closed, not a TLS error), + # retry with http:// so that plain-HTTP→HTTPS redirect chains are followed. unverified = not verify_tls # the successful fetch ran without verification try: response = fetch_headers( url, timeout=timeout, verify_tls=verify_tls, user_agent=user_agent ) - except requests.exceptions.SSLError as ssl_exc: - # SSLError must be caught before ConnectionError (its parent class): - # a TLS failure means the host is reachable, so an http:// fallback - # would assess the wrong endpoint. - if not verify_tls: - # Verification was already off — retrying identically is futile. - raise - logger.warning( - "TLS certificate verification failed for %s — " - "retrying without verification: %s", - url, - ssl_exc, - ) - if progress_cb: - progress_cb("Certificate verification failed — retrying unverified …") - try: - response = fetch_headers( - url, timeout=timeout, verify_tls=False, user_agent=user_agent - ) - except requests.exceptions.RequestException: - raise ssl_exc # re-raise the original TLS error - unverified = True except requests.exceptions.ConnectionError as exc: - if not url.startswith("https://"): + # SSLError (only possible with verify_tls=True) is a subclass of + # ConnectionError — do NOT fall back for TLS errors: the caller asked + # for strict verification, and the host is reachable, so an http:// + # fallback would assess the wrong endpoint. + if isinstance(exc, requests.exceptions.SSLError) or not url.startswith( + "https://" + ): raise http_url = "http://" + url[len("https://") :] logger.info( diff --git a/headersvalidator/cli.py b/headersvalidator/cli.py index 2d6255f..8859b91 100644 --- a/headersvalidator/cli.py +++ b/headersvalidator/cli.py @@ -41,8 +41,15 @@ def check( timeout: float = typer.Option( 10.0, "--timeout", "-t", help="Request timeout in seconds." ), - no_tls_verify: bool = typer.Option( - False, "--no-tls-verify", help="Skip TLS certificate verification." + tls_verify: bool = typer.Option( + False, + "--tls-verify/--no-tls-verify", + help=( + "Verify TLS certificates. Off by default so a single probe is " + "sent and invalid or self-signed certificates cannot block the " + "assessment; with verification on, TLS errors abort with exit " + "code 2." + ), ), json_output: bool = typer.Option(False, "--json", help="Output results as JSON."), fail_on_warn: bool = typer.Option( @@ -72,7 +79,7 @@ def check( from headersvalidator.reporter import print_full_report try: - report = assess(url, timeout=timeout, verify_tls=not no_tls_verify) + report = assess(url, timeout=timeout, verify_tls=tls_verify) except requests.RequestException as exc: console.print(f"[red]Error:[/red] Could not reach {url!r}: {exc}") raise typer.Exit(code=2) diff --git a/headersvalidator/http_utils.py b/headersvalidator/http_utils.py index 668b932..f1bb20e 100644 --- a/headersvalidator/http_utils.py +++ b/headersvalidator/http_utils.py @@ -40,8 +40,9 @@ def fetch_headers( :param user_agent: Custom ``User-Agent`` header value. Defaults to the headersvalidator UA string from :data:`headersvalidator.constants.USER_AGENT`. - :param verify_tls: Whether to verify TLS certificates. Set ``False`` - only for internal or self-signed hosts. + :param verify_tls: Whether to verify TLS certificates. The assessor + probes with ``False`` by default (single request; certificate + posture is tlsvalidator's domain). :returns: The full HTTP response (headers accessible via ``response.headers``). :rtype: requests.Response @@ -60,8 +61,8 @@ def fetch_headers( try: with warnings.catch_warnings(): if not verify_tls: - # An unverified fetch is a deliberate, flagged decision - # (assessor fallback or --no-tls-verify); urllib3's per-request + # An unverified fetch is the deliberate, report-flagged + # default probe mode; urllib3's per-request # InsecureRequestWarning on stderr adds nothing to that. warnings.simplefilter("ignore", InsecureRequestWarning) response = requests.head( diff --git a/headersvalidator/models.py b/headersvalidator/models.py index e8d36df..0838a48 100644 --- a/headersvalidator/models.py +++ b/headersvalidator/models.py @@ -94,11 +94,11 @@ class HeadersReport: """One HeaderResult per evaluated header.""" tls_verified: bool = True - """False when an HTTPS response was fetched without certificate - verification — automatic fallback after a failed verification, or an - explicit ``verify_tls=False``. Headers received over an unverified - channel could in principle be attacker-influenced; the grade is not - affected (certificate posture is tlsvalidator's domain).""" + """False when the final response was served over HTTPS without + certificate verification — the default probe mode (``verify_tls=False``). + Headers received over an unverified channel could in principle be + attacker-influenced; the grade is not affected (certificate posture is + tlsvalidator's domain).""" # ------------------------------------------------------------------ # Aggregate convenience properties diff --git a/headersvalidator/reporter.py b/headersvalidator/reporter.py index f720a4e..4ea1516 100644 --- a/headersvalidator/reporter.py +++ b/headersvalidator/reporter.py @@ -51,7 +51,7 @@ # Plain-text body of the unverified-TLS warning banner; also used to size # file exports so the banner is never wrapped. _TLS_UNVERIFIED_NOTICE = ( - "⚠ TLS certificate verification failed or was disabled — " + "⚠ TLS certificate verification disabled — " "headers were fetched over an unverified HTTPS connection." ) diff --git a/tests/test_assessor.py b/tests/test_assessor.py index 0e7f397..4fabfd1 100644 --- a/tests/test_assessor.py +++ b/tests/test_assessor.py @@ -166,8 +166,8 @@ def mock_fetch(url, verify_tls=True, **kwargs): return response monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) - assess("https://example.com", verify_tls=False) - assert captured["verify_tls"] is False + assess("https://example.com", verify_tls=True) + assert captured["verify_tls"] is True # --------------------------------------------------------------------------- @@ -192,23 +192,20 @@ def mock_fetch(url, **kwargs): assert calls == ["https://example.com", "http://example.com"] assert report is not None - def test_ssl_error_is_not_retried_over_http(self, monkeypatch): - """SSLError must not trigger an http:// fallback — the host is - reachable; the retry stays on https:// with verification off.""" + def test_ssl_error_with_verification_on_is_strict(self, monkeypatch): + """With verify_tls=True a TLS failure aborts — no unverified retry + and no http:// fallback (the host is reachable).""" calls = [] - def mock_fetch(url, verify_tls=True, **kwargs): + def mock_fetch(url, verify_tls=False, **kwargs): calls.append((url, verify_tls)) raise requests.exceptions.SSLError("cert verify failed") monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) with pytest.raises(requests.exceptions.SSLError): - assess("https://example.com") - # Verified attempt, then one unverified retry — never http://. - assert calls == [ - ("https://example.com", True), - ("https://example.com", False), - ] + assess("https://example.com", verify_tls=True) + # Exactly one strict attempt — no retry of any kind. + assert calls == [("https://example.com", True)] def test_https_connection_error_http_also_fails_raises_original(self, monkeypatch): """When both HTTPS and HTTP fail, the original HTTPS error is re-raised.""" @@ -243,76 +240,40 @@ def mock_fetch(url, **kwargs): # --------------------------------------------------------------------------- -# Unverified-TLS fallback on certificate failure +# tls_verified flag — unverified-by-default probe # --------------------------------------------------------------------------- -class TestAssessTlsVerifyFallback: - def test_cert_failure_retries_unverified_and_flags_report(self, monkeypatch): - """Certificate failure → one unverified https:// retry, flagged report.""" +class TestAssessTlsVerified: + def test_default_probe_is_unverified(self, monkeypatch): + """The default probe sends a single request with verification off.""" response = make_response(SECURE_HEADERS) calls = [] def mock_fetch(url, verify_tls=True, **kwargs): calls.append((url, verify_tls)) - if verify_tls: - raise requests.exceptions.SSLError("self-signed certificate") return response monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) report = assess("https://example.com") - assert calls == [ - ("https://example.com", True), - ("https://example.com", False), - ] + assert calls == [("https://example.com", False)] assert report.tls_verified is False def test_verified_fetch_reports_tls_verified_true(self, monkeypatch): - report = _mock_assess(monkeypatch, SECURE_HEADERS) - assert report.tls_verified is True - - def test_unverified_retry_failure_raises_original_ssl_error(self, monkeypatch): - """If even the unverified retry fails, the original TLS error surfaces.""" - ssl_error = requests.exceptions.SSLError("cert verify failed") - - def mock_fetch(url, verify_tls=True, **kwargs): - if verify_tls: - raise ssl_error - raise requests.ConnectionError("connection dropped") - - monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) - with pytest.raises(requests.exceptions.SSLError) as exc_info: - assess("https://example.com") - assert exc_info.value is ssl_error - - def test_ssl_error_with_verify_tls_false_not_retried(self, monkeypatch): - """A TLS failure with verification already off cannot be retried away.""" - calls = [] - - def mock_fetch(url, **kwargs): - calls.append(url) - raise requests.exceptions.SSLError("handshake failure") - - monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) - with pytest.raises(requests.exceptions.SSLError): - assess("https://example.com", verify_tls=False) - assert calls == ["https://example.com"] - - def test_explicit_verify_tls_false_flags_https_report(self, monkeypatch): response = make_response(SECURE_HEADERS) monkeypatch.setattr( "headersvalidator.assessor.fetch_headers", lambda *a, **kw: response ) - report = assess("https://example.com", verify_tls=False) - assert report.tls_verified is False + report = assess("https://example.com", verify_tls=True) + assert report.tls_verified is True - def test_explicit_verify_tls_false_http_url_not_flagged(self, monkeypatch): + def test_default_http_url_not_flagged(self, monkeypatch): """No TLS involved for an http:// target — nothing to flag.""" response = make_response(SECURE_HEADERS, url="http://example.com") monkeypatch.setattr( "headersvalidator.assessor.fetch_headers", lambda *a, **kw: response ) - report = assess("http://example.com", verify_tls=False) + report = assess("http://example.com") assert report.tls_verified is True def test_unverified_http_redirecting_to_https_is_flagged(self, monkeypatch): @@ -322,20 +283,16 @@ def test_unverified_http_redirecting_to_https_is_flagged(self, monkeypatch): monkeypatch.setattr( "headersvalidator.assessor.fetch_headers", lambda *a, **kw: response ) - report = assess("http://example.com", verify_tls=False) + report = assess("http://example.com") assert report.tls_verified is False def test_unverified_https_redirecting_to_http_not_flagged(self, monkeypatch): - """A cert-failure retry that ends on http:// makes no TLS claim — - the final URL in the report already shows the plain-HTTP channel.""" + """A probe that ends on http:// makes no TLS claim — the final URL + in the report already shows the plain-HTTP channel.""" response = make_response(SECURE_HEADERS, url="http://example.com/") - - def mock_fetch(url, verify_tls=True, **kwargs): - if verify_tls: - raise requests.exceptions.SSLError("self-signed certificate") - return response - - monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) + monkeypatch.setattr( + "headersvalidator.assessor.fetch_headers", lambda *a, **kw: response + ) report = assess("https://example.com") assert report.tls_verified is True @@ -352,22 +309,6 @@ def mock_fetch(url, **kwargs): report = assess("https://example.com") assert report.tls_verified is True - def test_progress_cb_reports_fallback(self, monkeypatch): - response = make_response(SECURE_HEADERS) - - def mock_fetch(url, verify_tls=True, **kwargs): - if verify_tls: - raise requests.exceptions.SSLError("self-signed certificate") - return response - - monkeypatch.setattr("headersvalidator.assessor.fetch_headers", mock_fetch) - messages = [] - assess("https://example.com", progress_cb=messages.append) - assert messages == [ - "Fetching headers for https://example.com …", - "Certificate verification failed — retrying unverified …", - ] - # --------------------------------------------------------------------------- # Specific header scenarios end-to-end diff --git a/tests/test_cli.py b/tests/test_cli.py index 7969b84..ea4e56c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -105,6 +105,32 @@ def fake_assess(url, timeout=10.0, **kw): assert captured.get("timeout") == 30.0 + def test_default_probe_does_not_verify_tls(self): + report = _make_report(Status.PASS) + captured = {} + + def fake_assess(url, verify_tls=True, **kw): + captured["verify_tls"] = verify_tls + return report + + with patch("headersvalidator.assessor.assess", side_effect=fake_assess): + runner.invoke(app, ["check", "https://example.com"]) + + assert captured.get("verify_tls") is False + + def test_tls_verify_flag(self): + report = _make_report(Status.PASS) + captured = {} + + def fake_assess(url, verify_tls=False, **kw): + captured["verify_tls"] = verify_tls + return report + + with patch("headersvalidator.assessor.assess", side_effect=fake_assess): + runner.invoke(app, ["check", "https://example.com", "--tls-verify"]) + + assert captured.get("verify_tls") is True + def test_no_tls_verify_flag(self): report = _make_report(Status.PASS) captured = {} From 2911472048ac5ed0684d7962c27245993036777c Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Tue, 18 Aug 2026 15:53:20 +0200 Subject: [PATCH 4/4] docs: qualify the single-request claim and document tls_verified in the API example The unverified default saves the verification attempt, but the pre-existing HTTP fallback (refused HTTPS) and HEAD-to-GET 405 retry can still add a request; say so in CHANGELOG, README, and SECURITY_VERDICT. Add report.tls_verified to the README results example. (CodeRabbit) --- CHANGELOG.md | 8 +++++--- README.md | 10 +++++++--- docs/SECURITY_VERDICT.md | 6 ++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92eef7a..7a3c999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,9 +29,11 @@ Version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ### Changed - The header probe no longer verifies TLS certificates by default - (`assess(..., verify_tls=False)`): a single request per target, and - self-signed, expired, or wrong-host certificates cannot block header - assessment (previously any certificate failure aborted with exit code 2). + (`assess(..., verify_tls=False)`): no extra request is ever spent on a + certificate verification attempt, and self-signed, expired, or wrong-host + certificates cannot block header assessment (previously any certificate + failure aborted with exit code 2). The pre-existing `http://` fallback on + a refused HTTPS connection and the HEAD→GET retry on 405 are unchanged. Certificate posture is tlsvalidator's domain; the grade is unaffected and the report carries `tls_verified=False` instead. With `verify_tls=True` TLS errors still abort and are never silently retried, and TLS errors diff --git a/README.md b/README.md index b9b3946..3d66901 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,11 @@ headersvalidator check https://example.com # Adjust request timeout (seconds, default 10) headersvalidator check example.com --timeout 15 -# The probe does not verify TLS certificates by default (single request; -# self-signed or invalid certificates cannot block the assessment, and the -# report is flagged). Opt into strict verification with: +# The probe does not verify TLS certificates by default, so self-signed or +# invalid certificates cannot block the assessment and no extra request is +# spent on a verification attempt (the existing HTTP fallback when port 443 +# is closed and the HEAD→GET 405 retry are unchanged); the report is +# flagged. Opt into strict verification with: headersvalidator check https://example.com --tls-verify # Output as JSON (for CI/CD pipelines) @@ -173,6 +175,8 @@ print(report.score) # 0–100 print(report.url) # "https://example.com" print(report.final_url) # effective URL after redirects print(report.status_code) # HTTP status code +print(report.tls_verified) # False = final HTTPS response fetched without + # certificate verification (the default probe mode) # Iterate over all results for result in report.results: diff --git a/docs/SECURITY_VERDICT.md b/docs/SECURITY_VERDICT.md index 05612c4..d731850 100644 --- a/docs/SECURITY_VERDICT.md +++ b/docs/SECURITY_VERDICT.md @@ -43,8 +43,10 @@ Total penalty is mapped to a letter grade: | > 60 | **F** | Critical — immediate remediation required | > **TLS certificate posture does not affect the grade.** The probe does not -> verify certificates by default (a single request per target, so self-signed -> or invalid certificates cannot block the assessment); the report is flagged +> verify certificates by default (no request is spent on a verification +> attempt and self-signed or invalid certificates cannot block the +> assessment; the pre-existing HTTP fallback on a refused HTTPS connection +> is unchanged); the report is flagged > instead (`tls_verified: false`, plus a warning banner). Certificate posture > is assessed and graded by **tlsvalidator**, not here — this module grades > only the headers themselves, exactly as it already does for targets served