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]>
153 lines
5.9 KiB
Python
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),
|
|
]
|