#!/usr/bin/env bash # ============================================================================= # L0 static check (offline, no cluster) — vuln-scanner-supply-chain # Serves: R-VULN-09 (docs/17 "Security of the scanner itself"). # # GIVEN the repository as authored, # THEN (1) the Trivy image is `ghcr.io/aquasecurity/trivy:@sha256:<64hex>` — by digest, and a # tag so Renovate has something to follow; # (2) that version is not 0.69.4/0.69.5/0.69.6 (the compromised March 2026 releases); # (3) every pod image is digest-pinned except the oauth2-proxy sidecar, which follows the # per-app sidecar pattern so one Renovate branch moves them all; # (4) renovate.json5 keeps Trivy in the blast-radius list, automerge:false, minimumReleaseAge # of at least 7 days, and no later rule re-enables automerge for it; # (5) rbac.yaml: only ClusterRole/ClusterRoleBinding to the scanner SA, only get/list/watch, no # Secret, no wildcard, and only the resources the inventory needs; # (6) the pod: non-root, read-only rootfs, drop ALL, seccomp RuntimeDefault, no host # namespaces or hostPath, CPU and memory limits on every container, an ephemeral-storage # limit on the scanner, and a size-limited /cache emptyDir. # (8) a renovate.json5 rule covers every file under platform/vuln-scanner: automerge:false, # minimumReleaseAge of at least 7 days, and no later rule turns auto-merge back on (the # python image holds the cluster-read token); # (9) the scanner names the private registry it cannot reach, so that registry reports # credential-required rather than a permanent failed. # AND (7) each evaluator is run on a mutated copy and must flag it (non-vacuity). # ============================================================================= set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "${DIR}/../../../.." && pwd)" exec python3 - "$ROOT" <<'PY' import copy, json, re, sys from pathlib import Path import yaml ROOT = Path(sys.argv[1]) NAME = "vuln-scanner-supply-chain" FAILURES, CHECKS = [], 0 COMP = ROOT / "platform" / "vuln-scanner" BANNED = {"0.69.4", "0.69.5", "0.69.6"} DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") ALLOWED_VERBS = {"get", "list", "watch"} ALLOWED_RESOURCES = { "": {"pods", "nodes"}, "apps": {"replicasets", "deployments", "statefulsets", "daemonsets"}, "batch": {"jobs", "cronjobs"}, "kubevirt.io": {"virtualmachineinstances"}, } def check(cond, desc): global CHECKS CHECKS += 1 if not cond: FAILURES.append(desc) def docs(path): return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)] for f in ("deployment.yaml", "rbac.yaml"): if not (COMP / f).is_file(): print(f"CHECK FAIL ({NAME}): platform/vuln-scanner/{f} is absent", file=sys.stderr) raise SystemExit(1) def split_ref(image): # A digest suffix, then a tag after the last ':' (only when it holds no '/', so a registry port # stays in the repository part). ref, _, digest = image.partition("@") head, colon, tail = ref.rpartition(":") if colon and "/" not in tail: return head, tail, digest or None return ref, None, digest or None def containers(pod): return (pod.get("initContainers") or []) + (pod.get("containers") or []) def eval_images(pod): bad = [] trivy = [c for c in containers(pod) if split_ref(c["image"])[0] == "ghcr.io/aquasecurity/trivy"] if len(trivy) != 1: bad.append(f"expected one Trivy image, found {len(trivy)}") for c in containers(pod): repo, tag, digest = split_ref(c["image"]) if repo == "quay.io/oauth2-proxy/oauth2-proxy": if not tag or tag == "latest": bad.append("oauth2-proxy sidecar has no version tag") continue if not digest or not DIGEST.match(digest): bad.append(f"{c['name']} image {c['image']} is not pinned by digest") if not tag: bad.append(f"{c['name']} image {c['image']} has no tag for Renovate to follow") if repo == "ghcr.io/aquasecurity/trivy" and tag in BANNED: bad.append(f"Trivy {tag} is a banned (compromised) version") return bad def strip_json5(text): # Drop // comments outside strings, then trailing commas. out, i, in_str, esc = [], 0, False, False while i < len(text): ch = text[i] if in_str: out.append(ch) if esc: esc = False elif ch == "\\": esc = True elif ch == '"': in_str = False i += 1 continue if ch == '"': in_str = True out.append(ch) i += 1 continue if text.startswith("//", i): while i < len(text) and text[i] != "\n": i += 1 continue out.append(ch) i += 1 return re.sub(r",(\s*[}\]])", r"\1", "".join(out)) TRIVY_PKG = "ghcr.io/aquasecurity/trivy" VERSION_UPDATE_RULE_RE = re.compile(r"^(\d+)\s*(day|days)$") def eval_renovate(rules): bad = [] trivy_rules = [r for r in rules if TRIVY_PKG in (r.get("matchPackageNames") or [])] if not trivy_rules: bad.append("no packageRule matches ghcr.io/aquasecurity/trivy (blast-radius list)") return bad if not any(r.get("automerge") is False and "blast-radius" in (r.get("addLabels") or []) for r in trivy_rules): bad.append("Trivy is not in a blast-radius rule with automerge:false") ages = [r["minimumReleaseAge"] for r in trivy_rules if "minimumReleaseAge" in r] if not ages or not any(VERSION_UPDATE_RULE_RE.match(a) and int(VERSION_UPDATE_RULE_RE.match(a).group(1)) >= 7 for a in ages): bad.append(f"Trivy has no minimumReleaseAge of at least 7 days (found {ages})") # Renovate applies packageRules in order, later wins; the last rule that names automerge for # Trivy (or matches it generically by update type) must be false. last = None for r in rules: if "automerge" in r and (TRIVY_PKG in (r.get("matchPackageNames") or []) or set(r) <= {"description", "matchUpdateTypes", "automerge", "automergeType", "platformAutomerge"}): last = r if last is None or last.get("automerge") is not False: bad.append("the last packageRule deciding automerge for Trivy leaves it true") return bad SCANNER_FILES = "platform/vuln-scanner/**" def eval_renovate_files(rules): positions = [i for i, r in enumerate(rules) if SCANNER_FILES in (r.get("matchFileNames") or [])] if not positions: return [f"no packageRule has matchFileNames {SCANNER_FILES}"] bad = [] rule = rules[positions[-1]] if rule.get("automerge") is not False: bad.append("the platform/vuln-scanner file rule is not automerge:false") age = VERSION_UPDATE_RULE_RE.match(rule.get("minimumReleaseAge") or "") if not age or int(age.group(1)) < 7: bad.append("the platform/vuln-scanner file rule has no minimumReleaseAge of at least 7 days") if any(r.get("automerge") is True for r in rules[positions[-1] + 1:]): bad.append("a rule after the platform/vuln-scanner file rule re-enables auto-merge") return bad def eval_private_registries(pod): scanner = next(c for c in pod["containers"] if c["name"] == "scanner") env = {e["name"]: e.get("value") for e in scanner.get("env") or []} if env.get("VULN_PRIVATE_REGISTRIES") != "gitea.${BASE_DOMAIN}": return ["the scanner does not name the private registry gitea.${BASE_DOMAIN}"] return [] def eval_rbac(rbac): bad = [] croles = [d for d in rbac if d.get("kind") == "ClusterRole"] if len(croles) != 1: bad.append(f"expected one ClusterRole, found {len(croles)}") return bad for rule in croles[0].get("rules") or []: verbs = set(rule.get("verbs") or []) if verbs - ALLOWED_VERBS: bad.append(f"rule grants non-read verbs {sorted(verbs - ALLOWED_VERBS)}") if "*" in verbs: bad.append("rule grants the verb wildcard") for group in rule.get("apiGroups") or []: if group == "*" or group not in ALLOWED_RESOURCES: bad.append(f"rule names apiGroup {group!r}") continue for res in rule.get("resources") or []: if res == "*" or res not in ALLOWED_RESOURCES[group]: bad.append(f"rule grants resource {group}/{res}") if "secrets" in (rule.get("resources") or []): bad.append("rule grants Secret access") kinds = {d.get("kind") for d in rbac} if kinds - {"ServiceAccount", "ClusterRole", "ClusterRoleBinding"}: bad.append(f"unexpected RBAC kinds {sorted(kinds)}") for b in (d for d in rbac if d.get("kind") == "ClusterRoleBinding"): if (b.get("roleRef") or {}).get("name") != croles[0]["metadata"]["name"]: bad.append("the ClusterRoleBinding does not bind the scanner's ClusterRole") return bad def eval_pod(pod): bad = [] psc = pod.get("securityContext") or {} if psc.get("runAsNonRoot") is not True: bad.append("pod runAsNonRoot is not true") if (psc.get("seccompProfile") or {}).get("type") != "RuntimeDefault": bad.append("pod seccompProfile is not RuntimeDefault") for f in ("hostNetwork", "hostPID", "hostIPC"): if pod.get(f): bad.append(f"pod sets {f}") if any("hostPath" in v for v in pod.get("volumes") or []): bad.append("pod mounts a hostPath volume") for c in containers(pod): sc = c.get("securityContext") or {} if sc.get("readOnlyRootFilesystem") is not True: bad.append(f"{c['name']} root filesystem is writable") if sc.get("allowPrivilegeEscalation") is not False: bad.append(f"{c['name']} allows privilege escalation") if "ALL" not in ((sc.get("capabilities") or {}).get("drop") or []): bad.append(f"{c['name']} does not drop ALL capabilities") limits = (c.get("resources") or {}).get("limits") or {} for lim in ("cpu", "memory"): if lim not in limits: bad.append(f"{c['name']} has no {lim} limit") if c["name"] == "scanner" and "ephemeral-storage" not in limits: bad.append("the scanner has no ephemeral-storage limit") vols = {v["name"]: v for v in pod.get("volumes") or []} if not (vols.get("cache", {}).get("emptyDir") or {}).get("sizeLimit"): bad.append("the /cache emptyDir has no sizeLimit (Longhorn or etcd must never hold it)") return bad dep = next(d for d in docs(COMP / "deployment.yaml") if d.get("kind") == "Deployment") pod = dep["spec"]["template"]["spec"] for v in eval_images(pod) + eval_pod(pod): check(False, f"(1-3,6) {v}") check(dep["spec"].get("replicas") == 1, "(6) one replica (one RWO report store)") check((dep["spec"].get("strategy") or {}).get("type") == "Recreate", "(6) Recreate strategy") renovate = json.loads(strip_json5((ROOT / "renovate.json5").read_text())) rules = renovate.get("packageRules") or [] check(len(rules) > 5, "(4) parsed renovate.json5 packageRules (parser degenerated)") for v in eval_renovate(rules): check(False, f"(4) {v}") for v in eval_renovate_files(rules): check(False, f"(8) {v}") for v in eval_private_registries(pod): check(False, f"(9) {v}") rbac = docs(COMP / "rbac.yaml") for v in eval_rbac(rbac): check(False, f"(5) {v}") # (7) non-vacuity def mutated(fn, base): m = copy.deepcopy(base) fn(m) return m # Image mutants def img_digest(p): p["initContainers"][0]["image"] = "ghcr.io/aquasecurity/trivy:0.76.1" def img_banned(p): p["initContainers"][0]["image"] = re.sub(r":[0-9.]+@", ":0.69.5@", p["initContainers"][0]["image"]) def img_nopin(p): p["containers"][0]["image"] = "docker.io/library/python:3.14.9-alpine" for name, fn in (("digest stripped from Trivy", img_digest), ("banned Trivy version", img_banned), ("python image unpinned", img_nopin)): check(bool(eval_images(mutated(fn, pod))), f"(7) image evaluator missed mutant: {name}") # RBAC mutants def rbac_secret(r): role = next(d for d in r if d.get("kind") == "ClusterRole") role["rules"][0]["resources"].append("secrets") def rbac_write(r): role = next(d for d in r if d.get("kind") == "ClusterRole") role["rules"][0]["verbs"].append("delete") def rbac_wild(r): role = next(d for d in r if d.get("kind") == "ClusterRole") role["rules"][1]["resources"] = ["*"] for name, fn in (("Secret resource granted", rbac_secret), ("write verb granted", rbac_write), ("resource wildcard granted", rbac_wild)): check(bool(eval_rbac(mutated(fn, rbac))), f"(7) RBAC evaluator missed mutant: {name}") # Renovate mutants def ren_auto(r): for x in r: if TRIVY_PKG in (x.get("matchPackageNames") or []): x["automerge"] = True def ren_age(r): for x in r: if TRIVY_PKG in (x.get("matchPackageNames") or []) and "minimumReleaseAge" in x: x["minimumReleaseAge"] = "2 days" def ren_drop(r): r[:] = [x for x in r if TRIVY_PKG not in (x.get("matchPackageNames") or [])] def ren_later(r): r.append({"matchUpdateTypes": ["digest"], "automerge": True}) for name, fn in (("Trivy automerge flipped true", ren_auto), ("minimumReleaseAge shortened", ren_age), ("Trivy removed from Renovate rules", ren_drop), ("later auto-merge rule appended", ren_later)): check(bool(eval_renovate(mutated(fn, rules))), f"(7) Renovate evaluator missed mutant: {name}") # Renovate file-rule mutants def files_rule(r): return next(x for x in r if SCANNER_FILES in (x.get("matchFileNames") or [])) def files_auto(r): files_rule(r)["automerge"] = True def files_age(r): files_rule(r)["minimumReleaseAge"] = "1 day" def files_drop(r): r.remove(files_rule(r)) def files_later(r): r.append({"matchDatasources": ["docker"], "automerge": True}) for name, fn in (("file rule automerge true", files_auto), ("file rule age shortened", files_age), ("file rule removed", files_drop), ("later rule re-enables auto-merge", files_later)): check(bool(eval_renovate_files(mutated(fn, rules))), f"(7) file-rule evaluator missed mutant: {name}") def reg_drop(p): next(c for c in p["containers"] if c["name"] == "scanner")["env"][:] = [ e for e in next(c for c in p["containers"] if c["name"] == "scanner")["env"] if e["name"] != "VULN_PRIVATE_REGISTRIES"] check(bool(eval_private_registries(mutated(reg_drop, pod))), "(7) private-registry evaluator missed the removal") # Pod mutants def pod_root(p): p["securityContext"]["runAsNonRoot"] = False def pod_host(p): p["hostNetwork"] = True def pod_rw(p): p["containers"][0]["securityContext"]["readOnlyRootFilesystem"] = False def pod_nolim(p): del p["containers"][0]["resources"]["limits"]["memory"] def pod_nocap(p): for v in p["volumes"]: if v["name"] == "cache": v["emptyDir"].pop("sizeLimit", None) for name, fn in (("runAsNonRoot off", pod_root), ("hostNetwork on", pod_host), ("rw rootfs", pod_rw), ("no memory limit", pod_nolim), ("no cache sizeLimit", pod_nocap)): check(bool(eval_pod(mutated(fn, pod))), f"(7) pod evaluator missed mutant: {name}") if CHECKS < 15: FAILURES.append(f"only {CHECKS} assertions ran — the check degenerated") if FAILURES: print(f"CHECK FAIL ({NAME}): {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr) for f in FAILURES: print(f" - {f}", file=sys.stderr) raise SystemExit(1) print(f"PASS ({NAME}): {CHECKS} assertions") PY