Files
home-network-k3s-impl/platform/vuln-scanner/src/inventory.py
T
jakman85andClaude Opus 5.5 50ec2297ee 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]>
2026-11-09 09:41:23 -06:00

249 lines
10 KiB
Python

"""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()