check_manifests.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. #!/usr/bin/env python3
  2. """Cluster-manifest policy lint (task 46).
  3. Validates `deploy/k8s` (and `compose.yaml` lightly) against the cluster
  4. contract WITHOUT a live cluster:
  5. - Deployment: 3 replicas, non-root 65532, read-only rootfs, RuntimeDefault
  6. seccomp, ALL capabilities dropped, requests+limits, startup/liveness on
  7. `/health/live` + readiness on `/health/ready` via httpGet against the
  8. plaintext health port (8081), `terminationGracePeriodSeconds >= 45`,
  9. topology spread, and NO per-replica migration.
  10. - Migration Job: exactly one, runs `migrate`, `restartPolicy: Never`.
  11. - PDB: `minAvailable: 2`.
  12. - Service: never exposes the health port (8081).
  13. - Hardening: `automountServiceAccountToken: false` and `fsGroup: 65532`; the
  14. token pepper and TOTP key must be file-mounted from `d-oauth-secrets` at
  15. `/run/secrets` (`defaultMode: 0400`) and referenced with
  16. `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`; the inline
  17. `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` envs FAIL.
  18. - NetworkPolicy: must select `app.kubernetes.io/name: d-oauth-server`, enable
  19. `Ingress` + `Egress`, admit ingress only on 8080/8081, and allow egress only
  20. on 53 (DNS), 5432 (Postgres), and 6379 (Redis) with `169.254.0.0/16`
  21. excluded (cloud metadata).
  22. - No baked secrets: no PEM blocks in manifests; `REPLACE_ME` placeholders
  23. only inside `secret.yaml`; no secret-shaped keys in the ConfigMap.
  24. - L7-vs-L4 mTLS constraint: an L7 Ingress that terminates TLS while
  25. in-process client-cert auth is enabled (`D_OAUTH_TLS__CLIENT_CA_PATH`
  26. set, or client-cert Ingress annotations) FAILS. mTLS needs L4/TCP
  27. passthrough (NLB gateway / TLSRoute) or explicit trusted-proxy mode.
  28. This constraint is DOCUMENTED (docs/DEPLOYMENT.md) and lint-checked
  29. here; it is explicitly NOT exercised end-to-end in CI.
  30. Usage:
  31. python3 scripts/check_manifests.py [--manifest-dir deploy/k8s]
  32. [--strict-digest]
  33. `--strict-digest` (used by supply-chain CI after the pin step) additionally
  34. requires every workload image to be digest-pinned (`@sha256:<64 hex>`).
  35. """
  36. from __future__ import annotations
  37. import argparse
  38. import re
  39. import sys
  40. from pathlib import Path
  41. import yaml
  42. HEALTH_PORT = 8081
  43. PUBLIC_PORT = 8080
  44. TERMINATION_GRACE_MIN = 45
  45. SECRET_NAME_RE = re.compile(r"(?i)(pepper|password|secret|private[_-]?key|totp)")
  46. PEM_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----|-----BEGIN CERTIFICATE-----")
  47. DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
  48. HIGH_ENTROPY_VALUE_RE = re.compile(r"^[A-Za-z0-9+/=_-]{32,}$")
  49. CLIENT_CERT_ANNOTATIONS = (
  50. "nginx.ingress.kubernetes.io/auth-tls-verify-client",
  51. "nginx.ingress.kubernetes.io/auth-tls-secret",
  52. "haproxy.org/ssl-verify-client",
  53. "traefik.ingress.kubernetes.io/router.tls.options",
  54. )
  55. SECRET_NAME = "d-oauth-secrets"
  56. SECRETS_MOUNT_PATH = "/run/secrets"
  57. FS_GROUP = 65532
  58. SECRET_VOLUME_MODE = 0o400
  59. FILE_SECRET_ENV_KEYS = {
  60. "D_OAUTH_TOKEN_PEPPER_FILE": "token-pepper",
  61. "D_OAUTH_TOTP_ENCRYPTION_KEY_FILE": "totp-encryption-key",
  62. }
  63. INLINE_SECRET_ENVS = ("D_OAUTH_TOKEN_PEPPER", "D_OAUTH_TOTP_ENCRYPTION_KEY")
  64. REQUIRED_EGRESS_PORTS = (53, 5432, 6379)
  65. METADATA_CIDR = "169.254.0.0/16"
  66. class Failures:
  67. def __init__(self) -> None:
  68. self.items: list[str] = []
  69. def add(self, msg: str) -> None:
  70. self.items.append(msg)
  71. def ok(self) -> bool:
  72. return not self.items
  73. def load_manifests(manifest_dir: Path) -> tuple[list[dict], Failures]:
  74. fails = Failures()
  75. docs: list[dict] = []
  76. files = sorted(manifest_dir.glob("*.yaml"))
  77. if not files:
  78. fails.add(f"no manifests found in {manifest_dir}")
  79. return docs, fails
  80. for path in files:
  81. if path.name.endswith(".example.yaml"):
  82. continue
  83. try:
  84. loaded = list(yaml.safe_load_all(path.read_text()))
  85. except yaml.YAMLError as exc:
  86. fails.add(f"{path.name}: invalid YAML: {exc}")
  87. continue
  88. for doc in loaded:
  89. if doc:
  90. doc["_source"] = path.name
  91. docs.append(doc)
  92. return docs, fails
  93. def by_kind(docs: list[dict], kind: str) -> list[dict]:
  94. return [d for d in docs if d.get("kind") == kind]
  95. def check_pem_and_placeholders(docs: list[dict], fails: Failures) -> None:
  96. for doc in docs:
  97. src = doc.get("_source", "?")
  98. text = yaml.safe_dump({k: v for k, v in doc.items() if not k.startswith("_")})
  99. if PEM_RE.search(text):
  100. fails.add(f"{src}: baked key material (PEM block) in a manifest")
  101. if src != "secret.yaml" and "REPLACE_ME" in text:
  102. fails.add(f"{src}: placeholder belongs only in secret.yaml")
  103. def check_configmap(doc: dict, fails: Failures) -> None:
  104. data = doc.get("data") or {}
  105. for key in data:
  106. if SECRET_NAME_RE.search(key):
  107. fails.add(f"configmap: secret-shaped key {key!r} must live in the Secret")
  108. if (data.get("D_OAUTH_TLS__CLIENT_CA_PATH") or "").strip():
  109. pass # mTLS on: L7 check below becomes load-bearing.
  110. def mtls_enabled(docs: list[dict]) -> bool:
  111. for doc in by_kind(docs, "ConfigMap"):
  112. data = doc.get("data") or {}
  113. if (data.get("D_OAUTH_TLS__CLIENT_CA_PATH") or "").strip():
  114. return True
  115. return False
  116. def octal_mode(value: object) -> int | None:
  117. """Parse a YAML file mode (`0400`, `0o400`, `256`) into an int, or None."""
  118. if isinstance(value, bool) or value is None:
  119. return None
  120. if isinstance(value, int):
  121. return value
  122. if isinstance(value, str):
  123. text = value.strip().lower().replace("_", "")
  124. try:
  125. if text.startswith("0o"):
  126. return int(text[2:], 8)
  127. if text.startswith("0") and len(text) > 1:
  128. return int(text, 8)
  129. return int(text, 10)
  130. except ValueError:
  131. return None
  132. return None
  133. def env_by_name(container: dict) -> dict[str, dict]:
  134. envs: dict[str, dict] = {}
  135. for entry in container.get("env") or []:
  136. if isinstance(entry, dict) and entry.get("name"):
  137. envs[entry["name"]] = entry
  138. return envs
  139. def check_file_mounted_secrets(
  140. workload: str, tpl: dict, container: dict, fails: Failures
  141. ) -> None:
  142. """Item 7: pepper/TOTP are 0400 files under /run/secrets, never inline env."""
  143. cname = container.get("name", "?")
  144. envs = env_by_name(container)
  145. for inline in INLINE_SECRET_ENVS:
  146. if inline in envs:
  147. fails.add(
  148. f"{workload}/{cname}: {inline} must not be injected as an env value; "
  149. "use the file-mounted *_FILE form"
  150. )
  151. mount = next(
  152. (
  153. m
  154. for m in container.get("volumeMounts") or []
  155. if isinstance(m, dict) and m.get("mountPath") == SECRETS_MOUNT_PATH
  156. ),
  157. None,
  158. )
  159. if mount is None:
  160. fails.add(
  161. f"{workload}/{cname}: must mount the secret volume at {SECRETS_MOUNT_PATH}"
  162. )
  163. elif mount.get("readOnly") is not True:
  164. fails.add(f"{workload}/{cname}: {SECRETS_MOUNT_PATH} must be readOnly")
  165. items: dict[str, str] = {}
  166. mode: int | None = None
  167. for volume in tpl.get("volumes") or []:
  168. if not isinstance(volume, dict):
  169. continue
  170. secret = volume.get("secret") or {}
  171. if secret.get("secretName") != SECRET_NAME:
  172. continue
  173. mode = octal_mode(secret.get("defaultMode"))
  174. for item in secret.get("items") or []:
  175. if isinstance(item, dict) and item.get("key") and item.get("path"):
  176. items[item["key"]] = item["path"]
  177. if mode != SECRET_VOLUME_MODE:
  178. fails.add(f"{workload}: {SECRET_NAME} volume defaultMode must be 0400")
  179. for env_name, key in FILE_SECRET_ENV_KEYS.items():
  180. if key not in items:
  181. fails.add(f"{workload}: {SECRET_NAME} volume must project key {key!r}")
  182. continue
  183. expected = f"{SECRETS_MOUNT_PATH}/{items[key]}"
  184. entry = envs.get(env_name) or {}
  185. if (entry.get("value") or "").strip() != expected:
  186. fails.add(f"{workload}/{cname}: {env_name} must point at {expected}")
  187. def policy_ports(rule: dict) -> set[int]:
  188. ports: set[int] = set()
  189. for port in rule.get("ports") or []:
  190. if isinstance(port, dict) and isinstance(port.get("port"), int):
  191. ports.add(port["port"])
  192. return ports
  193. def check_networkpolicy(doc: dict, fails: Failures) -> None:
  194. """Item 9: default-deny ingress/egress for the app pods."""
  195. name = doc.get("metadata", {}).get("name", "?")
  196. spec = doc.get("spec") or {}
  197. selector = (spec.get("podSelector") or {}).get("matchLabels") or {}
  198. if selector.get("app.kubernetes.io/name") != "d-oauth-server":
  199. fails.add(
  200. f"networkpolicy/{name}: podSelector must match "
  201. "app.kubernetes.io/name: d-oauth-server"
  202. )
  203. if not {"Ingress", "Egress"} <= set(spec.get("policyTypes") or []):
  204. fails.add(f"networkpolicy/{name}: policyTypes must include Ingress and Egress")
  205. ingress_ports: set[int] = set()
  206. for rule in spec.get("ingress") or []:
  207. if isinstance(rule, dict):
  208. ingress_ports |= policy_ports(rule)
  209. if not {PUBLIC_PORT, HEALTH_PORT} <= ingress_ports:
  210. fails.add(
  211. f"networkpolicy/{name}: ingress must admit ports "
  212. f"{PUBLIC_PORT} and {HEALTH_PORT}"
  213. )
  214. egress_ports: set[int] = set()
  215. metadata_excluded = False
  216. for rule in spec.get("egress") or []:
  217. if not isinstance(rule, dict):
  218. continue
  219. egress_ports |= policy_ports(rule)
  220. for peer in rule.get("to") or []:
  221. block = (peer or {}).get("ipBlock") or {}
  222. if METADATA_CIDR in (block.get("except") or []):
  223. metadata_excluded = True
  224. for port in REQUIRED_EGRESS_PORTS:
  225. if port not in egress_ports:
  226. fails.add(f"networkpolicy/{name}: egress must allow port {port}")
  227. if not metadata_excluded:
  228. fails.add(
  229. f"networkpolicy/{name}: egress must exclude {METADATA_CIDR} (cloud metadata)"
  230. )
  231. def check_deployment(doc: dict, fails: Failures, strict_digest: bool) -> None:
  232. name = doc.get("metadata", {}).get("name", "?")
  233. spec = doc.get("spec", {})
  234. if spec.get("replicas") != 3:
  235. fails.add(f"deployment/{name}: replicas must be 3")
  236. tpl = spec.get("template", {}).get("spec", {})
  237. if tpl.get("terminationGracePeriodSeconds", 0) < TERMINATION_GRACE_MIN:
  238. fails.add(f"deployment/{name}: terminationGracePeriodSeconds must be >= 45")
  239. pod_sc = tpl.get("securityContext", {})
  240. if pod_sc.get("runAsNonRoot") is not True:
  241. fails.add(f"deployment/{name}: runAsNonRoot must be true")
  242. if pod_sc.get("runAsUser") != 65532:
  243. fails.add(f"deployment/{name}: runAsUser must be 65532")
  244. if (pod_sc.get("seccompProfile") or {}).get("type") != "RuntimeDefault":
  245. fails.add(f"deployment/{name}: seccompProfile.type must be RuntimeDefault")
  246. if tpl.get("automountServiceAccountToken") is not False:
  247. fails.add(f"deployment/{name}: automountServiceAccountToken must be false")
  248. if pod_sc.get("fsGroup") != FS_GROUP:
  249. fails.add(
  250. f"deployment/{name}: fsGroup must be {FS_GROUP} so the 0400 secret "
  251. "files are readable by the runtime user"
  252. )
  253. if not tpl.get("topologySpreadConstraints"):
  254. fails.add(f"deployment/{name}: topologySpreadConstraints required")
  255. for container in tpl.get("containers", []):
  256. cname = container.get("name", "?")
  257. csc = container.get("securityContext", {})
  258. if csc.get("readOnlyRootFilesystem") is not True:
  259. fails.add(f"deployment/{name}/{cname}: readOnlyRootFilesystem must be true")
  260. if "ALL" not in (csc.get("capabilities") or {}).get("drop", []):
  261. fails.add(f"deployment/{name}/{cname}: capabilities.drop must include ALL")
  262. res = container.get("resources", {})
  263. if not res.get("requests") or not res.get("limits"):
  264. fails.add(f"deployment/{name}/{cname}: requests and limits required")
  265. args = " ".join(
  266. str(a)
  267. for a in (
  268. list(container.get("command", [])) + list(container.get("args", []))
  269. )
  270. )
  271. if "migrate" in args:
  272. fails.add(f"deployment/{name}/{cname}: replicas must never migrate")
  273. probes = {
  274. "startupProbe": ("/health/live", True),
  275. "livenessProbe": ("/health/live", True),
  276. "readinessProbe": ("/health/ready", True),
  277. }
  278. for probe_name, (path, _) in probes.items():
  279. probe = container.get(probe_name) or {}
  280. http_get = probe.get("httpGet") or {}
  281. if http_get.get("path") != path or http_get.get("port") != HEALTH_PORT:
  282. fails.add(
  283. f"deployment/{name}/{cname}: {probe_name} must httpGet "
  284. f"{path} on port {HEALTH_PORT} (plaintext health listener)"
  285. )
  286. image = container.get("image", "")
  287. if strict_digest and not DIGEST_RE.search(image):
  288. fails.add(f"deployment/{name}/{cname}: image must be digest-pinned")
  289. check_file_mounted_secrets(f"deployment/{name}", tpl, container, fails)
  290. def check_job(doc: dict, fails: Failures, strict_digest: bool) -> None:
  291. name = doc.get("metadata", {}).get("name", "?")
  292. spec = doc.get("spec", {}).get("template", {}).get("spec", {})
  293. if spec.get("restartPolicy") != "Never":
  294. fails.add(f"job/{name}: restartPolicy must be Never")
  295. if spec.get("automountServiceAccountToken") is not False:
  296. fails.add(f"job/{name}: automountServiceAccountToken must be false")
  297. if (spec.get("securityContext") or {}).get("fsGroup") != FS_GROUP:
  298. fails.add(
  299. f"job/{name}: fsGroup must be {FS_GROUP} so the 0400 secret files are "
  300. "readable by the runtime user"
  301. )
  302. containers = spec.get("containers", [])
  303. if not any(
  304. "migrate" in " ".join(str(a) for a in (list(c.get("command", [])) + list(c.get("args", []))))
  305. for c in containers
  306. ):
  307. fails.add(f"job/{name}: exactly one migrate Job must run `/d-oauth-server migrate`")
  308. for container in containers:
  309. if strict_digest and not DIGEST_RE.search(container.get("image", "")):
  310. fails.add(f"job/{name}: image must be digest-pinned")
  311. check_file_mounted_secrets(f"job/{name}", spec, container, fails)
  312. def check_pdb(doc: dict, fails: Failures) -> None:
  313. if doc.get("spec", {}).get("minAvailable") != 2:
  314. fails.add("poddisruptionbudget: minAvailable must be 2")
  315. def check_service(doc: dict, fails: Failures) -> None:
  316. ports = doc.get("spec", {}).get("ports", [])
  317. exposed = {p.get("port") for p in ports} | {p.get("targetPort") for p in ports}
  318. if HEALTH_PORT in exposed:
  319. fails.add("service: must not expose the plaintext health port 8081")
  320. def check_l7_mtls(docs: list[dict], fails: Failures) -> None:
  321. """An L7 ingress that terminates TLS strips the client certificate."""
  322. ingresses = by_kind(docs, "Ingress")
  323. if not ingresses:
  324. return
  325. client_auth = mtls_enabled(docs)
  326. for ing in ingresses:
  327. name = ing.get("metadata", {}).get("name", "?")
  328. annotations = (ing.get("metadata", {}) or {}).get("annotations", {}) or {}
  329. terminates = bool((ing.get("spec", {}) or {}).get("tls"))
  330. wants_client_cert = client_auth or any(a in annotations for a in CLIENT_CERT_ANNOTATIONS)
  331. if terminates and wants_client_cert:
  332. fails.add(
  333. f"ingress/{name}: L7 TLS termination strips the client certificate "
  334. "while in-process client-cert auth is enabled — use L4/TCP "
  335. "passthrough (NLB gateway / TLSRoute) or explicit trusted-proxy mode"
  336. )
  337. def main() -> int:
  338. parser = argparse.ArgumentParser()
  339. parser.add_argument("--manifest-dir", default="deploy/k8s")
  340. parser.add_argument("--strict-digest", action="store_true")
  341. args = parser.parse_args()
  342. fails = Failures()
  343. docs, load_fails = load_manifests(Path(args.manifest_dir))
  344. fails.items.extend(load_fails.items)
  345. kinds = {d.get("kind") for d in docs}
  346. for required in (
  347. "Deployment",
  348. "Service",
  349. "ConfigMap",
  350. "Secret",
  351. "Job",
  352. "PodDisruptionBudget",
  353. "NetworkPolicy",
  354. ):
  355. if required not in kinds:
  356. fails.add(f"missing required kind: {required}")
  357. check_pem_and_placeholders(docs, fails)
  358. for doc in by_kind(docs, "ConfigMap"):
  359. check_configmap(doc, fails)
  360. for doc in by_kind(docs, "Deployment"):
  361. check_deployment(doc, fails, args.strict_digest)
  362. for doc in by_kind(docs, "Job"):
  363. check_job(doc, fails, args.strict_digest)
  364. for doc in by_kind(docs, "PodDisruptionBudget"):
  365. check_pdb(doc, fails)
  366. for doc in by_kind(docs, "Service"):
  367. check_service(doc, fails)
  368. for doc in by_kind(docs, "NetworkPolicy"):
  369. check_networkpolicy(doc, fails)
  370. check_l7_mtls(docs, fails)
  371. if fails.ok():
  372. print(f"manifest lint: OK ({len(docs)} documents, strict-digest={args.strict_digest})")
  373. return 0
  374. print(f"manifest lint: {len(fails.items)} failure(s):")
  375. for item in fails.items:
  376. print(f" - {item}")
  377. return 1
  378. if __name__ == "__main__":
  379. sys.exit(main())