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