feat(vuln): vuln-scanner platform manifests, Flux tier, alerts, L0/L2/L3 tests
Hand-authored platform/vuln-scanner (non-gating Flux tier, digest-pinned Trivy 0.76.1 and python 3.14.9-alpine, read-only RBAC, per-pod oauth2-proxy, internal-only cve.<domain>), five alerts with promtool tests, R-VULN-01..09 registry entries, and vuln-* L0 checks with mutation self-tests. src/ and the bot workflow are integrated separately. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
4c3a284cad
commit
b85d1e3ed7
47 files changed
+2572
-6
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — the scanner's Flux tier cannot stall the platform, and its network reach is exactly what
|
||||
# docs/17 "Security of the scanner itself" allows (R-VULN-09). Offline, no cluster.
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: vuln-scanner-isolation
|
||||
labels:
|
||||
conformance.home.k3s/R-VULN-09: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-VULN-09"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
The vuln-scanner Flux tier is wait:false, in neither gating tier, and nothing dependsOn it;
|
||||
ingress is exactly the Gateway to 4180, the CI runner to 8081 and Prometheus to 9090;
|
||||
egress reaches the API, DNS, the public internet on 443 only with cluster and LAN excluded,
|
||||
and the Keycloak path the sidecar needs. Proven by mutation.
|
||||
spec:
|
||||
steps:
|
||||
- name: assert-isolation-and-network-shape
|
||||
description: >-
|
||||
Parse the Flux wiring and networkpolicy.yaml, assert isolation and the exact network shape,
|
||||
and run the evaluators on mutated copies to prove they can fail.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
#!/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
|
||||
Reference in new issue
Block a user