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]>
This commit is contained in:
1 parent
4c82c64b2f
commit
d8b25a67fe
15 files changed
+557
-43
No files matched your search
@@ -1,6 +1,7 @@
|
||||
"""Build, validate and publish report.json, the one interface to the page and the bot."""
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
@@ -85,10 +86,15 @@ def build(state, accepted, acceptance_errors, now):
|
||||
if acceptance_errors or state.get("inventoryError") or state.get("loadError") \
|
||||
or db_time is None or now - db_time > DB_STALE:
|
||||
run["status"] = "degraded"
|
||||
return {
|
||||
built = {
|
||||
"schemaVersion": 1, "generatedAt": fmt_ts(now), "database": database, "run": run,
|
||||
"targets": targets, "findings": findings, "remediation": list(state["remediation"]),
|
||||
}
|
||||
repaired = _isolate(built)
|
||||
if repaired:
|
||||
log.error("%d report entries held a value the schema refuses; repaired or dropped them", repaired)
|
||||
run["status"] = "degraded"
|
||||
return built
|
||||
|
||||
|
||||
# --- hand validator for the JSON-Schema subset report.schema.json uses -----------------------------
|
||||
@@ -175,6 +181,88 @@ def _check(schema, value, path, errors):
|
||||
_check(branch, value, path, errors)
|
||||
|
||||
|
||||
# --- isolation: one bad upstream value costs that value, never the whole report ---------------------
|
||||
|
||||
def _repair(schema, value):
|
||||
"""(usable, value): value itself when valid, else the nearest valid form of it."""
|
||||
trial = []
|
||||
_check(schema, value, "$", trial)
|
||||
if not trial:
|
||||
return True, value
|
||||
if isinstance(value, dict) and "properties" in schema:
|
||||
fixed = {}
|
||||
for key, sub in schema["properties"].items():
|
||||
if key not in value:
|
||||
continue
|
||||
usable, item = _repair(sub, value[key])
|
||||
if usable:
|
||||
fixed[key] = item
|
||||
elif key in schema.get("required", []):
|
||||
return False, None
|
||||
trial = []
|
||||
_check(schema, fixed, "$", trial)
|
||||
return not trial, fixed
|
||||
if isinstance(value, list) and "items" in schema:
|
||||
repaired = [_repair(schema["items"], item) for item in value]
|
||||
return True, [item for usable, item in repaired if usable]
|
||||
candidates = [None, "", "UNKNOWN"]
|
||||
if isinstance(value, str) and "maxLength" in schema:
|
||||
candidates.insert(0, value[:schema["maxLength"]])
|
||||
for candidate in candidates:
|
||||
trial = []
|
||||
_check(schema, candidate, "$", trial)
|
||||
if not trial:
|
||||
return True, candidate
|
||||
return False, None
|
||||
|
||||
|
||||
def _isolate(report):
|
||||
"""Repair (or, as a last resort, drop) each entry the schema refuses. Returns how many changed."""
|
||||
changed = 0
|
||||
schema = SCHEMA["properties"]
|
||||
usable, database = _repair(schema["database"], report["database"])
|
||||
if database is not report["database"]:
|
||||
changed += 1
|
||||
report["database"] = database if usable else {"updatedAt": EPOCH, "version": "unavailable"}
|
||||
renamed, dropped, targets = {}, set(), []
|
||||
for target in report["targets"]:
|
||||
original = target["id"]
|
||||
if len(original) > 2048: # keep it unique: a truncated id may not collide with another
|
||||
target = dict(target, id=original[:2031] + "#" + hashlib.sha256(original.encode("utf-8")).hexdigest()[:16])
|
||||
renamed[original] = target["id"]
|
||||
usable, fixed = _repair(schema["targets"]["items"], target)
|
||||
if not usable:
|
||||
changed += 1
|
||||
dropped.add(original)
|
||||
continue
|
||||
if fixed is not target or original in renamed:
|
||||
changed += 1
|
||||
targets.append(fixed)
|
||||
report["targets"] = targets
|
||||
findings = []
|
||||
for finding in report["findings"]:
|
||||
if finding["targetId"] in dropped:
|
||||
changed += 1
|
||||
continue
|
||||
if finding["targetId"] in renamed:
|
||||
finding = dict(finding, targetId=renamed[finding["targetId"]])
|
||||
usable, fixed = _repair(schema["findings"]["items"], finding)
|
||||
if not usable or fixed is not finding:
|
||||
changed += 1
|
||||
if usable:
|
||||
findings.append(fixed)
|
||||
report["findings"] = findings
|
||||
remediation = []
|
||||
for entry in report["remediation"]:
|
||||
usable, fixed = _repair(schema["remediation"]["items"], entry)
|
||||
if not usable or fixed is not entry:
|
||||
changed += 1
|
||||
if usable:
|
||||
remediation.append(fixed)
|
||||
report["remediation"] = remediation
|
||||
return changed
|
||||
|
||||
|
||||
def validate(report):
|
||||
"""Schema and referential errors; an empty list means the report may be served."""
|
||||
errors = []
|
||||
|
||||
Reference in new issue
Block a user