#!/usr/bin/env bash # ============================================================================= # L0 static check (offline, no cluster) — vuln-scanner-isolation # Serves: R-VULN-09 (docs/17 "Security of the scanner itself"). # # GIVEN the repository as authored, # THEN (1) the Flux tier is wait:false, dependsOn platform-config, in neither gating tier, nothing # anywhere dependsOn it, and both overlays carry its SOPS decryption patch; # (2) ingress is EXACTLY: reserved:ingress to 4180, act-runner to 8081, prometheus to 9090; # (3) egress to the world is tcp/443 only through a CIDR rule excluding every private range, # and the only other destinations are the API entity, the Gateway entity on 443 and Keycloak # (the oauth2-proxy sidecar's OIDC path, the one deliberate addition to the spec's list); # no `world`, `all`, `cluster`, `host` or `remote-node` entity; # (4) the namespace ships a default-deny baseline and is not app-namespace=true (single owner); # AND (5) 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, re, sys from pathlib import Path import yaml ROOT = Path(sys.argv[1]) NAME = "vuln-scanner-isolation" FAILURES, CHECKS = [], 0 FORBIDDEN_ENTITIES = {"world", "all", "cluster", "host", "remote-node"} PRIVATE_RANGES = {"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"} 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)] fk_path = ROOT / "clusters" / "base" / "vuln-scanner.yaml" np_path = ROOT / "platform" / "vuln-scanner" / "networkpolicy.yaml" for p in (fk_path, np_path): if not p.is_file(): print(f"CHECK FAIL ({NAME}): {p.relative_to(ROOT)} is absent", file=sys.stderr) raise SystemExit(1) # (1) Flux wiring fk = next(d for d in docs(fk_path) if d.get("kind") == "Kustomization") check(fk["spec"].get("wait") is False, "(1) clusters/base/vuln-scanner.yaml must be wait: false") check([d.get("name") for d in fk["spec"].get("dependsOn") or []] == ["platform-config"], "(1) the tier dependsOn exactly platform-config") check(fk["spec"].get("path") == "./platform/vuln-scanner", "(1) the tier reconciles ./platform/vuln-scanner") check((fk["spec"].get("postBuild") or {}).get("substituteFrom"), "(1) postBuild.substituteFrom is present (the route/alerts use ${BASE_DOMAIN})") check("vuln-scanner.yaml" in (ROOT / "clusters" / "base" / "kustomization.yaml").read_text(), "(1) clusters/base/kustomization.yaml lists vuln-scanner.yaml") check("vuln-scanner" not in (ROOT / "platform" / "kustomization.yaml").read_text(), "(1) platform/kustomization.yaml must not include vuln-scanner (the gating tier)") for f in sorted((ROOT / "clusters").rglob("*.yaml")): for d in docs(f): for dep in (d.get("spec") or {}).get("dependsOn") or []: check(not (isinstance(dep, dict) and dep.get("name") == "vuln-scanner"), f"(1) {f.relative_to(ROOT)} {d.get('metadata', {}).get('name')} dependsOn vuln-scanner — " "a broken scanner must never stall anything") for env in ("dev", "prod"): text = (ROOT / "clusters" / env / "kustomization.yaml").read_text() check(re.search(r"name:\s*vuln-scanner\s*\n\s*namespace:\s*flux-system\s*\n\s*spec:\s*\n\s*decryption:", text) is not None, f"(1) clusters/{env}/kustomization.yaml carries the vuln-scanner SOPS decryption patch") # Network shape np_docs = docs(np_path) dd = [d for d in np_docs if d.get("kind") == "NetworkPolicy" and d["metadata"]["name"] == "default-deny"] check(len(dd) == 1 and set(dd[0]["spec"]["policyTypes"]) == {"Ingress", "Egress"} and dd[0]["spec"].get("ingress") == [], "(4) default-deny baseline denies both directions with no ingress") ns = next(d for d in docs(ROOT / "platform" / "vuln-scanner" / "namespace.yaml") if d.get("kind") == "Namespace") check("homelab.keithjackson.dev/app-namespace" not in (ns["metadata"].get("labels") or {}), "(4) the namespace must not be app-namespace=true (the default-deny would be double-owned)") def cnp(name): return next((d for d in np_docs if d.get("kind") == "CiliumNetworkPolicy" and d["metadata"]["name"] == name), None) def ports_of(rule): return {(p.get("port"), p.get("protocol", "TCP")) for tp in rule.get("toPorts") or [] for p in tp.get("ports") or []} def eval_ingress(policy): bad = [] rules = policy["spec"].get("ingress") or [] if len(rules) != 3: bad.append(f"expected exactly 3 ingress rules, found {len(rules)}") seen = set() for r in rules: ports = ports_of(r) if r.get("fromEntities") == ["ingress"] and ports == {("4180", "TCP")}: seen.add("gateway") elif len(r.get("fromEndpoints") or []) == 1: sel = r["fromEndpoints"][0].get("matchLabels") or {} if (sel.get("io.kubernetes.pod.namespace") == "gitea-actions" and sel.get("app.kubernetes.io/name") == "act-runner" and ports == {("8081", "TCP")}): seen.add("ci") elif (sel.get("io.kubernetes.pod.namespace") == "monitoring" and sel.get("app.kubernetes.io/name") == "prometheus" and ports == {("9090", "TCP")}): seen.add("prometheus") else: bad.append(f"unexpected ingress rule {r}") else: bad.append(f"unexpected ingress rule {r}") missing = {"gateway", "ci", "prometheus"} - seen if missing: bad.append(f"ingress paths missing: {sorted(missing)}") return bad def eval_egress(policy): bad = [] rules = policy["spec"].get("egress") or [] if len(rules) != 4: bad.append(f"expected exactly 4 egress rules, found {len(rules)}") world = 0 for r in rules: ents = set(r.get("toEntities") or []) if ents & FORBIDDEN_ENTITIES: bad.append(f"egress admits forbidden entity {sorted(ents & FORBIDDEN_ENTITIES)}") if ents == {"kube-apiserver"} and not r.get("toPorts"): continue if ents == {"ingress"} and ports_of(r) == {("443", "TCP")}: continue if r.get("toEndpoints"): sel = r["toEndpoints"][0].get("matchLabels") or {} if not (len(r["toEndpoints"]) == 1 and sel.get("io.kubernetes.pod.namespace") == "keycloak" and sel.get("app") == "keycloak"): bad.append(f"egress to unexpected endpoints {r}") continue if r.get("toCIDRSet"): world += 1 sets = r["toCIDRSet"] if len(sets) != 1 or sets[0].get("cidr") != "0.0.0.0/0": bad.append(f"unexpected CIDR rule {r}") continue if not PRIVATE_RANGES.issubset(set(sets[0].get("except") or [])): bad.append(f"the world CIDR rule does not exclude {sorted(PRIVATE_RANGES - set(sets[0].get('except') or []))}") if ports_of(r) != {("443", "TCP")}: bad.append(f"the world CIDR rule is not tcp/443 only ({sorted(ports_of(r))})") continue if r.get("toCIDR"): bad.append(f"egress names a bare CIDR rule {r}") continue if not ents: bad.append(f"unexpected egress rule {r}") if world != 1: bad.append(f"expected exactly one world CIDR rule, found {world}") return bad ing, eg = cnp("vuln-scanner-allow-ingress"), cnp("vuln-scanner-allow-egress") check(ing is not None, "(2) the ingress CiliumNetworkPolicy exists") check(eg is not None, "(3) the egress CiliumNetworkPolicy exists") if ing is not None: for v in eval_ingress(ing): check(False, f"(2) {v}") if eg is not None: for v in eval_egress(eg): check(False, f"(3) {v}") check(len([d for d in np_docs if d.get("kind") == "CiliumNetworkPolicy"]) == 2, "(3) exactly two CiliumNetworkPolicies, so no other path is added silently") # (5) non-vacuity if ing is not None and eg is not None: def m_ing_open(p): p["spec"]["ingress"].append({"fromEntities": ["world"], "toPorts": [{"ports": [{"port": "8081", "protocol": "TCP"}]}]}) def m_ing_ns(p): p["spec"]["ingress"][1]["fromEndpoints"][0]["matchLabels"]["io.kubernetes.pod.namespace"] = "default" def m_ing_port(p): p["spec"]["ingress"][0]["toPorts"][0]["ports"][0]["port"] = "8080" for name, fn in (("extra world ingress", m_ing_open), ("CI rule moved to another namespace", m_ing_ns), ("Gateway reaches the page port", m_ing_port)): m = copy.deepcopy(ing) fn(m) check(bool(eval_ingress(m)), f"(5) ingress evaluator missed mutant: {name}") def m_eg_world(p): p["spec"]["egress"].append({"toEntities": ["world"]}) def m_eg_lan(p): for r in p["spec"]["egress"]: if r.get("toCIDRSet"): r["toCIDRSet"][0]["except"] = ["10.0.0.0/8"] def m_eg_port(p): for r in p["spec"]["egress"]: if r.get("toCIDRSet"): r["toPorts"][0]["ports"].append({"port": "80", "protocol": "TCP"}) def m_eg_endpoint(p): for r in p["spec"]["egress"]: if r.get("toEndpoints"): r["toEndpoints"][0]["matchLabels"]["io.kubernetes.pod.namespace"] = "default" for name, fn in (("world entity added", m_eg_world), ("LAN not excluded", m_eg_lan), ("port 80 added", m_eg_port), ("endpoints moved", m_eg_endpoint)): m = copy.deepcopy(eg) fn(m) check(bool(eval_egress(m)), f"(5) egress evaluator missed mutant: {name}") if CHECKS < 18: 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