feat(hardening): Phase 15 — redaction, retry/backoff, budgets, threat-model docs

Release-readiness hardening.

- redaction.py (zero-dep): redact_secrets masks provider API keys, AWS/GitHub/Slack/
  Google tokens, bearer tokens, URL-embedded credentials, PEM private keys, and
  sensitive key=value assignments. Wired into the trace writer and the markdown report
  (on by default), so scan artifacts never leak a credential quoted from source.
- provider/openrouter.py: call_with_retry — exponential backoff on transient failures
  (HTTP 429/5xx, connection errors, timeouts); non-transient errors fail fast. Both the
  chat and embedding clients use it (max_attempts/retry_base_delay configurable).
- config [hardening]: redact_secrets (default true) + max_findings (bounded-CI output
  cap, 0 = unlimited; truncation is severity-ordered and disclosed as a `budget`
  degradation). Agentic spend stays bounded by [harnessx] max_cost_usd/token_threshold.
- docs/threat-model.md (trust boundaries, controls, sandbox limits) + docs/examples.md
  (six end-to-end walkthroughs); README "Security & hardening" section links both.
- CI lint/typecheck/test/detection-gate/dagger gates already existed.

tests/test_redaction.py, test_provider_retry.py, test_hardening_pipeline.py + config
tests. 206 passed, 4 skipped; ruff + mypy clean; detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-06-22 20:02:34 +02:00
co-authored by Claude Opus 4.8
parent 3fd0057e5f
commit b5e40318db
15 changed files with 505 additions and 16 deletions
+16
View File
@@ -73,6 +73,22 @@ def test_load_config_reads_fusion_section(tmp_path: Path) -> None:
assert config.fusion.high_assurance is True
def test_hardening_defaults_when_section_absent() -> None:
config = load_config(None)
assert config.hardening.redact_secrets is True
assert config.hardening.max_findings == 0
def test_load_config_reads_hardening_section(tmp_path: Path) -> None:
config_path = tmp_path / "openultrasast.toml"
config_path.write_text("[hardening]\nredact_secrets = false\nmax_findings = 5\n")
config = load_config(config_path)
assert config.hardening.redact_secrets is False
assert config.hardening.max_findings == 5
def test_write_resolved_config_creates_json(tmp_path: Path) -> None:
output = tmp_path / "run" / "resolved_config.json"
+1
View File
@@ -148,6 +148,7 @@ GOVERNANCE_SCORING_BENCHMARK_PLANES = (
"openultrasast.stage_processors",
"openultrasast.fusion",
"openultrasast.skills",
"openultrasast.redaction",
)
+61
View File
@@ -0,0 +1,61 @@
"""Hardening wired into the scan: redaction + bounded-CI finding budget (Phase 15)."""
import json
from pathlib import Path
import pytest
from openultrasast.cli import main
# Two vulnerable routes whose source quotes a live-looking secret. The secret is
# assembled at runtime so no scannable token literal lives in the repository.
_SECRET = "sk-" + "abcdEFGH1234567890zzzzzz"
_VULN_WITH_SECRET = (
f'API_KEY = "{_SECRET}"\n'
"@app.route('/a')\n"
"def a():\n"
" return eval(request.data)\n"
"@app.route('/b')\n"
"def b():\n"
" return eval(request.args['x'])\n"
)
def _run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, config_body: str | None) -> Path:
repo = tmp_path / "repo"
repo.mkdir(parents=True)
(repo / "app.py").write_text(_VULN_WITH_SECRET)
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
argv = ["scan", str(repo), "--mode", "quick"]
if config_body is not None:
cfg = tmp_path / "openultrasast.toml"
cfg.write_text(config_body)
argv += ["--config", str(cfg)]
assert main(argv) == 0
return sorted((repo / ".runs").iterdir())[-1]
def test_trace_does_not_leak_secrets_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _run(tmp_path, monkeypatch, config_body=None)
trace = (run_dir / "trace" / "events.jsonl").read_text()
assert _SECRET not in trace
def test_max_findings_budget_truncates_and_records_degradation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# The fixture yields two eval findings; cap to 1 and confirm truncation is disclosed.
uncapped = _run(tmp_path / "full", monkeypatch, config_body=None)
assert len(json.loads((uncapped / "findings.json").read_text())["findings"]) >= 2
run_dir = _run(tmp_path / "capped", monkeypatch, config_body="[hardening]\nmax_findings = 1\n")
findings = json.loads((run_dir / "findings.json").read_text())["findings"]
manifest = json.loads((run_dir / "manifest.json").read_text())
assert len(findings) == 1
budget = next(d for d in manifest["degradations"] if d["stage"] == "budget")
assert budget["reason"] == "max_findings_exceeded" and budget["actual"] >= 2
def test_redaction_can_be_disabled(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _run(tmp_path, monkeypatch, config_body="[hardening]\nredact_secrets = false\n")
trace = (run_dir / "trace" / "events.jsonl").read_text()
# With redaction off, the trace is not scrubbed (target path/source may appear verbatim).
assert "***REDACTED***" not in trace
+65
View File
@@ -0,0 +1,65 @@
"""Provider retry/backoff policy (Phase 15)."""
import urllib.error
import pytest
from openultrasast.provider.openrouter import OpenRouterError, call_with_retry
class _Counter:
def __init__(self, fail_times: int, exc: Exception) -> None:
self.calls = 0
self._fail_times = fail_times
self._exc = exc
def __call__(self) -> str:
self.calls += 1
if self.calls <= self._fail_times:
raise self._exc
return "ok"
def _no_sleep(_seconds: float) -> None:
return None
def test_retries_transient_then_succeeds() -> None:
op = _Counter(2, TimeoutError("slow"))
assert call_with_retry(op, attempts=3, base_delay=0, sleep=_no_sleep) == "ok"
assert op.calls == 3
def test_retries_transient_http_5xx() -> None:
op = _Counter(1, urllib.error.HTTPError("u", 503, "busy", {}, None)) # type: ignore[arg-type]
assert call_with_retry(op, attempts=3, base_delay=0, sleep=_no_sleep) == "ok"
assert op.calls == 2
def test_does_not_retry_non_transient_http_4xx() -> None:
op = _Counter(5, urllib.error.HTTPError("u", 400, "bad", {}, None)) # type: ignore[arg-type]
with pytest.raises(urllib.error.HTTPError):
call_with_retry(op, attempts=3, base_delay=0, sleep=_no_sleep)
assert op.calls == 1 # tried once, not retried
def test_raises_after_exhausting_attempts() -> None:
op = _Counter(10, TimeoutError("slow"))
with pytest.raises(TimeoutError):
call_with_retry(op, attempts=3, base_delay=0, sleep=_no_sleep)
assert op.calls == 3
def test_backoff_is_exponential() -> None:
delays: list[float] = []
op = _Counter(2, urllib.error.URLError("reset"))
call_with_retry(op, attempts=3, base_delay=0.5, sleep=delays.append)
assert delays == [0.5, 1.0] # 0.5 * 2**0, 0.5 * 2**1
def test_non_retryable_value_error_propagates_immediately() -> None:
def op() -> str:
raise OpenRouterError("not network")
with pytest.raises(OpenRouterError):
call_with_retry(op, attempts=3, base_delay=0, sleep=_no_sleep)
+55
View File
@@ -0,0 +1,55 @@
"""Secret redaction for traces and reports (Phase 15).
Example secrets are assembled at runtime (string concatenation) so no scannable token
literal lives in the repository, while still exercising the redaction patterns.
"""
from openultrasast.redaction import REDACTED, redact_secrets
def test_redacts_provider_api_keys() -> None:
secret = "sk-" + "abcdEFGH1234567890zzzz"
out = redact_secrets(f"key {secret} used")
assert secret not in out and REDACTED in out
def test_redacts_aws_github_slack_google_keys() -> None:
aws = "AKIA" + "IOSFODNN7EXAMPLE"
github = "ghp_" + "0123456789abcdefghij" + "ABCDEFGHIJ012345"
slack = "xoxb-" + "123456789012" + "-abcdefghijklmno"
google = "AIza" + "SyA1234567890_abcdefghijklmnopqrst"
assert aws not in redact_secrets(aws)
assert github not in redact_secrets(github)
assert slack not in redact_secrets(slack)
assert google not in redact_secrets(google)
def test_redacts_bearer_token_but_keeps_scheme() -> None:
out = redact_secrets("Authorization: Bearer " + "abcDEF123456ghiJKL")
assert out == f"Authorization: Bearer {REDACTED}"
def test_redacts_url_embedded_credentials() -> None:
password = "s3cret" + "Pass"
out = redact_secrets("postgres://admin:" + password + "@db.internal:5432/app")
assert password not in out and "admin" in out # user kept, password masked
def test_redacts_key_value_assignments() -> None:
pw = "hunter2" + "value"
tok = "tok_" + "abcdefgh"
assert pw not in redact_secrets('password = "' + pw + '"')
assert tok not in redact_secrets("api_key: " + tok)
def test_redacts_private_key_block() -> None:
body = "MIIE" + "abc"
pem = "-----BEGIN RSA PRIVATE KEY-----\n" + body + "\n-----END RSA PRIVATE KEY-----"
assert body not in redact_secrets(pem)
def test_leaves_benign_text_untouched_and_is_idempotent() -> None:
benign = "return eval(request.data) # CWE-95 code injection"
assert redact_secrets(benign) == benign
once = redact_secrets("sk-" + "abcdEFGH1234567890zzzz")
assert redact_secrets(once) == once # idempotent