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

+276
View File
@@ -0,0 +1,276 @@
"""Scanner entrypoint and the scan loop. Import has no side effects; main() runs the service."""
import collections
import logging
import os
import signal
import sys
import threading
import time
from datetime import timedelta
import acceptances
import inventory
import metrics
import page
import report as report_mod
import server
import state as st
import trivy
from state import fmt_ts, parse_ts
log = logging.getLogger("vuln-scanner")
FULL_RUN_INTERVAL = timedelta(hours=20)
DB_MAX_AGE = timedelta(hours=48)
DB_REFRESH_AGE = timedelta(hours=24)
class Engine:
def __init__(self, data_dir, client, runner, acceptances_path, clock=st.utcnow, tick_seconds=900, publish_seconds=120):
self.store = st.Store(data_dir)
self.data_dir = data_dir
self.client = client
self.runner = runner
self.acceptances_path = acceptances_path
self.clock = clock
self.tick_seconds = tick_seconds
self.publish_seconds = publish_seconds
self.lock = threading.RLock()
self.stop = threading.Event()
self.state = self.store.load()
self.report = report_mod.load_published(data_dir)
self.report_json = report_mod.serialise(self.report) if self.report else None
self.loop_thread = None
self._last_publish = 0.0
self._last_inventory = None
# --- views used by the HTTP servers; none of them waits for a scan --------------------------
def report_bytes(self):
return self.report_json
def render_page(self, query):
return page.render(self.report, query, self.clock())
def metrics_text(self):
with self.lock:
return metrics.render(self.report, self.state, self.clock())
def healthy(self):
return self.loop_thread is None or self.loop_thread.is_alive()
def record_triage(self, document):
with self.lock:
self.state["remediation"] = document["remediation"]
self.state["triageHeartbeatAt"] = fmt_ts(self.clock()) # server receipt time, per contract
self.store.save(self.state)
self.publish()
# --- persistence -----------------------------------------------------------------------------
def publish(self):
with self.lock:
now = self.clock()
entries, errors = acceptances.load(self.acceptances_path, now.date())
for problem in errors:
log.error("invalid acceptance ignored: %s", problem)
built = report_mod.build(self.state, entries, errors, now)
if report_mod.publish(built, self.data_dir):
self.report = built
self.report_json = report_mod.serialise(built)
self._last_publish = time.monotonic()
# --- inventory -------------------------------------------------------------------------------
def refresh_inventory(self):
"""Install a fresh inventory. A failure keeps the previous one and degrades the report."""
now = self.clock()
self._last_inventory = time.monotonic()
try:
targets = inventory.build(inventory.collect(self.client))
except Exception as exc: # pylint: disable=broad-except
log.exception("inventory failed; keeping the previous inventory")
with self.lock:
self.state["inventoryError"] = type(exc).__name__
self.store.save(self.state)
return False
with self.lock:
st.merge_inventory(self.state, targets, now)
self.store.save(self.state)
return True
def _units(self):
"""{scan key: reference}. Two targets sharing a digest are one unit, scanned once."""
units = {}
for target in sorted(self.state["inventory"], key=lambda t: t["id"]):
key = st.scan_key(target)
if key is not None and key not in units:
units[key] = target["image"] + "@" + target["digest"] if target["digest"] else target["ref"]
return units
def _never_attempted(self):
units = self._units()
return [k for k in units if self.state["scans"][k]["lastAttemptAt"] is None]
def _full_run_due(self):
"""Keyed on the last ATTEMPT, not the last success: a permanently failing image would
otherwise look 'older than 20h' forever and restart a full run every tick."""
if self.state["run"] is None:
return True
now = self.clock()
for key in self._units():
attempted = parse_ts(self.state["scans"][key]["lastAttemptAt"])
if attempted is not None and now - attempted >= FULL_RUN_INTERVAL:
return True
return False
# --- scanning --------------------------------------------------------------------------------
def _refresh_database(self, always):
"""True when a usable database is present. Failures are logged, never raised."""
info = self._db_info()
if always or info is None or self._db_age(info) >= DB_REFRESH_AGE:
try:
self.runner.download_db()
except Exception: # pylint: disable=broad-except
log.exception("vulnerability database update failed")
return False
info = self._db_info()
if info is None:
return False
with self.lock:
self.state["database"] = info
self.store.save(self.state)
return True
def _db_info(self):
try:
info = self.runner.db_info()
except Exception: # pylint: disable=broad-except
log.exception("could not read vulnerability database information")
return None
return dict(info) if info else None
def _db_age(self, info):
moment = parse_ts(info.get("updatedAt"))
return timedelta.max if moment is None else self.clock() - moment
def _scan_unit(self, key, reference):
try:
found = trivy.parse_results(self.runner.scan(reference))
except trivy.ScanFailure as exc:
self._record_failure(key, exc.kind, exc.reason)
except Exception as exc: # pylint: disable=broad-except
log.exception("scan of %s failed unexpectedly", reference)
self._record_failure(key, "failed", "scan error: " + type(exc).__name__)
else:
self._record_success(key, found)
try:
self.runner.clean_cache()
except Exception: # pylint: disable=broad-except
log.exception("scan cache cleanup failed")
def _record_failure(self, key, kind, reason):
now = fmt_ts(self.clock())
with self.lock:
record = self.state["scans"].get(key)
if record is None:
return
record["lastAttemptAt"] = now
record["outcome"] = kind
record["reason"] = reason
record["failedSince"] = (record["failedSince"] or now) if kind == "failed" else None
self.store.save(self.state)
def _record_success(self, key, found):
now = fmt_ts(self.clock())
with self.lock:
record = self.state["scans"].get(key)
if record is None:
return
record["lastAttemptAt"] = now
record["lastSuccessAt"] = now
record["outcome"] = "ok"
record["reason"] = None
record["failedSince"] = None
record["findings"] = found
record["firstSeen"] = {f["id"]: record["firstSeen"].get(f["id"], now) for f in found}
self.store.save(self.state)
def _scan_queue(self, keys):
"""Scan sequentially, keeping the inventory and report fresh while a long run proceeds."""
queue = collections.deque(keys)
while queue and not self.stop.is_set():
if self._last_inventory is None or time.monotonic() - self._last_inventory >= self.tick_seconds:
if self.refresh_inventory():
queue.extendleft(k for k in self._never_attempted() if k not in queue)
key = queue.popleft()
reference = self._units().get(key)
if reference is None:
continue # it left the inventory while queued
try:
self._scan_unit(key, reference)
except Exception: # pylint: disable=broad-except
log.exception("scan bookkeeping failed for %s", key)
if time.monotonic() - self._last_publish >= self.publish_seconds:
self.publish()
def run_full(self):
started = self.clock()
log.info("full run starting")
database_ok = self._refresh_database(always=True)
self._scan_queue(sorted(self._units()))
info = self.state["database"]
now = self.clock()
degraded = not database_ok or info is None or self._db_age(info) > DB_MAX_AGE
_, errors = acceptances.load(self.acceptances_path, now.date())
if errors or self.state.get("inventoryError") or self.state.get("loadError"):
degraded = True
status = "degraded" if degraded else "complete"
with self.lock:
self.state["run"] = {"startedAt": fmt_ts(started), "finishedAt": fmt_ts(now), "status": status}
if not degraded:
self.state["lastCompleteRunAt"] = fmt_ts(now)
self.store.save(self.state)
log.info("full run finished: %s", status)
def tick(self):
self.refresh_inventory()
self.publish()
if self._full_run_due():
self.run_full()
else:
never = self._never_attempted()
if never:
self._refresh_database(always=False)
self._scan_queue(never)
self.publish()
def run_forever(self):
while not self.stop.is_set():
try:
self.tick()
except Exception: # pylint: disable=broad-except
log.exception("scan cycle crashed; continuing with the next cycle")
self.stop.wait(self.tick_seconds)
def start(self):
self.loop_thread = threading.Thread(target=self.run_forever, name="scan-loop", daemon=True)
self.loop_thread.start()
def main():
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
data_dir = os.environ.get("VULN_DATA_DIR", "/data")
cache_dir = os.environ.get("VULN_CACHE_DIR", "/cache")
tools_dir = os.environ.get("VULN_TOOLS_DIR", "/tools")
trivy_bin = os.environ.get("TRIVY_BIN", os.path.join(tools_dir, "trivy"))
runner = trivy.Runner(trivy_bin, cache_dir, int(os.environ.get("VULN_SCAN_TIMEOUT_SECONDS", "600")))
engine = Engine(data_dir, inventory.KubeClient(), runner, os.environ.get("VULN_ACCEPTANCES", "/config/acceptances.yaml"))
server.start_all(engine, ("127.0.0.1", 8080), ("0.0.0.0", 8081), ("0.0.0.0", 9090))
finished = threading.Event()
signal.signal(signal.SIGTERM, lambda *_: finished.set())
signal.signal(signal.SIGINT, lambda *_: finished.set())
engine.start()
finished.wait()
engine.stop.set()
if __name__ == "__main__":
main()