From 6499c3753125f74e14e159e4166ab51b9a7d7516 Mon Sep 17 00:00:00 2001 From: Maxwell Date: Fri, 2 Oct 2026 11:44:35 +0100 Subject: [PATCH] fix: return scan-error status for partial URL timeouts A per-URL budget expiry is an incomplete scan, so it now exits 2 and keeps precedence over verified findings. Co-authored-by: Cursor --- docs/CI.md | 4 ++- src/headerproof/cli.py | 6 +++- tests/test_header_active_scan.py | 56 ++++++++++++++++++++++++++++++++ tests/test_output_formats.py | 12 ++++--- 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/docs/CI.md b/docs/CI.md index a485451..1ad73d0 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -20,11 +20,13 @@ Operational scan messages remain on stderr. `-silent` suppresses operational out |---:|---| | 0 | Scan completed with no verified technical findings | | 1 | Scan completed with one or more verified technical findings | -| 2 | Scan failed or completed with scan errors | +| 2 | Scan failed, exhausted a per-URL budget (`partial_timeout`), or completed with scan errors | | 130 | Interrupted by the operator | Exit code 1 is a finding result, not a scanner failure. CI wrappers should decide whether findings fail a workflow based on their own policy. +A `partial_timeout` means the per-URL budget expired before that target finished. Exit code 2 keeps precedence over verified findings from the same run. + ## GitHub Action The independently versioned action is available as `TayfurYldz/headerproof-action@v1`. Its `version` input selects the scanner release separately; by default it installs the latest published HeaderProof binary. Set `fail-on-findings: "false"` to retain exit-code `1` as an action output without failing the workflow step. diff --git a/src/headerproof/cli.py b/src/headerproof/cli.py index 805aae7..ddaff10 100644 --- a/src/headerproof/cli.py +++ b/src/headerproof/cli.py @@ -182,7 +182,11 @@ def _payload_count(payload: dict[str, object], key: str) -> int: def _result_exit_code(payload: dict[str, object], interrupted: bool) -> int: if interrupted: return EXIT_INTERRUPTED - if _payload_count(payload, "error") or _payload_count(payload, "partial_error"): + if ( + _payload_count(payload, "error") + or _payload_count(payload, "partial_error") + or _payload_count(payload, "partial_timeout") + ): return EXIT_SCAN_ERROR if _payload_count(payload, "verified_technical_signals"): return EXIT_FINDINGS diff --git a/tests/test_header_active_scan.py b/tests/test_header_active_scan.py index 24a8a72..5577a3d 100644 --- a/tests/test_header_active_scan.py +++ b/tests/test_header_active_scan.py @@ -545,6 +545,62 @@ def test_scan_timeout_keeps_batch_moving(tmp_path: Path) -> None: server.server_close() +def test_partial_url_timeout_is_scan_error_exit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class SlowAfterBaseline(BaseHTTPRequestHandler): + seen = 0 + lock = threading.Lock() + + def log_message(self, format: str, *args) -> None: # noqa: A002 + return + + def do_GET(self) -> None: + self._respond() + + def do_OPTIONS(self) -> None: + self._respond() + + def _respond(self) -> None: + with SlowAfterBaseline.lock: + SlowAfterBaseline.seen += 1 + slow = SlowAfterBaseline.seen > 1 + if slow: + time.sleep(1) + body = b"ok" + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + from headerproof.engine import UrlBudget + + class ShortUrlBudget(UrlBudget): + def __init__(self, seconds: float) -> None: + super().__init__(0.15) + + server = ThreadingHTTPServer(("127.0.0.1", 0), SlowAfterBaseline) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + monkeypatch.setattr("headerproof.engine.UrlBudget", ShortUrlBudget) + url = f"http://127.0.0.1:{server.server_port}/budget" + rc = header_active_scan.main_from_args([url, "-c", "1", "-silent"]) + runs = tmp_path / "state" / "headerproof" / "runs" + result = json.loads(next(runs.iterdir()).joinpath("results.jsonl").read_text().splitlines()[0]) + finally: + server.shutdown() + server.server_close() + + assert result["status"] == "partial_timeout" + assert rc == 2 + capsys.readouterr() + + def test_unreachable_baseline_is_error_not_scanned(tmp_path: Path) -> None: input_file = tmp_path / "urls.txt" input_file.write_text("http://127.0.0.1:1/\n") diff --git a/tests/test_output_formats.py b/tests/test_output_formats.py index 3d794bd..c6a9fa3 100644 --- a/tests/test_output_formats.py +++ b/tests/test_output_formats.py @@ -51,13 +51,17 @@ def test_export_sarif_uses_signal_records(tmp_path: Path) -> None: def test_ci_exit_code_contract_is_stable() -> None: - clean = {"error": 0, "partial_error": 0, "verified_technical_signals": 0} - findings = {"error": 0, "partial_error": 0, "verified_technical_signals": 1} - error = {"error": 1, "partial_error": 0, "verified_technical_signals": 0} - partial_error = {"error": 0, "partial_error": 1, "verified_technical_signals": 1} + clean = {"error": 0, "partial_error": 0, "partial_timeout": 0, "verified_technical_signals": 0} + findings = {"error": 0, "partial_error": 0, "partial_timeout": 0, "verified_technical_signals": 1} + error = {"error": 1, "partial_error": 0, "partial_timeout": 0, "verified_technical_signals": 0} + partial_error = {"error": 0, "partial_error": 1, "partial_timeout": 0, "verified_technical_signals": 1} + partial_timeout = {"error": 0, "partial_error": 0, "partial_timeout": 1, "verified_technical_signals": 0} + mixed_timeout = {"error": 0, "partial_error": 0, "partial_timeout": 1, "verified_technical_signals": 2} assert _result_exit_code(clean, False) == EXIT_CLEAN == 0 assert _result_exit_code(findings, False) == EXIT_FINDINGS == 1 assert _result_exit_code(error, False) == EXIT_SCAN_ERROR == 2 assert _result_exit_code(partial_error, False) == EXIT_SCAN_ERROR == 2 + assert _result_exit_code(partial_timeout, False) == EXIT_SCAN_ERROR == 2 + assert _result_exit_code(mixed_timeout, False) == EXIT_SCAN_ERROR == 2 assert _result_exit_code(clean, True) == EXIT_INTERRUPTED == 130