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,141 @@
|
||||
"""R-VULN-08 acceptances: a strict parser for exactly the spec's flat list, no YAML library.
|
||||
|
||||
- id: CVE-2026-00000
|
||||
image: registry/repository # exact match; omit only for platform-wide
|
||||
reason: not reachable - feature disabled
|
||||
expires: 2027-02-01 # mandatory, at most 180 days out
|
||||
|
||||
Fail-safe: a malformed entry is ignored and reported, so it can never hide a finding. Anything the
|
||||
parser cannot place (bad indentation, a stray line) invalidates the whole file for the same reason.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from datetime import date, timedelta
|
||||
|
||||
from state import utcnow
|
||||
|
||||
MAX_DAYS = 180
|
||||
_KEYS = ("id", "image", "reason", "expires")
|
||||
_ENTRY = re.compile(r"^- ([a-z]+):(?: (.*))?$")
|
||||
_FIELD = re.compile(r"^ ([a-z]+):(?: (.*))?$")
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$")
|
||||
_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
_FORBIDDEN_START = tuple("[{&*!|>%@`")
|
||||
|
||||
|
||||
def _scalar(raw):
|
||||
"""(value, None) or (None, error) for an unquoted or simply-quoted YAML scalar."""
|
||||
value = (raw or "").strip()
|
||||
if value[:1] in ("'", '"'):
|
||||
quote = value[0]
|
||||
end = value.find(quote, 1)
|
||||
if end == -1 or "\\" in value[:end]:
|
||||
return None, "bad quoting"
|
||||
rest = value[end + 1:].strip()
|
||||
if rest and not rest.startswith("#"):
|
||||
return None, "text after the closing quote"
|
||||
return value[1:end], None
|
||||
if value.startswith(_FORBIDDEN_START):
|
||||
return None, "only plain scalars are allowed"
|
||||
comment = re.search(r"\s#", value)
|
||||
if comment:
|
||||
value = value[:comment.start()].rstrip()
|
||||
return value, None
|
||||
|
||||
|
||||
def parse_text(source, today=None):
|
||||
"""(valid entries, error strings). Entries carry id, reason, expires (date) and optional image."""
|
||||
today = today or utcnow().date()
|
||||
errors = []
|
||||
raw_entries = []
|
||||
current = None
|
||||
for lineno, line in enumerate(source.splitlines(), 1):
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
entry = _ENTRY.match(line)
|
||||
field = _FIELD.match(line)
|
||||
if line.strip().split("#", 1)[0].strip() == "[]" and not raw_entries and current is None:
|
||||
continue
|
||||
if entry:
|
||||
current = {"line": lineno, "fields": {}, "errors": []}
|
||||
raw_entries.append(current)
|
||||
key, raw = entry.group(1), entry.group(2)
|
||||
elif field and current is not None:
|
||||
key, raw = field.group(1), field.group(2)
|
||||
else:
|
||||
return [], ["line " + str(lineno) + ": not a flat acceptance entry; the whole file is ignored"]
|
||||
value, problem = _scalar(raw)
|
||||
if key not in _KEYS:
|
||||
current["errors"].append("unknown key " + key)
|
||||
elif problem:
|
||||
current["errors"].append(key + ": " + problem)
|
||||
elif key in current["fields"]:
|
||||
current["errors"].append("duplicate key " + key)
|
||||
else:
|
||||
current["fields"][key] = value
|
||||
entries = []
|
||||
for raw in raw_entries:
|
||||
problems = list(raw["errors"]) + _validate(raw["fields"], today)
|
||||
if problems:
|
||||
errors.append("entry at line " + str(raw["line"]) + ": " + "; ".join(problems))
|
||||
continue
|
||||
fields = raw["fields"]
|
||||
entries.append({"id": fields["id"], "image": fields.get("image") or None, "reason": fields["reason"],
|
||||
"expires": date.fromisoformat(fields["expires"])})
|
||||
return entries, errors
|
||||
|
||||
|
||||
def _validate(fields, today):
|
||||
problems = []
|
||||
if not _ID.match(fields.get("id") or ""):
|
||||
problems.append("id is missing or malformed")
|
||||
if not fields.get("reason"):
|
||||
problems.append("reason is mandatory")
|
||||
if "image" in fields and not fields["image"]:
|
||||
problems.append("image is empty")
|
||||
expires = fields.get("expires") or ""
|
||||
if not _DATE.match(expires):
|
||||
problems.append("expires is mandatory and must be YYYY-MM-DD")
|
||||
return problems
|
||||
try:
|
||||
when = date.fromisoformat(expires)
|
||||
except ValueError:
|
||||
problems.append("expires is not a real date")
|
||||
return problems
|
||||
if when > today + timedelta(days=MAX_DAYS):
|
||||
problems.append("expires is more than " + str(MAX_DAYS) + " days out")
|
||||
return problems
|
||||
|
||||
|
||||
def load(path, today=None):
|
||||
"""Entries and errors from a file. An unreadable file is an error, never silently empty."""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
source = handle.read(1024 * 1024 + 1)
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
return [], ["acceptances file unreadable: " + type(exc).__name__]
|
||||
if len(source) > 1024 * 1024:
|
||||
return [], ["acceptances file exceeds 1 MiB"]
|
||||
return parse_text(source, today)
|
||||
|
||||
|
||||
def match(entries, finding_id, image, today=None):
|
||||
"""The unexpired acceptance covering a finding, if any. Expiry day itself still counts."""
|
||||
today = today or utcnow().date()
|
||||
best = None
|
||||
for entry in entries:
|
||||
if entry["id"] != finding_id or entry["expires"] < today:
|
||||
continue
|
||||
if entry["image"] is not None and entry["image"] != image:
|
||||
continue
|
||||
if best is None or entry["expires"] > best["expires"]:
|
||||
best = entry
|
||||
return best
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
found, problems = load(sys.argv[1])
|
||||
for problem in problems:
|
||||
print("INVALID: " + problem, file=sys.stderr)
|
||||
print(str(len(found)) + " valid entries")
|
||||
sys.exit(1 if problems else 0)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Inventory from the Kubernetes API: what is actually running, and which workloads run it."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
log = logging.getLogger("vuln-scanner.inventory")
|
||||
|
||||
_ID_PREFIXES = ("docker-pullable://", "docker://", "containerd://", "cri-o://")
|
||||
_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
||||
_DOCKER_ALIASES = {"index.docker.io", "registry-1.docker.io", "docker.io"}
|
||||
_CONTAINER_FIELDS = (
|
||||
("containers", "containerStatuses"),
|
||||
("initContainers", "initContainerStatuses"),
|
||||
("ephemeralContainers", "ephemeralContainerStatuses"),
|
||||
)
|
||||
_VMI_PATH = "/apis/kubevirt.io/v1/virtualmachineinstances"
|
||||
PATHS = {
|
||||
"pods": "/api/v1/pods",
|
||||
"replicasets": "/apis/apps/v1/replicasets",
|
||||
"jobs": "/apis/batch/v1/jobs",
|
||||
"cronjobs": "/apis/batch/v1/cronjobs",
|
||||
"nodes": "/api/v1/nodes",
|
||||
}
|
||||
|
||||
|
||||
class InventoryError(Exception):
|
||||
"""The API could not be listed; the previous inventory stands."""
|
||||
|
||||
|
||||
class NotFound(InventoryError):
|
||||
pass
|
||||
|
||||
|
||||
class KubeClient:
|
||||
"""Paginated GET list against the API using the pod's service account."""
|
||||
|
||||
def __init__(self, base_url=None, token_file=None, ca_file=None):
|
||||
sa = "/var/run/secrets/kubernetes.io/serviceaccount"
|
||||
if base_url is None:
|
||||
host = os.environ.get("KUBERNETES_SERVICE_HOST", "kubernetes.default.svc")
|
||||
port = os.environ.get("KUBERNETES_SERVICE_PORT", "443")
|
||||
host = "[" + host + "]" if ":" in host else host
|
||||
base_url = "https://" + host + ":" + port
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token_file = token_file or os.path.join(sa, "token")
|
||||
self.ca_file = ca_file or os.path.join(sa, "ca.crt")
|
||||
|
||||
def _get_json(self, path, params):
|
||||
url = self.base_url + path + "?" + urllib.parse.urlencode(params)
|
||||
with open(self.token_file, encoding="utf-8") as handle: # projected tokens rotate
|
||||
token = handle.read().strip()
|
||||
request = urllib.request.Request(url, headers={"Authorization": "Bearer " + token})
|
||||
context = ssl.create_default_context(cafile=self.ca_file) if url.startswith("https") else None
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30, context=context) as response:
|
||||
return json.loads(response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
raise NotFound(path) from None
|
||||
raise InventoryError(path + ": HTTP " + str(exc.code)) from None
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
raise InventoryError(path + ": " + type(exc).__name__) from None
|
||||
|
||||
def list_all(self, path):
|
||||
items = []
|
||||
params = {"limit": "500"}
|
||||
while True:
|
||||
page = self._get_json(path, params)
|
||||
items.extend(page.get("items") or [])
|
||||
token = (page.get("metadata") or {}).get("continue")
|
||||
if not token:
|
||||
return items
|
||||
params = {"limit": "500", "continue": token}
|
||||
|
||||
|
||||
def collect(client):
|
||||
"""Every list the inventory needs. A VMI API that 404s means no KubeVirt, hence no VMs."""
|
||||
lists = {name: client.list_all(path) for name, path in PATHS.items()}
|
||||
try:
|
||||
lists["vmis"] = client.list_all(_VMI_PATH)
|
||||
except NotFound:
|
||||
lists["vmis"] = []
|
||||
return lists
|
||||
|
||||
|
||||
def normalise_reference(reference):
|
||||
"""(registry/repository, tag or None, digest or None) for an image reference."""
|
||||
text = reference.strip()
|
||||
for prefix in _ID_PREFIXES:
|
||||
if text.startswith(prefix):
|
||||
text = text[len(prefix):]
|
||||
digest = None
|
||||
if "@" in text:
|
||||
text, _, raw = text.partition("@")
|
||||
digest = raw if _DIGEST.fullmatch(raw) else None
|
||||
tag = None
|
||||
tail = text.rsplit("/", 1)[-1]
|
||||
if ":" in tail:
|
||||
text, _, tag = text.rpartition(":")
|
||||
first, slash, rest = text.partition("/")
|
||||
if slash and ("." in first or ":" in first or first == "localhost"):
|
||||
registry, repository = first.lower(), rest
|
||||
else:
|
||||
registry, repository = "docker.io", text
|
||||
if registry in _DOCKER_ALIASES:
|
||||
registry = "docker.io"
|
||||
if "/" not in repository:
|
||||
repository = "library/" + repository
|
||||
return registry + "/" + repository, tag, digest
|
||||
|
||||
|
||||
def digest_from_image_id(image_id):
|
||||
"""The repo digest in a container status imageID, or None.
|
||||
|
||||
A bare `sha256:...` with no repository is a local config id, not a pullable digest, so it is
|
||||
treated as absent (the target is then by-tag) rather than scanned as something it is not.
|
||||
"""
|
||||
text = image_id or ""
|
||||
for prefix in _ID_PREFIXES:
|
||||
if text.startswith(prefix):
|
||||
text = text[len(prefix):]
|
||||
if "@" not in text:
|
||||
return None
|
||||
candidate = text.rsplit("@", 1)[1]
|
||||
return candidate if _DIGEST.fullmatch(candidate) else None
|
||||
|
||||
|
||||
def _controller(obj):
|
||||
references = (obj.get("metadata") or {}).get("ownerReferences") or []
|
||||
for ref in references:
|
||||
if ref.get("controller"):
|
||||
return ref
|
||||
return references[0] if references else None
|
||||
|
||||
|
||||
def resolve_owner(pod, replicasets, jobs):
|
||||
"""The top-level workload (kind, name) a pod belongs to."""
|
||||
meta = pod.get("metadata") or {}
|
||||
namespace = meta.get("namespace", "")
|
||||
owner = _controller(pod)
|
||||
if owner is None:
|
||||
return "Pod", meta.get("name", "")
|
||||
kind, name = owner.get("kind", ""), owner.get("name", "")
|
||||
index = {"ReplicaSet": replicasets, "Job": jobs}.get(kind)
|
||||
if index is not None:
|
||||
parent = index.get((namespace, name))
|
||||
upstream = _controller(parent) if parent else None
|
||||
if upstream is not None:
|
||||
return upstream.get("kind", kind), upstream.get("name", name)
|
||||
return kind, name
|
||||
|
||||
|
||||
def _index(items):
|
||||
return {((i.get("metadata") or {}).get("namespace", ""), (i.get("metadata") or {}).get("name", "")): i
|
||||
for i in items}
|
||||
|
||||
|
||||
class _Builder:
|
||||
def __init__(self):
|
||||
self.targets = {}
|
||||
|
||||
def add(self, target_id, image, digest, ref, workload, container):
|
||||
target = self.targets.get(target_id)
|
||||
if target is None:
|
||||
target = self.targets[target_id] = {
|
||||
"id": target_id, "type": "image", "image": image, "digest": digest, "ref": ref,
|
||||
"reason": None, "workloads": {},
|
||||
}
|
||||
if digest and not target["digest"]:
|
||||
target["digest"] = digest
|
||||
target["workloads"].setdefault(workload, set()).add(container)
|
||||
|
||||
def add_image(self, spec_image, image_id, workload, container):
|
||||
image, tag, spec_digest = normalise_reference(spec_image)
|
||||
digest = digest_from_image_id(image_id)
|
||||
if digest:
|
||||
self.add(image + "@" + digest, image, digest, image + "@" + digest, workload, container)
|
||||
else:
|
||||
ref = image + "@" + spec_digest if spec_digest else image + ":" + (tag or "latest")
|
||||
target_id = image + ":" + tag if tag else (ref if spec_digest else image + ":latest")
|
||||
self.add(target_id, image, None, ref, workload, container)
|
||||
|
||||
def add_uncovered(self, target_id, kind, reason, namespace, name, node_kind):
|
||||
self.targets[target_id] = {
|
||||
"id": target_id, "type": kind, "image": "", "digest": None, "ref": None, "reason": reason,
|
||||
"workloads": {(namespace, node_kind, name): set()},
|
||||
}
|
||||
|
||||
def finish(self):
|
||||
result = []
|
||||
for target in sorted(self.targets.values(), key=lambda t: t["id"]):
|
||||
target["workloads"] = [
|
||||
{"namespace": ns, "kind": kind, "name": name, "containers": sorted(containers)}
|
||||
for (ns, kind, name), containers in sorted(target["workloads"].items())
|
||||
]
|
||||
result.append(target)
|
||||
return result
|
||||
|
||||
|
||||
def build(lists):
|
||||
"""Resolve raw API lists into target dicts."""
|
||||
replicasets = _index(lists.get("replicasets") or [])
|
||||
jobs = _index(lists.get("jobs") or [])
|
||||
builder = _Builder()
|
||||
running = set()
|
||||
for pod in lists.get("pods") or []:
|
||||
status = pod.get("status") or {}
|
||||
# A Succeeded/Failed pod runs nothing; counting it would let a stale evicted pod pin a digest.
|
||||
if status.get("phase") in ("Succeeded", "Failed"):
|
||||
continue
|
||||
namespace = (pod.get("metadata") or {}).get("namespace", "")
|
||||
kind, name = resolve_owner(pod, replicasets, jobs)
|
||||
workload = (namespace, kind, name)
|
||||
if kind == "CronJob":
|
||||
running.add((namespace, name))
|
||||
spec = pod.get("spec") or {}
|
||||
for spec_field, status_field in _CONTAINER_FIELDS:
|
||||
statuses = {s.get("name"): s for s in status.get(status_field) or []}
|
||||
for container in spec.get(spec_field) or []:
|
||||
image_id = (statuses.get(container.get("name")) or {}).get("imageID", "")
|
||||
builder.add_image(container.get("image", ""), image_id, workload, container.get("name", ""))
|
||||
for cronjob in lists.get("cronjobs") or []:
|
||||
meta = cronjob.get("metadata") or {}
|
||||
namespace, name = meta.get("namespace", ""), meta.get("name", "")
|
||||
if (namespace, name) in running:
|
||||
continue
|
||||
job_spec = ((cronjob.get("spec") or {}).get("jobTemplate") or {}).get("spec") or {}
|
||||
pod_spec = (job_spec.get("template") or {}).get("spec") or {}
|
||||
for field in ("containers", "initContainers"):
|
||||
for container in pod_spec.get(field) or []:
|
||||
builder.add_image(container.get("image", ""), "", (namespace, "CronJob", name), container.get("name", ""))
|
||||
for vmi in lists.get("vmis") or []:
|
||||
meta = vmi.get("metadata") or {}
|
||||
namespace, name = meta.get("namespace", ""), meta.get("name", "")
|
||||
builder.add_uncovered("vm/" + namespace + "/" + name, "vm",
|
||||
"guest OS packages are not assessed by this scanner",
|
||||
namespace, name, "VirtualMachineInstance")
|
||||
for node in lists.get("nodes") or []:
|
||||
name = (node.get("metadata") or {}).get("name", "")
|
||||
builder.add_uncovered("node/" + name, "node",
|
||||
"node OS packages and the k3s binary are not assessed by this scanner",
|
||||
"", name, "Node")
|
||||
return builder.finish()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Prometheus text exposition: aggregates only (R-VULN-06). The label allowlist is state, severity, fixable."""
|
||||
from datetime import timedelta
|
||||
|
||||
import report as report_mod
|
||||
from state import parse_ts
|
||||
|
||||
_CRITICAL_GRACE = timedelta(hours=72)
|
||||
_SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
|
||||
|
||||
|
||||
def _sample(lines, name, value, labels=""):
|
||||
lines.append(name + labels + " " + format(value, ".0f" if float(value).is_integer() else "g"))
|
||||
|
||||
|
||||
def render(report, state, now):
|
||||
lines = []
|
||||
if report is not None:
|
||||
_sample(lines, "vuln_scanner_report_generated_timestamp_seconds", parse_ts(report["generatedAt"]).timestamp())
|
||||
_sample(lines, "vuln_scanner_database_updated_timestamp_seconds", parse_ts(report["database"]["updatedAt"]).timestamp())
|
||||
counts = {name: 0 for name in report_mod.STATES}
|
||||
for target in report["targets"]:
|
||||
counts[target["state"]] += 1
|
||||
for name in report_mod.STATES:
|
||||
_sample(lines, "vuln_scanner_targets", counts[name], '{state="' + name + '"}')
|
||||
findings = {(sev, fix): 0 for sev in _SEVERITIES for fix in ("true", "false")}
|
||||
overdue = 0
|
||||
for finding in report["findings"]:
|
||||
if finding["status"] == "accepted":
|
||||
continue
|
||||
findings[(finding["severity"], "true" if finding["fixedVersion"] else "false")] += 1
|
||||
if finding["severity"] == "CRITICAL" and finding["fixedVersion"]:
|
||||
first_seen = parse_ts(finding["firstSeenAt"])
|
||||
if first_seen and now - first_seen > _CRITICAL_GRACE:
|
||||
overdue += 1
|
||||
for (severity, fixable), value in sorted(findings.items()):
|
||||
_sample(lines, "vuln_scanner_findings", value, '{severity="' + severity + '",fixable="' + fixable + '"}')
|
||||
_sample(lines, "vuln_scanner_critical_fix_overdue", overdue)
|
||||
_sample(lines, "vuln_scanner_failed_targets_over_24h", report_mod.failed_over_24h(state, now))
|
||||
for name, key in (("vuln_scanner_last_complete_run_timestamp_seconds", "lastCompleteRunAt"),
|
||||
("vuln_scanner_triage_heartbeat_timestamp_seconds", "triageHeartbeatAt")):
|
||||
value = parse_ts(state.get(key))
|
||||
if value is not None:
|
||||
_sample(lines, name, value.timestamp())
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -0,0 +1,482 @@
|
||||
"""The CVE page: server-rendered HTML with no script at all.
|
||||
|
||||
Every string that reaches the document goes through e(), the single escape path; links additionally
|
||||
go through safe_url(). The style block is the only inline resource and is allowed by hash.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import html
|
||||
import math
|
||||
from datetime import timedelta
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
|
||||
import report as report_mod
|
||||
import trivy
|
||||
from state import parse_ts
|
||||
|
||||
REPORT_STALE = timedelta(hours=26)
|
||||
DB_STALE = timedelta(hours=48)
|
||||
SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
|
||||
SEV_RANK = {name: i for i, name in enumerate(SEVERITIES)}
|
||||
STATUS_RANK = {"fix-available": 0, "open": 1, "accepted": 2}
|
||||
STATE_RANK = {name: i for i, name in enumerate(report_mod.STATES)}
|
||||
PAGE_SIZES = (25, 50, 100, 200, 500)
|
||||
DEFAULT_SIZE = 100
|
||||
_MAX_PARAM = 200
|
||||
_EXPANSION_LIMIT = 200 # per-target CVE list in the inventory view; the remainder is linked
|
||||
|
||||
STYLE = """
|
||||
body{font:14px/1.45 system-ui,sans-serif;margin:0;background:#fff;color:#1a1a1a}
|
||||
main{padding:0 16px 32px}
|
||||
header{padding:12px 16px;background:#f4f4f6;border-bottom:1px solid #ccc}
|
||||
h1{font-size:18px;margin:0 0 8px}
|
||||
nav a{margin-right:12px}
|
||||
.warn{background:#fff0d6;border:2px solid #b35900;padding:8px 12px;margin:8px 0;font-weight:600}
|
||||
.status{font-weight:700}
|
||||
.tiles span{display:inline-block;margin:2px 10px 2px 0}
|
||||
table{border-collapse:collapse;width:100%;margin-top:8px}
|
||||
th,td{border-bottom:1px solid #ddd;padding:4px 6px;text-align:left;vertical-align:top}
|
||||
th a{text-decoration:none}
|
||||
.sev-CRITICAL{color:#a00;font-weight:700}
|
||||
.sev-HIGH{color:#b35900;font-weight:700}
|
||||
form{margin:8px 0}
|
||||
form label{margin-right:8px}
|
||||
code{word-break:break-all}
|
||||
@media (prefers-color-scheme:dark){body{background:#16181c;color:#e6e6e6}
|
||||
header{background:#22252b;border-color:#3a3d44}
|
||||
th,td{border-color:#33363c}
|
||||
a{color:#7fb4ff}
|
||||
.warn{background:#3d2a12;border-color:#c98422}}
|
||||
"""
|
||||
STYLE_HASH = base64.b64encode(hashlib.sha256(STYLE.encode("utf-8")).digest()).decode("ascii")
|
||||
CSP = ("default-src 'none'; style-src 'sha256-" + STYLE_HASH + "'; base-uri 'none'; "
|
||||
"form-action 'self'; frame-ancestors 'none'")
|
||||
SECURITY_HEADERS = {
|
||||
"Content-Security-Policy": CSP,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
}
|
||||
|
||||
|
||||
def e(value):
|
||||
"""The one escape path for anything placed in the document."""
|
||||
return "" if value is None else html.escape(str(value), quote=True)
|
||||
|
||||
|
||||
def safe_url(value):
|
||||
return trivy.safe_url(value) if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _link(url, label):
|
||||
url = safe_url(url)
|
||||
return '<a href="' + e(url) + '" rel="noopener noreferrer">' + e(label) + "</a>" if url else e(label)
|
||||
|
||||
|
||||
def _parse(query):
|
||||
raw = parse_qs(query or "", keep_blank_values=False)
|
||||
params = {k: v[0][:_MAX_PARAM] for k, v in raw.items() if v}
|
||||
view = params.get("view", "cves")
|
||||
params["view"] = view if view in ("cves", "workloads", "inventory") else "cves"
|
||||
try:
|
||||
params["page"] = max(1, int(params.get("page", "1")))
|
||||
except ValueError:
|
||||
params["page"] = 1
|
||||
try:
|
||||
size = int(params.get("size", str(DEFAULT_SIZE)))
|
||||
except ValueError:
|
||||
size = DEFAULT_SIZE
|
||||
params["size"] = size if size in PAGE_SIZES else DEFAULT_SIZE
|
||||
return params
|
||||
|
||||
|
||||
def _qs(params, **overrides):
|
||||
merged = dict(params)
|
||||
merged.update(overrides)
|
||||
pairs = sorted((k, str(v)) for k, v in merged.items() if v not in (None, ""))
|
||||
return "?" + urlencode(pairs)
|
||||
|
||||
|
||||
def _age(moment, now):
|
||||
if moment is None:
|
||||
return "unknown"
|
||||
seconds = int((now - moment).total_seconds())
|
||||
if seconds < 0:
|
||||
return "in the future"
|
||||
hours, remainder = divmod(seconds, 3600)
|
||||
minutes = remainder // 60
|
||||
return str(hours) + "h " + str(minutes) + "m ago"
|
||||
|
||||
|
||||
def _finding_text(finding, target):
|
||||
parts = [finding["id"], finding["package"], finding["title"], target["id"], target["image"]]
|
||||
for workload in target["workloads"]:
|
||||
parts.extend((workload["namespace"], workload["name"], *workload["containers"]))
|
||||
return " ".join(parts).lower()
|
||||
|
||||
|
||||
def _match_workload(target, params):
|
||||
namespace, workload = params.get("namespace"), params.get("workload")
|
||||
if not namespace and not workload:
|
||||
return True
|
||||
for item in target["workloads"]:
|
||||
if namespace and item["namespace"] != namespace:
|
||||
continue
|
||||
if workload and workload not in (item["name"], item["kind"] + "/" + item["name"]):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _match_finding(finding, target, params):
|
||||
if params.get("severity") and finding["severity"] != params["severity"].upper():
|
||||
return False
|
||||
if params.get("status") and finding["status"] != params["status"]:
|
||||
return False
|
||||
fix = params.get("fix")
|
||||
if fix == "yes" and not finding["fixedVersion"]:
|
||||
return False
|
||||
if fix == "no" and finding["fixedVersion"]:
|
||||
return False
|
||||
if not _match_workload(target, params):
|
||||
return False
|
||||
text = params.get("q", "").lower()
|
||||
return not text or text in _finding_text(finding, target)
|
||||
|
||||
|
||||
def _paginate(rows, params):
|
||||
total = len(rows)
|
||||
size = params["size"]
|
||||
pages = max(1, math.ceil(total / size))
|
||||
page = min(params["page"], pages)
|
||||
start = (page - 1) * size
|
||||
return rows[start:start + size], total, page, pages, start
|
||||
|
||||
|
||||
def _pager(params, total, page, pages, start, shown, noun):
|
||||
first = start + 1 if total else 0
|
||||
summary = "Showing " + str(first) + "–" + str(start + shown) + " of " + str(total) + " " + noun \
|
||||
+ " (page " + str(page) + " of " + str(pages) + ")"
|
||||
links = []
|
||||
if page > 1:
|
||||
links.append('<a href="' + e(_qs(params, page=page - 1)) + '">previous</a>')
|
||||
if page < pages:
|
||||
links.append('<a href="' + e(_qs(params, page=page + 1)) + '">next</a>')
|
||||
return "<p>" + e(summary) + " " + " ".join(links) + "</p>"
|
||||
|
||||
|
||||
def _th(params, label, key):
|
||||
direction = "desc" if params.get("sort") == key and params.get("dir", "asc") == "asc" else "asc"
|
||||
return '<th><a href="' + e(_qs(params, sort=key, dir=direction, page=1)) + '">' + e(label) + "</a></th>"
|
||||
|
||||
|
||||
def _table(head, rows, empty):
|
||||
if not rows:
|
||||
return "<p>" + e(empty) + "</p>"
|
||||
return "<table><thead><tr>" + "".join(head) + "</tr></thead><tbody>" + "".join(rows) + "</tbody></table>"
|
||||
|
||||
|
||||
def _sev(severity):
|
||||
return '<span class="sev-' + e(severity) + '">' + e(severity) + "</span>"
|
||||
|
||||
|
||||
def _workload_lines(target):
|
||||
items = []
|
||||
for workload in target["workloads"]:
|
||||
items.append("<li>" + e(workload["namespace"]) + " / " + e(workload["kind"]) + " / " + e(workload["name"])
|
||||
+ " — containers: " + e(", ".join(workload["containers"]) or "none") + "</li>")
|
||||
return "<ul>" + "".join(items) + "</ul>"
|
||||
|
||||
|
||||
def _remediation_cell(images, remediation):
|
||||
if not images:
|
||||
return ""
|
||||
out = []
|
||||
for image in sorted(images):
|
||||
entry = remediation.get(image)
|
||||
if entry is None:
|
||||
out.append(e(image) + ": no data")
|
||||
elif entry["state"] == "pr-open" and safe_url(entry.get("url")):
|
||||
out.append(e(image) + ": " + _link(entry["url"], "pr-open"))
|
||||
else:
|
||||
out.append(e(image) + ": " + e(entry["state"]))
|
||||
return "<br>".join(out)
|
||||
|
||||
|
||||
# --- views ------------------------------------------------------------------------------------------
|
||||
|
||||
def _cves(report, targets, params, remediation):
|
||||
groups = {}
|
||||
for finding in report["findings"]:
|
||||
target = targets[finding["targetId"]]
|
||||
if not _match_finding(finding, target, params):
|
||||
continue
|
||||
group = groups.setdefault(finding["id"], {
|
||||
"id": finding["id"], "severity": finding["severity"], "cvss": None, "packages": set(), "fixed": set(),
|
||||
"firstSeen": finding["firstSeenAt"], "statuses": set(), "url": "", "title": "", "items": [],
|
||||
})
|
||||
if SEV_RANK[finding["severity"]] < SEV_RANK[group["severity"]]:
|
||||
group["severity"] = finding["severity"]
|
||||
if finding["cvss"] is not None and (group["cvss"] is None or finding["cvss"] > group["cvss"]):
|
||||
group["cvss"] = finding["cvss"]
|
||||
group["packages"].add(finding["package"])
|
||||
if finding["fixedVersion"]:
|
||||
group["fixed"].add(finding["fixedVersion"])
|
||||
group["firstSeen"] = min(group["firstSeen"], finding["firstSeenAt"])
|
||||
group["statuses"].add(finding["status"])
|
||||
group["url"] = group["url"] or finding["url"]
|
||||
group["title"] = group["title"] or finding["title"]
|
||||
group["items"].append((finding, target))
|
||||
rows = []
|
||||
for group in groups.values():
|
||||
group["status"] = min(group["statuses"], key=STATUS_RANK.get)
|
||||
group["imageCount"] = len({t["id"] for _, t in group["items"]})
|
||||
group["workloadCount"] = len({(w["namespace"], w["kind"], w["name"]) for _, t in group["items"] for w in t["workloads"]})
|
||||
group["package"] = ", ".join(sorted(group["packages"]))
|
||||
group["fix"] = ", ".join(sorted(group["fixed"]))
|
||||
rows.append(group)
|
||||
sorts = {
|
||||
"id": lambda r: r["id"], "severity": lambda r: SEV_RANK[r["severity"]], "cvss": lambda r: r["cvss"] if r["cvss"] is not None else -1,
|
||||
"package": lambda r: r["package"], "fixed": lambda r: r["fix"], "firstSeen": lambda r: r["firstSeen"],
|
||||
"status": lambda r: STATUS_RANK[r["status"]], "images": lambda r: r["imageCount"], "workloads": lambda r: r["workloadCount"],
|
||||
}
|
||||
rows.sort(key=lambda r: r["id"])
|
||||
rows.sort(key=sorts.get(params.get("sort"), sorts["severity"]), reverse=params.get("dir") == "desc")
|
||||
page_rows, total, page, pages, start = _paginate(rows, params)
|
||||
html_rows = []
|
||||
for row in page_rows:
|
||||
lines = []
|
||||
fix_images = set()
|
||||
for finding, target in row["items"]:
|
||||
if finding["status"] == "fix-available":
|
||||
fix_images.add(target["image"])
|
||||
lines.append("<li>" + e(target["id"]) + " [" + e(target["state"]) + "] — " + e(finding["package"]) + " "
|
||||
+ e(finding["installedVersion"]) + " → " + e(finding["fixedVersion"] or "no fix")
|
||||
+ _workload_lines(target) + "</li>")
|
||||
acceptance = next((f.get("acceptance") for f, _ in row["items"] if f.get("acceptance")), None)
|
||||
status_text = e(row["status"])
|
||||
if acceptance:
|
||||
status_text += " (" + e(acceptance["reason"]) + ", until " + e(acceptance["expires"]) + ")"
|
||||
html_rows.append(
|
||||
"<tr><td>" + _link(row["url"], row["id"]) + "</td><td>" + _sev(row["severity"]) + "</td><td>"
|
||||
+ e("" if row["cvss"] is None else row["cvss"]) + "</td><td>" + e(row["package"]) + "</td><td>" + e(row["fix"])
|
||||
+ "</td><td>" + e(row["firstSeen"]) + "</td><td>" + status_text + "</td><td>" + _remediation_cell(fix_images, remediation)
|
||||
+ "</td><td><details><summary>" + e(row["imageCount"]) + " images, " + e(row["workloadCount"])
|
||||
+ " workloads</summary><p>" + e(row["title"]) + "</p><ul>" + "".join(lines) + "</ul></details></td></tr>")
|
||||
head = [_th(params, "CVE", "id"), _th(params, "Severity", "severity"), _th(params, "CVSS", "cvss"), _th(params, "Package", "package"),
|
||||
_th(params, "Fixed in", "fixed"), _th(params, "First seen", "firstSeen"), _th(params, "Status", "status"),
|
||||
"<th>Remediation</th>", _th(params, "Affected", "images")]
|
||||
return _pager(params, total, page, pages, start, len(page_rows), "CVEs") \
|
||||
+ _table(head, html_rows, "No CVE rows match these filters.")
|
||||
|
||||
|
||||
def _workload_key(item):
|
||||
return item["namespace"], item["kind"], item["name"]
|
||||
|
||||
|
||||
def _workloads(report, targets, params):
|
||||
by_target = {}
|
||||
for finding in report["findings"]:
|
||||
target = targets[finding["targetId"]]
|
||||
if _match_finding(finding, target, params):
|
||||
by_target.setdefault(target["id"], []).append(finding)
|
||||
rows = {}
|
||||
finding_filter = params.get("severity") or params.get("status") or params.get("fix")
|
||||
needle = params.get("q", "").lower()
|
||||
for target in report["targets"]:
|
||||
matching = by_target.get(target["id"], [])
|
||||
for workload in target["workloads"]:
|
||||
key = _workload_key(workload)
|
||||
scope = {"namespace": params.get("namespace"), "workload": params.get("workload")}
|
||||
if not _match_workload({"workloads": [workload]}, scope):
|
||||
continue
|
||||
text = " ".join((workload["namespace"], workload["name"], *workload["containers"], target["image"])).lower()
|
||||
if not matching and (finding_filter or (needle and needle not in text)):
|
||||
continue
|
||||
row = rows.setdefault(key, {"key": key, "containers": set(), "images": set(), "findings": {}, "counts": dict.fromkeys(SEVERITIES, 0)})
|
||||
row["containers"].update(workload["containers"])
|
||||
row["images"].add(target["id"])
|
||||
for finding in matching:
|
||||
row["findings"].setdefault((finding["id"], target["id"], finding["package"]), finding)
|
||||
for row in rows.values():
|
||||
for finding in row["findings"].values():
|
||||
row["counts"][finding["severity"]] += 1
|
||||
items = list(rows.values())
|
||||
sorts = {
|
||||
"namespace": lambda r: r["key"][0], "kind": lambda r: r["key"][1], "name": lambda r: r["key"][2],
|
||||
"images": lambda r: len(r["images"]), "total": lambda r: -len(r["findings"]),
|
||||
}
|
||||
for severity in SEVERITIES:
|
||||
sorts[severity.lower()] = (lambda s: lambda r: -r["counts"][s])(severity)
|
||||
items.sort(key=lambda r: r["key"])
|
||||
if params.get("sort") in sorts:
|
||||
items.sort(key=sorts[params["sort"]], reverse=params.get("dir") == "desc")
|
||||
else:
|
||||
items.sort(key=lambda r: tuple(-r["counts"][s] for s in SEVERITIES))
|
||||
page_rows, total, page, pages, start = _paginate(items, params)
|
||||
html_rows = []
|
||||
for row in page_rows:
|
||||
lines = "".join(
|
||||
"<li>" + _link(f["url"], f["id"]) + " " + _sev(f["severity"]) + " " + e(f["package"]) + " — " + e(f["status"]) + "</li>"
|
||||
for f in sorted(row["findings"].values(), key=lambda f: (SEV_RANK[f["severity"]], f["id"])))
|
||||
counts = "".join("<td>" + e(row["counts"][s]) + "</td>" for s in SEVERITIES)
|
||||
html_rows.append(
|
||||
"<tr><td>" + e(row["key"][0]) + "</td><td>" + e(row["key"][1]) + "</td><td>" + e(row["key"][2]) + "</td><td>"
|
||||
+ e(", ".join(sorted(row["containers"])) or "none") + "</td><td>" + "<br>".join(e(i) for i in sorted(row["images"])) + "</td>"
|
||||
+ counts + "<td><details><summary>" + e(len(row["findings"])) + " findings</summary><ul>" + lines + "</ul></details></td></tr>")
|
||||
head = [_th(params, "Namespace", "namespace"), _th(params, "Kind", "kind"), _th(params, "Workload", "name"), "<th>Containers</th>",
|
||||
_th(params, "Images", "images")] + [_th(params, s, s.lower()) for s in SEVERITIES] + [_th(params, "Findings", "total")]
|
||||
return _pager(params, total, page, pages, start, len(page_rows), "workloads") \
|
||||
+ _table(head, html_rows, "No workload rows match these filters.")
|
||||
|
||||
|
||||
def _inventory(report, params):
|
||||
counts = {}
|
||||
by_target = {}
|
||||
for finding in report["findings"]:
|
||||
counts[finding["targetId"]] = counts.get(finding["targetId"], 0) + 1
|
||||
by_target.setdefault(finding["targetId"], []).append(finding)
|
||||
needle = params.get("q", "").lower()
|
||||
wanted_state = params.get("status")
|
||||
rows = []
|
||||
for target in report["targets"]:
|
||||
if wanted_state and target["state"] != wanted_state:
|
||||
continue
|
||||
if not _match_workload(target, params):
|
||||
continue
|
||||
if needle:
|
||||
text = " ".join((target["id"], target["image"], target["state"], *[w["namespace"] + " " + w["name"] for w in target["workloads"]])).lower()
|
||||
if needle not in text:
|
||||
continue
|
||||
rows.append(target)
|
||||
sorts = {
|
||||
"id": lambda t: t["id"], "type": lambda t: t["type"], "state": lambda t: STATE_RANK[t["state"]],
|
||||
"lastScanned": lambda t: t["lastScannedAt"] or "", "findings": lambda t: counts.get(t["id"], 0),
|
||||
}
|
||||
rows.sort(key=lambda t: t["id"])
|
||||
rows.sort(key=sorts.get(params.get("sort"), sorts["state"]), reverse=params.get("dir") == "desc")
|
||||
page_rows, total, page, pages, start = _paginate(rows, params)
|
||||
html_rows = []
|
||||
for target in page_rows:
|
||||
found = sorted(by_target.get(target["id"], []), key=lambda f: (SEV_RANK[f["severity"]], f["id"]))
|
||||
listed = "".join("<li>" + _link(f["url"], f["id"]) + " " + _sev(f["severity"]) + " " + e(f["package"]) + " — " + e(f["status"]) + "</li>"
|
||||
for f in found[:_EXPANSION_LIMIT])
|
||||
if len(found) > _EXPANSION_LIMIT:
|
||||
listed += '<li><a href="' + e(_qs({"view": "cves", "q": target["image"]})) + '">' + e(len(found) - _EXPANSION_LIMIT) + " more in the CVE view</a></li>"
|
||||
html_rows.append(
|
||||
"<tr><td><code>" + e(target["id"]) + "</code></td><td>" + e(target["type"]) + "</td><td>" + e(target["state"]) + "</td><td>"
|
||||
+ e(target["reason"]) + "</td><td>" + e(target["lastScannedAt"] or "never") + "</td><td>" + e(counts.get(target["id"], 0))
|
||||
+ "</td><td><details><summary>" + e(len(target["workloads"])) + " workloads, " + e(len(found)) + " findings</summary>"
|
||||
+ _workload_lines(target) + ("<ul>" + listed + "</ul>" if listed else "") + "</details></td></tr>")
|
||||
head = [_th(params, "Target", "id"), _th(params, "Type", "type"), _th(params, "State", "state"), "<th>Reason</th>",
|
||||
_th(params, "Last scanned", "lastScanned"), _th(params, "Findings", "findings"), "<th>Workloads</th>"]
|
||||
return _pager(params, total, page, pages, start, len(page_rows), "targets") \
|
||||
+ _table(head, html_rows, "No targets match these filters.")
|
||||
|
||||
|
||||
# --- header -----------------------------------------------------------------------------------------
|
||||
|
||||
def _header(report, now):
|
||||
generated = parse_ts(report["generatedAt"])
|
||||
database = parse_ts(report["database"]["updatedAt"])
|
||||
states = dict.fromkeys(report_mod.STATES, 0)
|
||||
for target in report["targets"]:
|
||||
states[target["state"]] += 1
|
||||
open_counts = dict.fromkeys(SEVERITIES, 0)
|
||||
accepted = 0
|
||||
for finding in report["findings"]:
|
||||
if finding["status"] == "accepted":
|
||||
accepted += 1
|
||||
else:
|
||||
open_counts[finding["severity"]] += 1
|
||||
warnings = []
|
||||
report_stale = generated is None or now - generated > REPORT_STALE
|
||||
if report_stale:
|
||||
warnings.append("STALE REPORT: generated " + _age(generated, now) + "; the expected refresh is within 26 hours.")
|
||||
if report["run"]["status"] == "degraded":
|
||||
warnings.append("DEGRADED RUN: the last run did not complete normally (database, acceptances file or inventory problem). Do not treat these results as current.")
|
||||
db_epoch = report["database"]["version"] == "unavailable"
|
||||
if db_epoch:
|
||||
warnings.append("DATABASE UNAVAILABLE: no vulnerability database has been downloaded.")
|
||||
elif database is None or now - database > DB_STALE:
|
||||
warnings.append("DATABASE STALE: the vulnerability database is older than 48 hours.")
|
||||
total_targets = len(report["targets"])
|
||||
not_scanned = total_targets - states["scanned"]
|
||||
if total_targets == 0:
|
||||
warnings.append("NO TARGETS: the inventory is empty; nothing has been evaluated.")
|
||||
if not_scanned:
|
||||
warnings.append("INCOMPLETE COVERAGE: " + str(not_scanned) + " of " + str(total_targets)
|
||||
+ " targets are not in the scanned state; findings for them are missing or may be out of date.")
|
||||
open_total = sum(open_counts.values())
|
||||
if warnings:
|
||||
status = "Status: ATTENTION NEEDED"
|
||||
elif open_total or accepted:
|
||||
status = "Status: CURRENT, findings present"
|
||||
else:
|
||||
status = "Status: CURRENT"
|
||||
tiles = "".join("<span>" + e(name) + ": <strong>" + e(states[name]) + "</strong></span>" for name in report_mod.STATES)
|
||||
sev_tiles = "".join("<span>" + _sev(s) + ": <strong>" + e(open_counts[s]) + "</strong></span>" for s in SEVERITIES)
|
||||
claim = ""
|
||||
if not warnings and not report["findings"] and total_targets > 0 and not_scanned == 0:
|
||||
claim = "<p><strong>Clean: all " + e(total_targets) + " targets were evaluated within 24 hours with zero findings.</strong></p>"
|
||||
return ("<header><h1>CVE visibility</h1>"
|
||||
"<p class=\"status\">" + e(status) + "</p>"
|
||||
+ "".join('<p class="warn">' + e(w) + "</p>" for w in warnings) + claim
|
||||
+ "<p>Report age: " + e(_age(generated, now)) + " · Database age: "
|
||||
+ e("unavailable" if db_epoch else _age(database, now)) + " · Run: " + e(report["run"]["status"]) + "</p>"
|
||||
+ '<p class="tiles">Targets (' + e(total_targets) + "): " + tiles + "</p>"
|
||||
+ '<p class="tiles">Open findings by severity: ' + sev_tiles + "<span>accepted: <strong>" + e(accepted) + "</strong></span></p>"
|
||||
+ "</header>")
|
||||
|
||||
|
||||
def _nav(params):
|
||||
links = []
|
||||
for view, label in (("cves", "CVEs"), ("workloads", "Workloads"), ("inventory", "Inventory")):
|
||||
marker = ' aria-current="page"' if params["view"] == view else ""
|
||||
target = {k: v for k, v in params.items() if k not in ("page", "sort", "dir")}
|
||||
target["view"] = view
|
||||
links.append('<a href="' + e(_qs(target)) + '"' + marker + ">" + e(label) + "</a>")
|
||||
return "<nav>" + "".join(links) + "</nav>"
|
||||
|
||||
|
||||
def _filters(params):
|
||||
def field(name, label):
|
||||
return "<label>" + e(label) + ' <input name="' + e(name) + '" value="' + e(params.get(name, "")) + '"></label>'
|
||||
selected = params.get("fix", "")
|
||||
fix_options = "".join(
|
||||
'<option value="' + e(v) + '"' + (" selected" if selected == v else "") + ">" + e(t) + "</option>"
|
||||
for v, t in (("", "any"), ("yes", "yes"), ("no", "no")))
|
||||
return ('<form method="get"><input type="hidden" name="view" value="' + e(params["view"]) + '">'
|
||||
+ field("q", "Search") + field("severity", "Severity") + field("namespace", "Namespace")
|
||||
+ field("workload", "Workload") + field("status", "Status")
|
||||
+ '<label>Fix available <select name="fix">' + fix_options + "</select></label>"
|
||||
+ '<button type="submit">Filter</button></form>')
|
||||
|
||||
|
||||
def _document(body):
|
||||
return ("<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>CVE visibility</title>"
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1"><style>' + STYLE + "</style></head>"
|
||||
"<body>" + body + "</body></html>").encode("utf-8")
|
||||
|
||||
|
||||
def render(report, query, now):
|
||||
"""(status code, header dict, body bytes) for one page request."""
|
||||
if report is None:
|
||||
body = ("<main><h1>CVE visibility</h1><p class=\"warn\">NO REPORT YET: nothing has been evaluated. "
|
||||
"Nothing can be said about this cluster until the first report is published.</p></main>")
|
||||
return 503, dict(SECURITY_HEADERS), _document(body)
|
||||
params = _parse(query)
|
||||
targets = {t["id"]: t for t in report["targets"]}
|
||||
remediation = {r["image"]: r for r in report["remediation"]}
|
||||
view = params["view"]
|
||||
if view == "workloads":
|
||||
content = _workloads(report, targets, params)
|
||||
elif view == "inventory":
|
||||
content = _inventory(report, params)
|
||||
else:
|
||||
content = _cves(report, targets, params, remediation)
|
||||
return 200, dict(SECURITY_HEADERS), _document(_header(report, now) + "<main>" + _nav(params) + _filters(params) + content + "</main>")
|
||||
|
||||
|
||||
def not_found():
|
||||
return 404, dict(SECURITY_HEADERS), _document("<main><p>Not found.</p></main>")
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Build, validate and publish report.json, the one interface to the page and the bot."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
|
||||
import acceptances as acceptances_mod
|
||||
import state as st
|
||||
from state import fmt_ts, parse_ts
|
||||
|
||||
log = logging.getLogger("vuln-scanner.report")
|
||||
|
||||
STATES = ("scanned", "pending", "stale", "failed", "credential-required", "by-tag", "not-covered")
|
||||
SCAN_WINDOW = timedelta(hours=24)
|
||||
PENDING_WINDOW = timedelta(hours=2)
|
||||
DB_STALE = timedelta(hours=48)
|
||||
NOT_SCANNED_REASON = "not scanned within 2h of first appearing"
|
||||
EPOCH = "1970-01-01T00:00:00Z" # database.updatedAt when no database has ever been downloaded
|
||||
|
||||
_SCHEMA_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "report.schema.json")
|
||||
with open(_SCHEMA_PATH, encoding="utf-8") as _handle:
|
||||
SCHEMA = json.load(_handle)
|
||||
|
||||
|
||||
def coverage(target, record, now):
|
||||
"""(state, reason, failed-since datetime or None) for one target. The single place states come from."""
|
||||
if target["type"] != "image":
|
||||
return "not-covered", target["reason"], None
|
||||
record = record or st.new_record(now)
|
||||
outcome = record["outcome"]
|
||||
if outcome == "credential-required":
|
||||
return "credential-required", record["reason"], None
|
||||
if outcome == "failed":
|
||||
return "failed", record["reason"], parse_ts(record["failedSince"]) or now
|
||||
if record["lastAttemptAt"] is None:
|
||||
appeared = parse_ts(record["firstDiscoveredAt"]) or now
|
||||
if now - appeared > PENDING_WINDOW:
|
||||
return "failed", NOT_SCANNED_REASON, appeared + PENDING_WINDOW
|
||||
return "pending", None, None
|
||||
last_success = parse_ts(record["lastSuccessAt"]) or now
|
||||
if now - last_success > SCAN_WINDOW:
|
||||
return "stale", "last successful scan was at " + record["lastSuccessAt"], None
|
||||
return ("by-tag" if target["digest"] is None else "scanned"), None, None
|
||||
|
||||
|
||||
def failed_over_24h(state, now):
|
||||
count = 0
|
||||
for target in state["inventory"]:
|
||||
status, _, since = coverage(target, state["scans"].get(st.scan_key(target)), now)
|
||||
if status == "failed" and since is not None and now - since > SCAN_WINDOW:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def build(state, accepted, acceptance_errors, now):
|
||||
"""The report for `state` at `now`; acceptance_errors degrades the run."""
|
||||
today = now.date()
|
||||
targets = []
|
||||
findings = []
|
||||
for target in state["inventory"]:
|
||||
record = state["scans"].get(st.scan_key(target))
|
||||
status, reason, _ = coverage(target, record, now)
|
||||
last_scanned = record["lastSuccessAt"] if record else None
|
||||
targets.append({
|
||||
"id": target["id"], "type": target["type"], "image": target["image"], "digest": target["digest"],
|
||||
"state": status, "reason": reason, "lastScannedAt": last_scanned, "workloads": target["workloads"],
|
||||
})
|
||||
for item in (record or {}).get("findings", []):
|
||||
finding = {key: item[key] for key in
|
||||
("id", "severity", "cvss", "package", "installedVersion", "fixedVersion", "title", "url")}
|
||||
finding["targetId"] = target["id"]
|
||||
finding["firstSeenAt"] = record["firstSeen"].get(item["id"]) or fmt_ts(now)
|
||||
acceptance = acceptances_mod.match(accepted, item["id"], target["image"], today)
|
||||
if acceptance:
|
||||
finding["status"] = "accepted"
|
||||
finding["acceptance"] = {"reason": acceptance["reason"], "expires": acceptance["expires"].isoformat()}
|
||||
else:
|
||||
finding["status"] = "fix-available" if item["fixedVersion"] else "open"
|
||||
findings.append(finding)
|
||||
findings.sort(key=lambda f: (f["id"], f["targetId"], f["package"], f["installedVersion"]))
|
||||
database = dict(state["database"]) if state["database"] else {"updatedAt": EPOCH, "version": "unavailable"}
|
||||
run = dict(state["run"]) if state["run"] else {"startedAt": fmt_ts(now), "finishedAt": fmt_ts(now), "status": "degraded"}
|
||||
db_time = parse_ts(database["updatedAt"])
|
||||
if acceptance_errors or state.get("inventoryError") or state.get("loadError") \
|
||||
or db_time is None or now - db_time > DB_STALE:
|
||||
run["status"] = "degraded"
|
||||
return {
|
||||
"schemaVersion": 1, "generatedAt": fmt_ts(now), "database": database, "run": run,
|
||||
"targets": targets, "findings": findings, "remediation": list(state["remediation"]),
|
||||
}
|
||||
|
||||
|
||||
# --- hand validator for the JSON-Schema subset report.schema.json uses -----------------------------
|
||||
|
||||
def _type_ok(value, name):
|
||||
if name == "object":
|
||||
return isinstance(value, dict)
|
||||
if name == "array":
|
||||
return isinstance(value, list)
|
||||
if name == "string":
|
||||
return isinstance(value, str)
|
||||
if name == "null":
|
||||
return value is None
|
||||
if name == "boolean":
|
||||
return isinstance(value, bool)
|
||||
if name == "number":
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
if name == "integer":
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
return False
|
||||
|
||||
|
||||
def _format_ok(value, name):
|
||||
if name == "date-time":
|
||||
return parse_ts(value) is not None
|
||||
if name == "date":
|
||||
try:
|
||||
date.fromisoformat(value)
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _check(schema, value, path, errors):
|
||||
if len(errors) >= 50:
|
||||
return
|
||||
if "type" in schema:
|
||||
names = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
|
||||
if not any(_type_ok(value, n) for n in names):
|
||||
errors.append(path + ": wrong type")
|
||||
return
|
||||
if "const" in schema and value != schema["const"]:
|
||||
errors.append(path + ": not the required constant")
|
||||
if "enum" in schema and value not in schema["enum"]:
|
||||
errors.append(path + ": not an allowed value")
|
||||
if isinstance(value, str):
|
||||
if "minLength" in schema and len(value) < schema["minLength"]:
|
||||
errors.append(path + ": too short")
|
||||
if "maxLength" in schema and len(value) > schema["maxLength"]:
|
||||
errors.append(path + ": too long")
|
||||
if "pattern" in schema and not re.search(schema["pattern"], value):
|
||||
errors.append(path + ": does not match the pattern")
|
||||
if "format" in schema and not _format_ok(value, schema["format"]):
|
||||
errors.append(path + ": not a valid " + schema["format"])
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
if "minimum" in schema and value < schema["minimum"]:
|
||||
errors.append(path + ": below the minimum")
|
||||
if "maximum" in schema and value > schema["maximum"]:
|
||||
errors.append(path + ": above the maximum")
|
||||
if isinstance(value, dict):
|
||||
for key in schema.get("required", []):
|
||||
if key not in value:
|
||||
errors.append(path + ": missing " + key)
|
||||
properties = schema.get("properties", {})
|
||||
for key, item in value.items():
|
||||
if key in properties:
|
||||
_check(properties[key], item, path + "." + key, errors)
|
||||
elif schema.get("additionalProperties") is False:
|
||||
errors.append(path + ": unexpected key " + key)
|
||||
if isinstance(value, list) and "items" in schema:
|
||||
for index, item in enumerate(value):
|
||||
_check(schema["items"], item, path + "[" + str(index) + "]", errors)
|
||||
if "not" in schema:
|
||||
trial = []
|
||||
_check(schema["not"], value, path, trial)
|
||||
if not trial:
|
||||
errors.append(path + ": matches a forbidden shape")
|
||||
if "if" in schema:
|
||||
trial = []
|
||||
_check(schema["if"], value, path, trial)
|
||||
branch = schema.get("then") if not trial else schema.get("else")
|
||||
if branch:
|
||||
_check(branch, value, path, errors)
|
||||
|
||||
|
||||
def validate(report):
|
||||
"""Schema and referential errors; an empty list means the report may be served."""
|
||||
errors = []
|
||||
_check(SCHEMA, report, "$", errors)
|
||||
if errors:
|
||||
return errors
|
||||
ids = [t["id"] for t in report["targets"]]
|
||||
if len(set(ids)) != len(ids):
|
||||
errors.append("$.targets: duplicate target id")
|
||||
known = set(ids)
|
||||
for finding in report["findings"]:
|
||||
if finding["targetId"] not in known:
|
||||
errors.append("$.findings: targetId has no target")
|
||||
break
|
||||
return errors
|
||||
|
||||
|
||||
def serialise(report):
|
||||
return json.dumps(report, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def publish(report, data_dir):
|
||||
"""Write report.json. An invalid report writes nothing and is logged loudly."""
|
||||
errors = validate(report)
|
||||
if errors:
|
||||
log.critical("REFUSING to publish an invalid report; the previous one stands: %s", errors[:10])
|
||||
return False
|
||||
st.atomic_write(os.path.join(data_dir, "report.json"), serialise(report))
|
||||
return True
|
||||
|
||||
|
||||
def load_published(data_dir):
|
||||
"""The published report if present and still valid, else None (never a corrupt report)."""
|
||||
try:
|
||||
with open(os.path.join(data_dir, "report.json"), "rb") as handle:
|
||||
report = json.loads(handle.read())
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, ValueError):
|
||||
log.critical("published report.json is unreadable; serving no report until the next publish")
|
||||
return None
|
||||
if validate(report):
|
||||
log.critical("published report.json is invalid; serving no report until the next publish")
|
||||
return None
|
||||
return report
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "report.schema.json",
|
||||
"title": "vuln-scanner report (docs/17, schemaVersion 1)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schemaVersion", "generatedAt", "database", "run", "targets", "findings", "remediation"],
|
||||
"properties": {
|
||||
"schemaVersion": {"const": 1},
|
||||
"generatedAt": {"type": "string", "format": "date-time"},
|
||||
"database": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["updatedAt", "version"],
|
||||
"properties": {
|
||||
"updatedAt": {"type": "string", "format": "date-time"},
|
||||
"version": {"type": "string", "minLength": 1, "maxLength": 128}
|
||||
}
|
||||
},
|
||||
"run": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["startedAt", "finishedAt", "status"],
|
||||
"properties": {
|
||||
"startedAt": {"type": "string", "format": "date-time"},
|
||||
"finishedAt": {"type": "string", "format": "date-time"},
|
||||
"status": {"enum": ["complete", "degraded"]}
|
||||
}
|
||||
},
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "type", "image", "digest", "state", "reason", "lastScannedAt", "workloads"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1, "maxLength": 2048},
|
||||
"type": {"enum": ["image", "vm", "node"]},
|
||||
"image": {"type": "string", "maxLength": 2048},
|
||||
"digest": {"type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$"},
|
||||
"state": {"enum": ["scanned", "pending", "stale", "failed", "credential-required", "by-tag", "not-covered"]},
|
||||
"reason": {"type": ["string", "null"], "maxLength": 1024},
|
||||
"lastScannedAt": {"type": ["string", "null"], "format": "date-time"},
|
||||
"workloads": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["namespace", "kind", "name", "containers"],
|
||||
"properties": {
|
||||
"namespace": {"type": "string", "maxLength": 253},
|
||||
"kind": {"type": "string", "minLength": 1, "maxLength": 253},
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 253},
|
||||
"containers": {"type": "array", "items": {"type": "string", "maxLength": 253}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"findings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "targetId", "severity", "cvss", "package", "installedVersion", "fixedVersion",
|
||||
"title", "url", "firstSeenAt", "status"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1, "maxLength": 256},
|
||||
"targetId": {"type": "string", "minLength": 1, "maxLength": 2048},
|
||||
"severity": {"enum": ["CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"]},
|
||||
"cvss": {"type": ["number", "null"], "minimum": 0, "maximum": 10},
|
||||
"package": {"type": "string", "maxLength": 256},
|
||||
"installedVersion": {"type": "string", "maxLength": 256},
|
||||
"fixedVersion": {"type": ["string", "null"], "maxLength": 512},
|
||||
"title": {"type": "string", "maxLength": 512},
|
||||
"url": {"type": "string", "maxLength": 2048, "pattern": "^(https?://.+)?$"},
|
||||
"firstSeenAt": {"type": "string", "format": "date-time"},
|
||||
"status": {"enum": ["open", "fix-available", "accepted"]},
|
||||
"acceptance": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["reason", "expires"],
|
||||
"properties": {
|
||||
"reason": {"type": "string", "minLength": 1, "maxLength": 2048},
|
||||
"expires": {"type": "string", "format": "date"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"if": {"properties": {"status": {"const": "accepted"}}},
|
||||
"then": {"required": ["acceptance"]},
|
||||
"else": {"not": {"required": ["acceptance"]}}
|
||||
}
|
||||
},
|
||||
"remediation": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["image", "state", "url", "updatedAt"],
|
||||
"properties": {
|
||||
"image": {"type": "string", "minLength": 1, "maxLength": 512},
|
||||
"state": {"enum": ["pr-open", "approval-requested", "no-update-identified", "not-tracked"]},
|
||||
"url": {"type": ["string", "null"], "maxLength": 2048, "pattern": "^https?://.+$"},
|
||||
"updatedAt": {"type": "string", "format": "date-time"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Persisted scanner state and the time helpers every module shares.
|
||||
|
||||
state.json holds what must survive a restart: the last inventory, one record per unit of scan
|
||||
(a digest, or a by-tag target id), the database info, the last run, remediation and heartbeat.
|
||||
Every write is temp-file + fsync + rename, so a crash leaves the previous file intact.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
log = logging.getLogger("vuln-scanner.state")
|
||||
|
||||
STATE_VERSION = 1
|
||||
RETENTION = timedelta(days=7) # how long a scan record outlives its target in the inventory
|
||||
|
||||
_RFC3339 = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$")
|
||||
|
||||
|
||||
def utcnow():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def fmt_ts(value):
|
||||
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def parse_ts(value):
|
||||
"""A timezone-aware datetime for an RFC 3339 string, or None for anything else."""
|
||||
if not isinstance(value, str) or not _RFC3339.match(value):
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def atomic_write(path, data):
|
||||
directory = os.path.dirname(path) or "."
|
||||
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
dir_fd = os.open(directory, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(dir_fd)
|
||||
finally:
|
||||
os.close(dir_fd)
|
||||
|
||||
|
||||
def new_state():
|
||||
return {
|
||||
"version": STATE_VERSION,
|
||||
"inventory": [],
|
||||
"inventoryAt": None,
|
||||
"inventoryError": None,
|
||||
"scans": {},
|
||||
"database": None,
|
||||
"run": None,
|
||||
"lastCompleteRunAt": None,
|
||||
"remediation": [],
|
||||
"triageHeartbeatAt": None,
|
||||
"loadError": None,
|
||||
}
|
||||
|
||||
|
||||
def new_record(now):
|
||||
return {
|
||||
"firstDiscoveredAt": fmt_ts(now),
|
||||
"lastInventoriedAt": fmt_ts(now),
|
||||
"lastAttemptAt": None,
|
||||
"lastSuccessAt": None,
|
||||
"outcome": None,
|
||||
"reason": None,
|
||||
"failedSince": None,
|
||||
"findings": [],
|
||||
"firstSeen": {},
|
||||
}
|
||||
|
||||
|
||||
class Store:
|
||||
"""state.json on the data volume."""
|
||||
|
||||
def __init__(self, data_dir):
|
||||
self.data_dir = data_dir
|
||||
self.path = os.path.join(data_dir, "state.json")
|
||||
|
||||
def load(self):
|
||||
try:
|
||||
with open(self.path, "rb") as handle:
|
||||
loaded = json.loads(handle.read())
|
||||
if not isinstance(loaded, dict) or loaded.get("version") != STATE_VERSION:
|
||||
raise ValueError("unrecognised state version")
|
||||
except FileNotFoundError:
|
||||
return new_state()
|
||||
except (OSError, ValueError) as exc:
|
||||
log.critical("state.json is unreadable (%s); starting empty and marking the run degraded", exc)
|
||||
try:
|
||||
os.replace(self.path, self.path + ".corrupt")
|
||||
except OSError:
|
||||
pass
|
||||
fresh = new_state()
|
||||
fresh["loadError"] = "state.json was unreadable at startup"
|
||||
return fresh
|
||||
merged = new_state()
|
||||
merged.update(loaded)
|
||||
return merged
|
||||
|
||||
def save(self, state):
|
||||
atomic_write(self.path, json.dumps(state, sort_keys=True).encode("utf-8"))
|
||||
|
||||
|
||||
def merge_inventory(state, targets, now):
|
||||
"""Install a fresh inventory; create a record per new unit; forget long-gone units."""
|
||||
state["inventory"] = targets
|
||||
state["inventoryAt"] = fmt_ts(now)
|
||||
state["inventoryError"] = None
|
||||
stamp = fmt_ts(now)
|
||||
for target in targets:
|
||||
key = scan_key(target)
|
||||
if key is None:
|
||||
continue
|
||||
record = state["scans"].get(key)
|
||||
if record is None:
|
||||
record = state["scans"][key] = new_record(now)
|
||||
record["lastInventoriedAt"] = stamp
|
||||
cutoff = now - RETENTION
|
||||
for key in [k for k, r in state["scans"].items() if (parse_ts(r["lastInventoriedAt"]) or now) < cutoff]:
|
||||
del state["scans"][key]
|
||||
|
||||
|
||||
def scan_key(target):
|
||||
"""The unit of scan: the digest, else the by-tag target id. VMs and nodes have none."""
|
||||
if target["type"] != "image":
|
||||
return None
|
||||
return target["digest"] or target["id"]
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Run Trivy and normalise its output. Everything Trivy emits is untrusted upstream text."""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
log = logging.getLogger("vuln-scanner.trivy")
|
||||
|
||||
SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
|
||||
_CREDENTIAL = re.compile(
|
||||
r"unauthorized|authentication required|\bdenied:|access to the resource is denied|forbidden"
|
||||
r"|status(?: code)?:? ?(?:401|403)\b|\b40[13] (?:unauthorized|forbidden)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
class ScanFailure(Exception):
|
||||
"""A scan did not produce a result. kind is `failed` or `credential-required`."""
|
||||
|
||||
def __init__(self, kind, reason):
|
||||
super().__init__(reason)
|
||||
self.kind = kind
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def text(value, limit=512):
|
||||
"""A bounded, control-character-free string; non-strings become empty."""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return _CONTROL.sub(" ", value).strip()[:limit]
|
||||
|
||||
|
||||
def safe_url(value):
|
||||
"""value if it is a plain http(s) URL, else the empty string."""
|
||||
url = text(value, 2048)
|
||||
if not url or url != value or re.search(r"\s", url):
|
||||
return ""
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
return ""
|
||||
return url if parts.scheme.lower() in ("http", "https") and parts.netloc else ""
|
||||
|
||||
|
||||
def _cvss(vuln):
|
||||
best = None
|
||||
for vendor in (vuln.get("CVSS") or {}).values() if isinstance(vuln.get("CVSS"), dict) else ():
|
||||
if not isinstance(vendor, dict):
|
||||
continue
|
||||
for key in ("V40Score", "V3Score", "V2Score"):
|
||||
score = vendor.get(key)
|
||||
if isinstance(score, (int, float)) and not isinstance(score, bool) and 0 <= score <= 10:
|
||||
best = max(best, float(score)) if best is not None else float(score)
|
||||
break
|
||||
return best
|
||||
|
||||
|
||||
def parse_results(document):
|
||||
"""Findings (id, severity, cvss, package, installedVersion, fixedVersion, title, url), deduped."""
|
||||
if not isinstance(document, dict) or "SchemaVersion" not in document:
|
||||
raise ScanFailure("failed", "trivy produced an unrecognised report")
|
||||
results = document.get("Results")
|
||||
if results is None:
|
||||
return []
|
||||
if not isinstance(results, list):
|
||||
raise ScanFailure("failed", "trivy report has a malformed Results field")
|
||||
seen = {}
|
||||
for result in results:
|
||||
vulnerabilities = result.get("Vulnerabilities") if isinstance(result, dict) else None
|
||||
if vulnerabilities is None:
|
||||
continue
|
||||
if not isinstance(vulnerabilities, list):
|
||||
raise ScanFailure("failed", "trivy report has a malformed Vulnerabilities field")
|
||||
for vuln in vulnerabilities:
|
||||
if not isinstance(vuln, dict) or not text(vuln.get("VulnerabilityID")):
|
||||
raise ScanFailure("failed", "trivy report has a finding with no identifier")
|
||||
severity = text(vuln.get("Severity")).upper()
|
||||
fixed = text(vuln.get("FixedVersion"))
|
||||
finding = {
|
||||
"id": text(vuln.get("VulnerabilityID"), 256),
|
||||
"severity": severity if severity in SEVERITIES else "UNKNOWN",
|
||||
"cvss": _cvss(vuln),
|
||||
"package": text(vuln.get("PkgName"), 256),
|
||||
"installedVersion": text(vuln.get("InstalledVersion"), 256),
|
||||
"fixedVersion": fixed or None,
|
||||
"title": text(vuln.get("Title") or vuln.get("Description"), 512),
|
||||
"url": safe_url(vuln.get("PrimaryURL")),
|
||||
}
|
||||
seen[(finding["id"], finding["package"], finding["installedVersion"])] = finding
|
||||
return sorted(seen.values(), key=lambda f: (f["id"], f["package"], f["installedVersion"]))
|
||||
|
||||
|
||||
def classify_failure(registry, stderr):
|
||||
"""Sort a non-zero Trivy exit into credential-required or failed. Reasons name the registry."""
|
||||
if _CREDENTIAL.search(stderr or ""):
|
||||
return ScanFailure("credential-required", "registry " + registry + " refused anonymous access")
|
||||
lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()]
|
||||
return ScanFailure("failed", text(lines[-1], 400) if lines else "trivy exited with an error and no message")
|
||||
|
||||
|
||||
class Runner:
|
||||
"""The real Trivy binary. Tests inject a runner with the same four methods."""
|
||||
|
||||
def __init__(self, trivy_bin, cache_dir, timeout=600):
|
||||
self.bin = trivy_bin
|
||||
self.cache_dir = cache_dir
|
||||
self.timeout = timeout
|
||||
|
||||
def _run(self, args, timeout):
|
||||
return subprocess.run([self.bin] + args, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
|
||||
def download_db(self):
|
||||
result = self._run(["image", "--download-db-only", "--no-progress", "--cache-dir", self.cache_dir], 900)
|
||||
if result.returncode != 0:
|
||||
raise ScanFailure("failed", "database update failed: " + text(result.stderr, 300))
|
||||
|
||||
def db_info(self):
|
||||
"""{updatedAt, version} of the DB on disk, or None when no DB is present."""
|
||||
result = self._run(["version", "--format", "json", "--cache-dir", self.cache_dir], 60)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
db = json.loads(result.stdout).get("VulnerabilityDB")
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
if not isinstance(db, dict) or not db.get("UpdatedAt"):
|
||||
return None
|
||||
return {"updatedAt": db["UpdatedAt"], "version": str(db.get("Version", "unknown"))}
|
||||
|
||||
def scan(self, reference):
|
||||
args = [
|
||||
"image", "--skip-db-update", "--image-src", "remote", "--scanners", "vuln", "--format", "json",
|
||||
"--no-progress", "--cache-dir", self.cache_dir, "--timeout", str(self.timeout) + "s", reference,
|
||||
]
|
||||
try:
|
||||
result = self._run(args, self.timeout + 30)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ScanFailure("failed", "timed out after " + str(self.timeout) + "s") from None
|
||||
except OSError as exc:
|
||||
raise ScanFailure("failed", "could not start trivy: " + type(exc).__name__) from None
|
||||
if result.returncode < 0:
|
||||
raise ScanFailure("failed", "trivy killed by signal " + str(-result.returncode) + " (out of memory?)")
|
||||
if result.returncode != 0:
|
||||
raise classify_failure(reference.split("/", 1)[0], result.stderr)
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except ValueError:
|
||||
raise ScanFailure("failed", "trivy output was not valid JSON") from None
|
||||
|
||||
def clean_cache(self):
|
||||
self._run(["clean", "--scan-cache", "--cache-dir", self.cache_dir], 120)
|
||||
Reference in new issue
Block a user