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]>
145 lines
5.5 KiB
Python
145 lines
5.5 KiB
Python
"""R-VULN-08 acceptances: a strict parser for exactly the spec's flat list, no YAML library.
|
|
|
|
- id: CVE-2026-00000
|
|
image: registry/repository # exact match; omit only for platform-wide
|
|
reason: not reachable - feature disabled
|
|
expires: 2027-02-01 # mandatory, at most 180 days out
|
|
|
|
Fail-safe: a malformed entry is ignored and reported, so it can never hide a finding. Anything the
|
|
parser cannot place (bad indentation, a stray line) invalidates the whole file for the same reason.
|
|
"""
|
|
import re
|
|
import sys
|
|
from datetime import date, timedelta
|
|
|
|
from state import utcnow
|
|
|
|
MAX_DAYS = 180
|
|
MAX_REASON = 2048 # report.schema.json's limit; a longer reason would make the whole report invalid
|
|
_KEYS = ("id", "image", "reason", "expires")
|
|
_ENTRY = re.compile(r"^- ([a-z]+):(?: (.*))?$")
|
|
_FIELD = re.compile(r"^ ([a-z]+):(?: (.*))?$")
|
|
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
|
|
_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
_FORBIDDEN_START = tuple("[{&*!|>%@`")
|
|
|
|
|
|
def _scalar(raw):
|
|
"""(value, None) or (None, error) for an unquoted or simply-quoted YAML scalar."""
|
|
value = (raw or "").strip()
|
|
if value[:1] in ("'", '"'):
|
|
quote = value[0]
|
|
end = value.find(quote, 1)
|
|
if end == -1 or "\\" in value[:end]:
|
|
return None, "bad quoting"
|
|
rest = value[end + 1:].strip()
|
|
if rest and not rest.startswith("#"):
|
|
return None, "text after the closing quote"
|
|
return value[1:end], None
|
|
if value.startswith(_FORBIDDEN_START):
|
|
return None, "only plain scalars are allowed"
|
|
comment = re.search(r"\s#", value)
|
|
if comment:
|
|
value = value[:comment.start()].rstrip()
|
|
return value, None
|
|
|
|
|
|
def parse_text(source, today=None):
|
|
"""(valid entries, error strings). Entries carry id, reason, expires (date) and optional image."""
|
|
today = today or utcnow().date()
|
|
errors = []
|
|
raw_entries = []
|
|
current = None
|
|
for lineno, line in enumerate(source.splitlines(), 1):
|
|
if not line.strip() or line.lstrip().startswith("#"):
|
|
continue
|
|
entry = _ENTRY.match(line)
|
|
field = _FIELD.match(line)
|
|
if line.strip().split("#", 1)[0].strip() == "[]" and not raw_entries and current is None:
|
|
continue
|
|
if entry:
|
|
current = {"line": lineno, "fields": {}, "errors": []}
|
|
raw_entries.append(current)
|
|
key, raw = entry.group(1), entry.group(2)
|
|
elif field and current is not None:
|
|
key, raw = field.group(1), field.group(2)
|
|
else:
|
|
return [], ["line " + str(lineno) + ": not a flat acceptance entry; the whole file is ignored"]
|
|
value, problem = _scalar(raw)
|
|
if key not in _KEYS:
|
|
current["errors"].append("unknown key " + key)
|
|
elif problem:
|
|
current["errors"].append(key + ": " + problem)
|
|
elif key in current["fields"]:
|
|
current["errors"].append("duplicate key " + key)
|
|
else:
|
|
current["fields"][key] = value
|
|
entries = []
|
|
for raw in raw_entries:
|
|
problems = list(raw["errors"]) + _validate(raw["fields"], today)
|
|
if problems:
|
|
errors.append("entry at line " + str(raw["line"]) + ": " + "; ".join(problems))
|
|
continue
|
|
fields = raw["fields"]
|
|
entries.append({"id": fields["id"], "image": fields.get("image") or None, "reason": fields["reason"],
|
|
"expires": date.fromisoformat(fields["expires"])})
|
|
return entries, errors
|
|
|
|
|
|
def _validate(fields, today):
|
|
problems = []
|
|
if not _ID.match(fields.get("id") or ""):
|
|
problems.append("id is missing or malformed")
|
|
if not fields.get("reason"):
|
|
problems.append("reason is mandatory")
|
|
elif len(fields["reason"]) > MAX_REASON:
|
|
problems.append("reason is longer than " + str(MAX_REASON) + " characters")
|
|
if "image" in fields and not fields["image"]:
|
|
problems.append("image is empty")
|
|
expires = fields.get("expires") or ""
|
|
if not _DATE.match(expires):
|
|
problems.append("expires is mandatory and must be YYYY-MM-DD")
|
|
return problems
|
|
try:
|
|
when = date.fromisoformat(expires)
|
|
except ValueError:
|
|
problems.append("expires is not a real date")
|
|
return problems
|
|
if when > today + timedelta(days=MAX_DAYS):
|
|
problems.append("expires is more than " + str(MAX_DAYS) + " days out")
|
|
return problems
|
|
|
|
|
|
def load(path, today=None):
|
|
"""Entries and errors from a file. An unreadable file is an error, never silently empty."""
|
|
try:
|
|
with open(path, encoding="utf-8") as handle:
|
|
source = handle.read(1024 * 1024 + 1)
|
|
except (OSError, UnicodeDecodeError) as exc:
|
|
return [], ["acceptances file unreadable: " + type(exc).__name__]
|
|
if len(source) > 1024 * 1024:
|
|
return [], ["acceptances file exceeds 1 MiB"]
|
|
return parse_text(source, today)
|
|
|
|
|
|
def match(entries, finding_id, image, today=None):
|
|
"""The unexpired acceptance covering a finding, if any. Expiry day itself still counts."""
|
|
today = today or utcnow().date()
|
|
best = None
|
|
for entry in entries:
|
|
if entry["id"] != finding_id or entry["expires"] < today:
|
|
continue
|
|
if entry["image"] is not None and entry["image"] != image:
|
|
continue
|
|
if best is None or entry["expires"] > best["expires"]:
|
|
best = entry
|
|
return best
|
|
|
|
|
|
if __name__ == "__main__":
|
|
found, problems = load(sys.argv[1])
|
|
for problem in problems:
|
|
print("INVALID: " + problem, file=sys.stderr)
|
|
print(str(len(found)) + " valid entries")
|
|
sys.exit(1 if problems else 0)
|