"""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), ]