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,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()
|
||||
Reference in new issue
Block a user