feat(vuln): scanner service and fixture-driven L1 tests

Stdlib-only scanner per docs/17: Kubernetes API inventory, Trivy runner,
persisted state, schema-validated report.json, strict acceptances parser,
server-rendered page, aggregate metrics, internal API, and a scan loop
that stays live while scanning.

L1 tests under conformance/tests/l1-rendering/vuln-scanner-* run the
actual modules against recorded fixtures with a fake Trivy runner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5.5 committed 2026-11-09 09:41:23 -06:00
1 parent 4c3a284cad
commit 50ec2297ee
32 files changed
+3951

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
"""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"]
if url is not None and (not isinstance(url, str) or len(url) > 2048 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),
]