Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 5 additions & 1 deletion src/headerproof/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions tests/test_header_active_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 8 additions & 4 deletions tests/test_output_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading