Review findings on the integrated scanner: - An OOM kill no longer wedges the loop: inFlight is persisted before each scan, and on restart that image is marked failed and sits out full runs for 20h. - One bad upstream string can no longer freeze the report: safe_url requires an exact lowercase scheme, acceptance reasons are bounded, /triage urls use the same rule, and report.build repairs or drops any entry the schema would refuse. lastCompleteRunAt advances only after a report is actually published. - The private registry reports credential-required, not failed: VULN_PRIVATE_REGISTRIES names it, and Trivy is not invoked. - loadError clears after a full run against a good inventory. - Every image under platform/vuln-scanner stays out of Renovate auto-merge. - The page labels status and target state per line, and mixed acceptance reads as mixed. The Keycloak client is still an operator action: its key must go in an existing encrypted file that an agent must not modify. platform/vuln-scanner/README.md has the exact steps. Co-Authored-By: Claude Opus 5.5 <[email protected]>
352 lines
15 KiB
Bash
Executable File
352 lines
15 KiB
Bash
Executable File
#!/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:<ver>@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
|