Files
home-network-k3s-impl/platform/vuln-scanner/src/server.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

153 lines
5.9 KiB
Python

"""The three HTTP listeners: page (loopback, behind oauth2-proxy), internal API, metrics."""
import json
import logging
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
import page
import trivy
from state import parse_ts
log = logging.getLogger("vuln-scanner.server")
MAX_TRIAGE_BYTES = 256 * 1024
MAX_REMEDIATION = 5000
REMEDIATION_STATES = ("pr-open", "approval-requested", "no-update-identified", "not-tracked")
class _Handler(BaseHTTPRequestHandler):
server_version = "vuln-scanner"
sys_version = ""
timeout = 30
engine = None
def log_message(self, fmt, *args): # request lines carry filters; keep them out of the log
return
def _send(self, status, headers, body):
self.send_response(status)
for name, value in headers.items():
self.send_header(name, value)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _text(self, status, body, content_type="text/plain; charset=utf-8"):
self._send(status, {"Content-Type": content_type}, body.encode("utf-8"))
class _PageHandler(_Handler):
def send_error(self, code, message=None, explain=None): # keep the CSP on error responses too
status, headers, body = page.not_found()
self._send(code, headers, body)
def do_GET(self):
parts = urlsplit(self.path)
if parts.path == "/":
self._send(*self.engine.render_page(parts.query))
elif parts.path == "/healthz":
self._text(200, "ok\n")
else:
self._send(*page.not_found())
class _MetricsHandler(_Handler):
def do_GET(self):
path = urlsplit(self.path).path
if path == "/metrics":
self._text(200, self.engine.metrics_text(), "text/plain; version=0.0.4; charset=utf-8")
elif path == "/healthz":
healthy = self.engine.healthy()
self._text(200 if healthy else 503, "ok\n" if healthy else "scan loop stopped\n")
else:
self._text(404, "not found\n")
class _InternalHandler(_Handler):
def do_GET(self):
path = urlsplit(self.path).path
if path == "/report.json":
body = self.engine.report_bytes()
if body is None:
self._text(503, "no report yet\n")
else:
self._send(200, {"Content-Type": "application/json"}, body)
elif path == "/healthz":
self._text(200, "ok\n")
else:
self._text(404, "not found\n")
def do_POST(self):
if urlsplit(self.path).path != "/triage":
self._text(404, "not found\n")
return
content_type = (self.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
if content_type != "application/json":
self._text(415, "content type must be application/json\n")
return
try:
length = int(self.headers.get("Content-Length", ""))
except ValueError:
self._text(411, "a content length is required\n")
return
if length < 0 or length > MAX_TRIAGE_BYTES:
self._text(413, "body too large\n")
return
raw = self.rfile.read(length)
try:
document = json.loads(raw)
except ValueError:
self._text(400, "body is not JSON\n")
return
errors = validate_triage(document)
if errors:
self._text(400, errors[0] + "\n")
return
self.engine.record_triage(document)
self._send(204, {}, b"")
def validate_triage(document):
"""Error strings for a POST /triage body; empty means valid. Exact shape, nothing extra."""
if not isinstance(document, dict) or set(document) != {"heartbeatAt", "remediation"}:
return ["body must have exactly heartbeatAt and remediation"]
if parse_ts(document["heartbeatAt"]) is None or len(document["heartbeatAt"]) > 64:
return ["heartbeatAt must be RFC 3339"]
entries = document["remediation"]
if not isinstance(entries, list) or len(entries) > MAX_REMEDIATION:
return ["remediation must be a list of at most " + str(MAX_REMEDIATION) + " entries"]
for index, entry in enumerate(entries):
prefix = "remediation[" + str(index) + "]: "
if not isinstance(entry, dict) or set(entry) != {"image", "state", "url", "updatedAt"}:
return [prefix + "must have exactly image, state, url, updatedAt"]
if not isinstance(entry["image"], str) or not 0 < len(entry["image"]) <= 512:
return [prefix + "image must be a string of at most 512 characters"]
if entry["state"] not in REMEDIATION_STATES:
return [prefix + "unknown state"]
url = entry["url"]
# safe_url is the same rule the report schema enforces, so a value accepted here cannot make
# the report invalid. Empty is refused: safe_url returns "" for it, and the schema does not.
if url is not None and (not isinstance(url, str) or not url or trivy.safe_url(url) != url):
return [prefix + "url must be null or an http(s) URL"]
if parse_ts(entry["updatedAt"]) is None or len(entry["updatedAt"]) > 64:
return [prefix + "updatedAt must be RFC 3339"]
return []
def serve(handler_type, address, engine):
handler = type(handler_type.__name__, (handler_type,), {"engine": engine})
server = ThreadingHTTPServer(address, handler)
server.daemon_threads = True
thread = threading.Thread(target=server.serve_forever, name="http-" + str(address[1]), daemon=True)
thread.start()
return server
def start_all(engine, page_address, internal_address, metrics_address):
return [
serve(_PageHandler, page_address, engine),
serve(_InternalHandler, internal_address, engine),
serve(_MetricsHandler, metrics_address, engine),
]