Bound advisory network retries while keeping audits fail closed
This commit is contained in:
@@ -28,7 +28,8 @@ jobs:
|
|||||||
- run: python -m ruff format --check pigway_frame_flow tests scripts
|
- run: python -m ruff format --check pigway_frame_flow tests scripts
|
||||||
- run: python -m mypy pigway_frame_flow
|
- run: python -m mypy pigway_frame_flow
|
||||||
- run: python -m pytest -q
|
- 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
|
- name: Isolated Docker build, smoke and restart verification
|
||||||
run: |
|
run: |
|
||||||
. ./deploy/build-cn.env
|
. ./deploy/build-cn.env
|
||||||
|
|||||||
@@ -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
|
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
|
verify its read-only `/api/v1/photo-sources/{id}/images?limit=1` endpoint before
|
||||||
running an automation task.
|
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.
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user