Files
home-network-k3s-impl/platform/vuln-scanner/src/acceptances.py
T
jakman85andClaude Opus 5.5 d8b25a67fe fix(vuln): survive OOM kills, isolate bad upstream strings, label findings per line
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]>
2026-11-09 12:28:29 -06:00

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)