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:
1 parent
4c3a284cad
commit
50ec2297ee
32 files changed
+3951
No files matched your search
@@ -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),
|
||||
]
|
||||
Reference in new issue
Block a user