check_manifests.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  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. - No baked secrets: no PEM blocks in manifests; `REPLACE_ME` placeholders
  14. only inside `secret.yaml`; no secret-shaped keys in the ConfigMap.
  15. - L7-vs-L4 mTLS constraint: an L7 Ingress that terminates TLS while
  16. in-process client-cert auth is enabled (`D_OAUTH_TLS__CLIENT_CA_PATH`
  17. set, or client-cert Ingress annotations) FAILS. mTLS needs L4/TCP
  18. passthrough (NLB gateway / TLSRoute) or explicit trusted-proxy mode.
  19. This constraint is DOCUMENTED (docs/DEPLOYMENT.md) and lint-checked
  20. here; it is explicitly NOT exercised end-to-end in CI.
  21. Usage:
  22. python3 scripts/check_manifests.py [--manifest-dir deploy/k8s]
  23. [--strict-digest]
  24. `--strict-digest` (used by supply-chain CI after the pin step) additionally
  25. requires every workload image to be digest-pinned (`@sha256:<64 hex>`).
  26. """
  27. from __future__ import annotations
  28. import argparse
  29. import re
  30. import sys
  31. from pathlib import Path
  32. import yaml
  33. HEALTH_PORT = 8081
  34. PUBLIC_PORT = 8080
  35. TERMINATION_GRACE_MIN = 45
  36. SECRET_NAME_RE = re.compile(r"(?i)(pepper|password|secret|private[_-]?key|totp)")
  37. PEM_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----|-----BEGIN CERTIFICATE-----")
  38. DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
  39. HIGH_ENTROPY_VALUE_RE = re.compile(r"^[A-Za-z0-9+/=_-]{32,}$")
  40. CLIENT_CERT_ANNOTATIONS = (
  41. "nginx.ingress.kubernetes.io/auth-tls-verify-client",
  42. "nginx.ingress.kubernetes.io/auth-tls-secret",
  43. "haproxy.org/ssl-verify-client",
  44. "traefik.ingress.kubernetes.io/router.tls.options",
  45. )
  46. class Failures:
  47. def __init__(self) -> None:
  48. self.items: list[str] = []
  49. def add(self, msg: str) -> None:
  50. self.items.append(msg)
  51. def ok(self) -> bool:
  52. return not self.items
  53. def load_manifests(manifest_dir: Path) -> tuple[list[dict], Failures]:
  54. fails = Failures()
  55. docs: list[dict] = []
  56. files = sorted(manifest_dir.glob("*.yaml"))
  57. if not files:
  58. fails.add(f"no manifests found in {manifest_dir}")
  59. return docs, fails
  60. for path in files:
  61. if path.name.endswith(".example.yaml"):
  62. continue
  63. try:
  64. loaded = list(yaml.safe_load_all(path.read_text()))
  65. except yaml.YAMLError as exc:
  66. fails.add(f"{path.name}: invalid YAML: {exc}")
  67. continue
  68. for doc in loaded:
  69. if doc:
  70. doc["_source"] = path.name
  71. docs.append(doc)
  72. return docs, fails
  73. def by_kind(docs: list[dict], kind: str) -> list[dict]:
  74. return [d for d in docs if d.get("kind") == kind]
  75. def check_pem_and_placeholders(docs: list[dict], fails: Failures) -> None:
  76. for doc in docs:
  77. src = doc.get("_source", "?")
  78. text = yaml.safe_dump({k: v for k, v in doc.items() if not k.startswith("_")})
  79. if PEM_RE.search(text):
  80. fails.add(f"{src}: baked key material (PEM block) in a manifest")
  81. if src != "secret.yaml" and "REPLACE_ME" in text:
  82. fails.add(f"{src}: placeholder belongs only in secret.yaml")
  83. def check_configmap(doc: dict, fails: Failures) -> None:
  84. data = doc.get("data") or {}
  85. for key in data:
  86. if SECRET_NAME_RE.search(key):
  87. fails.add(f"configmap: secret-shaped key {key!r} must live in the Secret")
  88. if (data.get("D_OAUTH_TLS__CLIENT_CA_PATH") or "").strip():
  89. pass # mTLS on: L7 check below becomes load-bearing.
  90. def mtls_enabled(docs: list[dict]) -> bool:
  91. for doc in by_kind(docs, "ConfigMap"):
  92. data = doc.get("data") or {}
  93. if (data.get("D_OAUTH_TLS__CLIENT_CA_PATH") or "").strip():
  94. return True
  95. return False
  96. def check_deployment(doc: dict, fails: Failures, strict_digest: bool) -> None:
  97. name = doc.get("metadata", {}).get("name", "?")
  98. spec = doc.get("spec", {})
  99. if spec.get("replicas") != 3:
  100. fails.add(f"deployment/{name}: replicas must be 3")
  101. tpl = spec.get("template", {}).get("spec", {})
  102. if tpl.get("terminationGracePeriodSeconds", 0) < TERMINATION_GRACE_MIN:
  103. fails.add(f"deployment/{name}: terminationGracePeriodSeconds must be >= 45")
  104. pod_sc = tpl.get("securityContext", {})
  105. if pod_sc.get("runAsNonRoot") is not True:
  106. fails.add(f"deployment/{name}: runAsNonRoot must be true")
  107. if pod_sc.get("runAsUser") != 65532:
  108. fails.add(f"deployment/{name}: runAsUser must be 65532")
  109. if (pod_sc.get("seccompProfile") or {}).get("type") != "RuntimeDefault":
  110. fails.add(f"deployment/{name}: seccompProfile.type must be RuntimeDefault")
  111. if not tpl.get("topologySpreadConstraints"):
  112. fails.add(f"deployment/{name}: topologySpreadConstraints required")
  113. for container in tpl.get("containers", []):
  114. cname = container.get("name", "?")
  115. csc = container.get("securityContext", {})
  116. if csc.get("readOnlyRootFilesystem") is not True:
  117. fails.add(f"deployment/{name}/{cname}: readOnlyRootFilesystem must be true")
  118. if "ALL" not in (csc.get("capabilities") or {}).get("drop", []):
  119. fails.add(f"deployment/{name}/{cname}: capabilities.drop must include ALL")
  120. res = container.get("resources", {})
  121. if not res.get("requests") or not res.get("limits"):
  122. fails.add(f"deployment/{name}/{cname}: requests and limits required")
  123. args = " ".join(
  124. str(a)
  125. for a in (
  126. list(container.get("command", [])) + list(container.get("args", []))
  127. )
  128. )
  129. if "migrate" in args:
  130. fails.add(f"deployment/{name}/{cname}: replicas must never migrate")
  131. probes = {
  132. "startupProbe": ("/health/live", True),
  133. "livenessProbe": ("/health/live", True),
  134. "readinessProbe": ("/health/ready", True),
  135. }
  136. for probe_name, (path, _) in probes.items():
  137. probe = container.get(probe_name) or {}
  138. http_get = probe.get("httpGet") or {}
  139. if http_get.get("path") != path or http_get.get("port") != HEALTH_PORT:
  140. fails.add(
  141. f"deployment/{name}/{cname}: {probe_name} must httpGet "
  142. f"{path} on port {HEALTH_PORT} (plaintext health listener)"
  143. )
  144. image = container.get("image", "")
  145. if strict_digest and not DIGEST_RE.search(image):
  146. fails.add(f"deployment/{name}/{cname}: image must be digest-pinned")
  147. def check_job(doc: dict, fails: Failures, strict_digest: bool) -> None:
  148. name = doc.get("metadata", {}).get("name", "?")
  149. spec = doc.get("spec", {}).get("template", {}).get("spec", {})
  150. if spec.get("restartPolicy") != "Never":
  151. fails.add(f"job/{name}: restartPolicy must be Never")
  152. containers = spec.get("containers", [])
  153. if not any(
  154. "migrate" in " ".join(str(a) for a in (list(c.get("command", [])) + list(c.get("args", []))))
  155. for c in containers
  156. ):
  157. fails.add(f"job/{name}: exactly one migrate Job must run `/d-oauth-server migrate`")
  158. for container in containers:
  159. if strict_digest and not DIGEST_RE.search(container.get("image", "")):
  160. fails.add(f"job/{name}: image must be digest-pinned")
  161. def check_pdb(doc: dict, fails: Failures) -> None:
  162. if doc.get("spec", {}).get("minAvailable") != 2:
  163. fails.add("poddisruptionbudget: minAvailable must be 2")
  164. def check_service(doc: dict, fails: Failures) -> None:
  165. ports = doc.get("spec", {}).get("ports", [])
  166. exposed = {p.get("port") for p in ports} | {p.get("targetPort") for p in ports}
  167. if HEALTH_PORT in exposed:
  168. fails.add("service: must not expose the plaintext health port 8081")
  169. def check_l7_mtls(docs: list[dict], fails: Failures) -> None:
  170. """An L7 ingress that terminates TLS strips the client certificate."""
  171. ingresses = by_kind(docs, "Ingress")
  172. if not ingresses:
  173. return
  174. client_auth = mtls_enabled(docs)
  175. for ing in ingresses:
  176. name = ing.get("metadata", {}).get("name", "?")
  177. annotations = (ing.get("metadata", {}) or {}).get("annotations", {}) or {}
  178. terminates = bool((ing.get("spec", {}) or {}).get("tls"))
  179. wants_client_cert = client_auth or any(a in annotations for a in CLIENT_CERT_ANNOTATIONS)
  180. if terminates and wants_client_cert:
  181. fails.add(
  182. f"ingress/{name}: L7 TLS termination strips the client certificate "
  183. "while in-process client-cert auth is enabled — use L4/TCP "
  184. "passthrough (NLB gateway / TLSRoute) or explicit trusted-proxy mode"
  185. )
  186. def main() -> int:
  187. parser = argparse.ArgumentParser()
  188. parser.add_argument("--manifest-dir", default="deploy/k8s")
  189. parser.add_argument("--strict-digest", action="store_true")
  190. args = parser.parse_args()
  191. fails = Failures()
  192. docs, load_fails = load_manifests(Path(args.manifest_dir))
  193. fails.items.extend(load_fails.items)
  194. kinds = {d.get("kind") for d in docs}
  195. for required in ("Deployment", "Service", "ConfigMap", "Secret", "Job", "PodDisruptionBudget"):
  196. kind_name = "Job" if required == "Job" else required
  197. if required not in kinds:
  198. fails.add(f"missing required kind: {required} ({kind_name})")
  199. check_pem_and_placeholders(docs, fails)
  200. for doc in by_kind(docs, "ConfigMap"):
  201. check_configmap(doc, fails)
  202. for doc in by_kind(docs, "Deployment"):
  203. check_deployment(doc, fails, args.strict_digest)
  204. for doc in by_kind(docs, "Job"):
  205. check_job(doc, fails, args.strict_digest)
  206. for doc in by_kind(docs, "PodDisruptionBudget"):
  207. check_pdb(doc, fails)
  208. for doc in by_kind(docs, "Service"):
  209. check_service(doc, fails)
  210. check_l7_mtls(docs, fails)
  211. if fails.ok():
  212. print(f"manifest lint: OK ({len(docs)} documents, strict-digest={args.strict_digest})")
  213. return 0
  214. print(f"manifest lint: {len(fails.items)} failure(s):")
  215. for item in fails.items:
  216. print(f" - {item}")
  217. return 1
  218. if __name__ == "__main__":
  219. sys.exit(main())