Review findings on the integrated scanner: - An OOM kill no longer wedges the loop: inFlight is persisted before each scan, and on restart that image is marked failed and sits out full runs for 20h. - One bad upstream string can no longer freeze the report: safe_url requires an exact lowercase scheme, acceptance reasons are bounded, /triage urls use the same rule, and report.build repairs or drops any entry the schema would refuse. lastCompleteRunAt advances only after a report is actually published. - The private registry reports credential-required, not failed: VULN_PRIVATE_REGISTRIES names it, and Trivy is not invoked. - loadError clears after a full run against a good inventory. - Every image under platform/vuln-scanner stays out of Renovate auto-merge. - The page labels status and target state per line, and mixed acceptance reads as mixed. The Keycloak client is still an operator action: its key must go in an existing encrypted file that an agent must not modify. platform/vuln-scanner/README.md has the exact steps. Co-Authored-By: Claude Opus 5.5 <[email protected]>
121 lines
5.9 KiB
Python
121 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""R-VULN-08: acceptance parsing, expiry and fail-safe behaviour."""
|
|
import sys
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE.parents[3] / "platform/vuln-scanner/src"))
|
|
import acceptances # noqa: E402
|
|
import report # noqa: E402
|
|
|
|
FAILED = []
|
|
TODAY = date(2026, 11, 9)
|
|
NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)
|
|
A1 = "sha256:" + "a1" * 32
|
|
|
|
|
|
def check(name, condition):
|
|
print(("ok " if condition else "FAIL ") + name)
|
|
if not condition:
|
|
FAILED.append(name)
|
|
|
|
|
|
def parse(text):
|
|
return acceptances.parse_text(text, TODAY)
|
|
|
|
|
|
entries, errors = parse("""# header comment
|
|
- id: CVE-2026-1
|
|
image: docker.io/library/nginx # exact match
|
|
reason: not reachable - feature disabled
|
|
expires: 2027-02-01
|
|
|
|
- id: CVE-2026-2
|
|
reason: "platform wide"
|
|
expires: 2026-12-01
|
|
""")
|
|
check("valid flat entries parse with no errors", len(entries) == 2 and errors == [])
|
|
check("inline comment is stripped", entries[0]["image"] == "docker.io/library/nginx")
|
|
check("omitted image means platform-wide", entries[1]["image"] is None)
|
|
check("empty list form is accepted", parse("# none\n[]\n") == ([], []))
|
|
check("empty file is accepted", parse("") == ([], []))
|
|
|
|
for name, text in (
|
|
("missing reason", "- id: CVE-1\n expires: 2026-12-01\n"),
|
|
("missing expires", "- id: CVE-1\n reason: x\n"),
|
|
("expires beyond 180 days", "- id: CVE-1\n reason: x\n expires: 2027-06-01\n"),
|
|
("unknown key", "- id: CVE-1\n reason: x\n expires: 2026-12-01\n scope: all\n"),
|
|
("not a date", "- id: CVE-1\n reason: x\n expires: soon\n"),
|
|
("impossible date", "- id: CVE-1\n reason: x\n expires: 2026-13-45\n"),
|
|
("duplicate key", "- id: CVE-1\n reason: x\n reason: y\n expires: 2026-12-01\n"),
|
|
("flow value", "- id: CVE-1\n reason: [x]\n expires: 2026-12-01\n"),
|
|
):
|
|
found, problems = parse(text)
|
|
check("rejected: " + name, found == [] and len(problems) == 1)
|
|
|
|
found, problems = parse("- id: CVE-1\n reason: x\n expires: 2026-12-01\n- id: bad entry\n expires: 2026-12-01\n")
|
|
check("one bad entry is ignored, the valid one kept", len(found) == 1 and len(problems) == 1)
|
|
found, problems = parse("- id: CVE-1\n reason: x\n")
|
|
check("bad indentation invalidates the whole file", found == [] and problems)
|
|
found, problems = parse("garbage\n- id: CVE-1\n reason: x\n expires: 2026-12-01\n")
|
|
check("a stray line invalidates the whole file", found == [] and problems)
|
|
check("missing file is an error, not an empty allowlist", acceptances.load("/nonexistent/acceptances.yaml")[1])
|
|
|
|
entry = {"id": "CVE-1", "image": None, "reason": "r", "expires": date(2026, 11, 9)}
|
|
check("acceptance holds on its expiry day", acceptances.match([entry], "CVE-1", "any/image", TODAY) is entry)
|
|
check("acceptance gone the day after", acceptances.match([entry], "CVE-1", "any/image", TODAY + timedelta(days=1)) is None)
|
|
scoped = {"id": "CVE-1", "image": "docker.io/library/nginx", "reason": "r", "expires": date(2026, 12, 1)}
|
|
check("image match is exact", acceptances.match([scoped], "CVE-1", "docker.io/library/nginx-extra", TODAY) is None)
|
|
check("image match accepts the exact image", acceptances.match([scoped], "CVE-1", "docker.io/library/nginx", TODAY) is scoped)
|
|
|
|
|
|
def state_with(findings):
|
|
target = {"id": "docker.io/library/nginx@" + A1, "type": "image", "image": "docker.io/library/nginx", "digest": A1,
|
|
"ref": "", "reason": None, "workloads": []}
|
|
return {
|
|
"inventory": [target], "database": {"updatedAt": "2026-11-09T06:00:00Z", "version": "2"},
|
|
"run": {"startedAt": "2026-11-09T09:00:00Z", "finishedAt": "2026-11-09T09:30:00Z", "status": "complete"},
|
|
"remediation": [], "inventoryError": None, "loadError": None,
|
|
"scans": {A1: {"firstDiscoveredAt": "2026-11-01T00:00:00Z", "lastInventoriedAt": "2026-11-09T09:00:00Z",
|
|
"lastAttemptAt": "2026-11-09T09:00:00Z", "lastSuccessAt": "2026-11-09T09:00:00Z", "outcome": "ok",
|
|
"reason": None, "failedSince": None, "findings": findings,
|
|
"firstSeen": {f["id"]: "2026-11-01T00:00:00Z" for f in findings}}},
|
|
}
|
|
|
|
|
|
FINDING = {"id": "CVE-1", "severity": "CRITICAL", "cvss": 9.0, "package": "p", "installedVersion": "1", "fixedVersion": "2",
|
|
"title": "t", "url": ""}
|
|
ST = state_with([FINDING])
|
|
|
|
|
|
def status(when, accepted, errs=()):
|
|
built = report.build(ST, accepted, list(errs), when)
|
|
return built, built["findings"][0]
|
|
|
|
|
|
def reason_entry(length):
|
|
return acceptances.parse_text("- id: CVE-1\n reason: " + "r" * length + "\n expires: 2026-12-01\n", NOW.date())
|
|
|
|
|
|
found, problems = reason_entry(3000)
|
|
check("a reason over 2048 characters ignores the entry and reports it", found == [] and any("2048" in p for p in problems))
|
|
found, problems = reason_entry(2048)
|
|
check("mutation: a reason of exactly 2048 characters is valid", len(found) == 1 and problems == [])
|
|
|
|
entry = {"id": "CVE-1", "image": None, "reason": "not reachable", "expires": date(2026, 11, 9)}
|
|
built, finding = status(NOW, [entry])
|
|
check("unexpired acceptance marks the finding accepted", finding["status"] == "accepted"
|
|
and finding["acceptance"] == {"reason": "not reachable", "expires": "2026-11-09"})
|
|
check("accepted report is schema-valid", report.validate(built) == [])
|
|
_, finding = status(NOW + timedelta(days=1), [entry])
|
|
check("expired acceptance reverts to the finding's own status", finding["status"] == "fix-available" and "acceptance" not in finding)
|
|
_, finding = status(NOW, [])
|
|
check("mutation: no acceptance leaves it open/fix-available", finding["status"] == "fix-available")
|
|
built, finding = status(NOW, [], ["bad entry"])
|
|
check("an invalid entry degrades the run", built["run"]["status"] == "degraded")
|
|
check("an invalid entry never hides the finding", finding["status"] == "fix-available")
|
|
|
|
print("FAILED: " + str(len(FAILED)) if FAILED else "all checks passed")
|
|
sys.exit(1 if FAILED else 0)
|