From cb9f1cd44a8da52f234fbc9fc4e4106387dcd7b3 Mon Sep 17 00:00:00 2001 From: Pigway Date: Thu, 8 Oct 2026 20:22:55 +0800 Subject: [PATCH] Bound advisory network retries while keeping audits fail closed --- .github/workflows/verify.yml | 3 +- docs/deployment/GITEA_CD.md | 19 ++++++++ scripts/ci-audit.py | 93 ++++++++++++++++++++++++++++++++++++ tests/test_ci_audit.py | 81 +++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 scripts/ci-audit.py create mode 100644 tests/test_ci_audit.py diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 979610d..f29a4e8 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -28,7 +28,8 @@ jobs: - run: python -m ruff format --check pigway_frame_flow tests scripts - run: python -m mypy pigway_frame_flow - run: python -m pytest -q - - run: python -m pip_audit -r requirements.lock --no-deps --disable-pip + - name: Complete vulnerability audit with bounded network retries + run: python scripts/ci-audit.py - name: Isolated Docker build, smoke and restart verification run: | . ./deploy/build-cn.env diff --git a/docs/deployment/GITEA_CD.md b/docs/deployment/GITEA_CD.md index f1cd953..64d9022 100644 --- a/docs/deployment/GITEA_CD.md +++ b/docs/deployment/GITEA_CD.md @@ -321,3 +321,22 @@ Photos configuration or stored connection is missing. These checks do not prove session validity or scan the complete catalog: after restoring a source, also verify its read-only `/api/v1/photo-sources/{id}/images?limit=1` endpoint before running an automation task. + +### Advisory-service network resilience + +CI dependency installation uses the domestic `CI_PIP_INDEX_URL` first and the +`CI_PIP_FALLBACK_INDEX_URL` sequentially on failure. These package indexes do not +replace PyPI's vulnerability JSON API. + +`python scripts/ci-audit.py` retains the pinned `requirements.lock`, PyPI advisory +service and `--no-deps --disable-pip` checks. It uses a 60-second socket timeout, +at most three attempts, and 5/10-second backoff only for network failures. Known +vulnerabilities, malformed/incomplete reports and skipped dependencies fail +immediately. Every attempt has a five-minute process limit. A timeout or failed +final attempt still blocks deployment. + +The advisory HTTP cache is reused within the job (including retries) and follows +the audit client's normal HTTP freshness rules. It is not a permanent offline +snapshot or a cross-job cache. Raw network exceptions are omitted because proxy +URLs may contain credentials. Logs identify the advisory host and whether the +complete audit succeeded. diff --git a/scripts/ci-audit.py b/scripts/ci-audit.py new file mode 100644 index 0000000..a72c604 --- /dev/null +++ b/scripts/ci-audit.py @@ -0,0 +1,93 @@ +"""Retry transient advisory-service failures without accepting incomplete audits.""" + +import json +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +NETWORK_ERRORS = ( + "ReadTimeout", + "Read timed out", + "ConnectTimeout", + "ConnectionError", + "ConnectionResetError", + "NameResolutionError", + "Temporary failure in name resolution", + "MaxRetryError", +) + + +def main(): + cache = Path(tempfile.gettempdir()) / "frame-flow-advisory-cache" + cache.mkdir(mode=0o700, exist_ok=True) + timeout = int(os.getenv("CI_AUDIT_TIMEOUT", "60")) + attempts = int(os.getenv("CI_AUDIT_ATTEMPTS", "3")) + if not 15 <= timeout <= 120 or not 1 <= attempts <= 3: + raise SystemExit("Invalid audit timeout or attempt limit") + print( + "Advisory host: pypi.org; HTTP cache enabled; socket timeout:", + timeout, + flush=True, + ) + command = [ + sys.executable, + "-m", + "pip_audit", + "-r", + "requirements.lock", + "--no-deps", + "--disable-pip", + "--vulnerability-service", + "pypi", + "--timeout", + str(timeout), + "--cache-dir", + str(cache), + "--format", + "json", + ] + for attempt in range(1, attempts + 1): + result = subprocess.run(command, capture_output=True, text=True, timeout=300) + try: + report = json.loads(result.stdout) + dependencies = report["dependencies"] + complete = bool(dependencies) and all( + isinstance(d.get("vulns"), list) and not d.get("skip_reason") + for d in dependencies + ) + except (ValueError, KeyError, TypeError, AttributeError): + dependencies, complete = [], False + vulnerabilities = [ + (d["name"], v["id"]) for d in dependencies for v in d.get("vulns", []) + ] + if vulnerabilities: + for name, advisory in vulnerabilities: + print("Vulnerable dependency:", name, advisory, flush=True) + raise SystemExit("Audit found vulnerabilities; deployment blocked") + if result.returncode == 0 and complete: + print( + "Complete audit passed:", + len(dependencies), + "dependencies; no known vulnerabilities", + flush=True, + ) + return + transient = any(marker in result.stderr for marker in NETWORK_ERRORS) + # Do not print raw network exceptions: proxy URLs can contain credentials. + print( + "Audit attempt", + attempt, + "failed:", + "network error" if transient else "incomplete/error result", + flush=True, + ) + if not transient or attempt == attempts: + raise SystemExit("Audit unsuccessful; deployment blocked") + time.sleep(5 * attempt) + + +if __name__ == "__main__": + main() diff --git a/tests/test_ci_audit.py b/tests/test_ci_audit.py new file mode 100644 index 0000000..98e508e --- /dev/null +++ b/tests/test_ci_audit.py @@ -0,0 +1,81 @@ +"""Network retries must never turn findings or incomplete audits into success.""" + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +@pytest.fixture +def audit(monkeypatch, tmp_path): + spec = importlib.util.spec_from_file_location( + "ci_audit", Path(__file__).parents[1] / "scripts/ci-audit.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setattr(module.tempfile, "gettempdir", lambda: str(tmp_path)) + monkeypatch.setattr(module.time, "sleep", lambda _: None) + monkeypatch.delenv("CI_AUDIT_TIMEOUT", raising=False) + monkeypatch.delenv("CI_AUDIT_ATTEMPTS", raising=False) + return module + + +def result(code=0, stderr="", vulns=None, dependencies=None): + report = { + "dependencies": dependencies + if dependencies is not None + else [{"name": "example", "vulns": vulns or []}] + } + return SimpleNamespace(returncode=code, stderr=stderr, stdout=json.dumps(report)) + + +def test_transient_retry_then_complete(audit, monkeypatch): + replies = iter([result(1, "ReadTimeout"), result()]) + calls = [] + + def run(command, **kwargs): + calls.append(command) + return next(replies) + + monkeypatch.setattr(audit.subprocess, "run", run) + audit.main() + assert len(calls) == 2 + assert calls[0] == calls[1] + assert "--cache-dir" in calls[0] and "--disable-pip" in calls[0] + + +@pytest.mark.parametrize( + "reply", + [ + result(1, vulns=[{"id": "CVE-test"}]), + result(dependencies=[]), + result(dependencies=[{"name": "example", "skip_reason": "unavailable"}]), + result(2), + ], +) +def test_no_retry_for_findings_or_incomplete(audit, monkeypatch, reply): + calls = [] + monkeypatch.setattr( + audit.subprocess, "run", lambda *a, **k: calls.append(a) or reply + ) + with pytest.raises(SystemExit): + audit.main() + assert len(calls) == 1 + + +def test_network_exhaustion_blocks_and_redacts(audit, monkeypatch, capsys): + calls = [] + monkeypatch.setattr( + audit.subprocess, + "run", + lambda *a, **k: ( + calls.append(a) + or result(1, "ConnectionError https://user:secret@example.invalid") + ), + ) + with pytest.raises(SystemExit): + audit.main() + assert len(calls) == 3 + assert "secret" not in capsys.readouterr().out