check_manifests.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  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`; every
  14. secret (token pepper, TOTP key, mandatory PII key, database and Redis
  15. passwords) must be file-mounted from `d-oauth-secrets` at `/run/secrets`
  16. (`defaultMode: 0400`) and referenced with its `*_FILE` variable; the inline
  17. `D_OAUTH_TOKEN_PEPPER` / `D_OAUTH_TOTP_ENCRYPTION_KEY` /
  18. `D_OAUTH_PII_ENCRYPTION_KEY` / `D_OAUTH_PII_PREVIOUS_KEY` /
  19. `D_OAUTH_TOTP_PREVIOUS_KEY` envs FAIL, and any workload env that injects a
  20. secret FAILS (`secretKeyRef`, `envFrom.secretRef`, a literal value embedding a
  21. DSN password, or a workload redefinition of `D_OAUTH_DATABASE__URL` /
  22. `D_OAUTH_REDIS__URL`). The PII key is mandatory in every workload; the
  23. previous PII/TOTP keys are OPTIONAL, but any `*_PREVIOUS_KEY_FILE` reference
  24. must point at `/run/secrets/<basename>` with `<basename>` actually projected
  25. by the secret volume's `items`.
  26. - ConfigMap: the ConfigMap referenced by a workload `envFrom` must provide the
  27. passwordless `D_OAUTH_DATABASE__URL` / `D_OAUTH_REDIS__URL` DSNs, and neither
  28. may embed a password (it belongs in the Secret, read through the
  29. `*_PASSWORD_FILE` form).
  30. - NetworkPolicy: must select `app.kubernetes.io/name: d-oauth-server`, enable
  31. `Ingress` + `Egress`, admit ingress only on 8080/8081, allow egress only on
  32. 53 (DNS), 5432 (Postgres), and 6379 (Redis), forbid an internet-wide
  33. `0.0.0.0/0` egress peer, and require any `ipBlock` peer to exclude
  34. `169.254.0.0/16` (cloud metadata).
  35. - No baked secrets: no PEM blocks in manifests; `REPLACE_ME` placeholders
  36. only inside `secret.yaml`; no secret-shaped keys in the ConfigMap.
  37. - L7-vs-L4 mTLS constraint: an L7 Ingress that terminates TLS while
  38. in-process client-cert auth is enabled (`D_OAUTH_TLS__CLIENT_CA_PATH`
  39. set, or client-cert Ingress annotations) FAILS. mTLS needs L4/TCP
  40. passthrough (NLB gateway / TLSRoute); forwarded client-cert headers are
  41. never trusted.
  42. This constraint is DOCUMENTED (docs/DEPLOYMENT.md) and lint-checked
  43. here; it is explicitly NOT exercised end-to-end in CI.
  44. Usage:
  45. python3 scripts/check_manifests.py [--manifest-dir deploy/k8s]
  46. [--strict-digest]
  47. `--strict-digest` (used by supply-chain CI after the pin step) additionally
  48. requires every workload image to be digest-pinned (`@sha256:<64 hex>`).
  49. """
  50. from __future__ import annotations
  51. import argparse
  52. import ipaddress
  53. import re
  54. import sys
  55. from pathlib import Path
  56. import yaml
  57. HEALTH_PORT = 8081
  58. PUBLIC_PORT = 8080
  59. TERMINATION_GRACE_MIN = 45
  60. SECRET_NAME_RE = re.compile(
  61. r"(?i)("
  62. r"pepper|password|secret|private[_-]?key"
  63. r"|pii[_-]?encryption[_-]?key(?!_id)"
  64. r"|pii[_-]?previous[_-]?key(?!_id)"
  65. r"|totp[_-]?previous[_-]?key(?!_id)"
  66. r"|totp[_-]?(encryption[_-]?)?key(?!_id)"
  67. r")"
  68. )
  69. PEM_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----|-----BEGIN CERTIFICATE-----")
  70. DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
  71. HIGH_ENTROPY_VALUE_RE = re.compile(r"^[A-Za-z0-9+/=_-]{32,}$")
  72. # A DSN that carries `user:password@` (or `:password@`) embeds a credential.
  73. EMBEDDED_PASSWORD_RE = re.compile(r"://[^/@?#]*:[^/@?#]*@")
  74. CLIENT_CERT_ANNOTATIONS = (
  75. "nginx.ingress.kubernetes.io/auth-tls-verify-client",
  76. "nginx.ingress.kubernetes.io/auth-tls-secret",
  77. "haproxy.org/ssl-verify-client",
  78. "traefik.ingress.kubernetes.io/router.tls.options",
  79. )
  80. SECRET_NAME = "d-oauth-secrets"
  81. SECRETS_MOUNT_PATH = "/run/secrets"
  82. FS_GROUP = 65532
  83. SECRET_VOLUME_MODE = 0o400
  84. FILE_SECRET_ENV_KEYS = {
  85. "D_OAUTH_TOKEN_PEPPER_FILE": "token-pepper",
  86. "D_OAUTH_TOTP_ENCRYPTION_KEY_FILE": "totp-encryption-key",
  87. "D_OAUTH_PII_ENCRYPTION_KEY_FILE": "pii-encryption-key",
  88. "D_OAUTH_DATABASE__PASSWORD_FILE": "database-password",
  89. "D_OAUTH_REDIS__PASSWORD_FILE": "redis-password",
  90. }
  91. INLINE_SECRET_ENVS = (
  92. "D_OAUTH_TOKEN_PEPPER",
  93. "D_OAUTH_TOTP_ENCRYPTION_KEY",
  94. "D_OAUTH_PII_ENCRYPTION_KEY",
  95. "D_OAUTH_PII_PREVIOUS_KEY",
  96. "D_OAUTH_TOTP_PREVIOUS_KEY",
  97. )
  98. # The previous keys are OPTIONAL, so their `_FILE` forms are not in
  99. # FILE_SECRET_ENV_KEYS (that set is mandatory in every workload). They are
  100. # still secret-shaped, so a workload that references them must file-mount them.
  101. OPTIONAL_FILE_SECRET_ENVS = (
  102. "D_OAUTH_PII_PREVIOUS_KEY_FILE",
  103. "D_OAUTH_TOTP_PREVIOUS_KEY_FILE",
  104. )
  105. OPTIONAL_FILE_SECRET_RE = re.compile(
  106. rf"^{re.escape(SECRETS_MOUNT_PATH)}/(?P<basename>[^/]+)$"
  107. )
  108. PASSWORDLESS_DSN_KEYS = ("D_OAUTH_DATABASE__URL", "D_OAUTH_REDIS__URL")
  109. REQUIRED_EGRESS_PORTS = (53, 5432, 6379)
  110. METADATA_CIDR = "169.254.0.0/16"
  111. class Failures:
  112. def __init__(self) -> None:
  113. self.items: list[str] = []
  114. def add(self, msg: str) -> None:
  115. self.items.append(msg)
  116. def ok(self) -> bool:
  117. return not self.items
  118. def load_manifests(manifest_dir: Path) -> tuple[list[dict], Failures]:
  119. fails = Failures()
  120. docs: list[dict] = []
  121. files = sorted(manifest_dir.glob("*.yaml"))
  122. if not files:
  123. fails.add(f"no manifests found in {manifest_dir}")
  124. return docs, fails
  125. for path in files:
  126. if path.name.endswith(".example.yaml"):
  127. continue
  128. try:
  129. loaded = list(yaml.safe_load_all(path.read_text()))
  130. except yaml.YAMLError as exc:
  131. fails.add(f"{path.name}: invalid YAML: {exc}")
  132. continue
  133. for doc in loaded:
  134. if doc:
  135. doc["_source"] = path.name
  136. docs.append(doc)
  137. return docs, fails
  138. def by_kind(docs: list[dict], kind: str) -> list[dict]:
  139. return [d for d in docs if d.get("kind") == kind]
  140. def check_pem_and_placeholders(docs: list[dict], fails: Failures) -> None:
  141. for doc in docs:
  142. src = doc.get("_source", "?")
  143. text = yaml.safe_dump({k: v for k, v in doc.items() if not k.startswith("_")})
  144. if PEM_RE.search(text):
  145. fails.add(f"{src}: baked key material (PEM block) in a manifest")
  146. if src != "secret.yaml" and "REPLACE_ME" in text:
  147. fails.add(f"{src}: placeholder belongs only in secret.yaml")
  148. def check_configmap(doc: dict, fails: Failures, requires_dsn: bool) -> None:
  149. data = doc.get("data") or {}
  150. for key in data:
  151. if SECRET_NAME_RE.search(key):
  152. fails.add(f"configmap: secret-shaped key {key!r} must live in the Secret")
  153. if not requires_dsn:
  154. return
  155. for key in PASSWORDLESS_DSN_KEYS:
  156. value = (data.get(key) or "").strip()
  157. if not value:
  158. fails.add(
  159. f"configmap: {key} is required; the workloads take the DSN from here "
  160. "and the password from the mounted *_PASSWORD_FILE"
  161. )
  162. elif EMBEDDED_PASSWORD_RE.search(value):
  163. fails.add(
  164. f"configmap: {key} must not embed a password; move it to the Secret "
  165. "and use the *_PASSWORD_FILE form"
  166. )
  167. def referenced_configmaps(docs: list[dict]) -> set[str]:
  168. """Names of ConfigMaps any workload pulls into the environment via `envFrom`."""
  169. names: set[str] = set()
  170. for kind in ("Deployment", "Job"):
  171. for doc in by_kind(docs, kind):
  172. tpl = (doc.get("spec") or {}).get("template", {}).get("spec", {})
  173. for container in tpl.get("containers") or []:
  174. for ref in container.get("envFrom") or []:
  175. name = ((ref or {}).get("configMapRef") or {}).get("name")
  176. if name:
  177. names.add(name)
  178. return names
  179. def mtls_enabled(docs: list[dict]) -> bool:
  180. for doc in by_kind(docs, "ConfigMap"):
  181. data = doc.get("data") or {}
  182. if (data.get("D_OAUTH_TLS__CLIENT_CA_PATH") or "").strip():
  183. return True
  184. return False
  185. def octal_mode(value: object) -> int | None:
  186. """Parse a YAML file mode (`0400`, `0o400`, `256`) into an int, or None."""
  187. if isinstance(value, bool) or value is None:
  188. return None
  189. if isinstance(value, int):
  190. return value
  191. if isinstance(value, str):
  192. text = value.strip().lower().replace("_", "")
  193. try:
  194. if text.startswith("0o"):
  195. return int(text[2:], 8)
  196. if text.startswith("0") and len(text) > 1:
  197. return int(text, 8)
  198. return int(text, 10)
  199. except ValueError:
  200. return None
  201. return None
  202. def env_by_name(container: dict) -> dict[str, dict]:
  203. envs: dict[str, dict] = {}
  204. for entry in container.get("env") or []:
  205. if isinstance(entry, dict) and entry.get("name"):
  206. envs[entry["name"]] = entry
  207. return envs
  208. def check_file_mounted_secrets(
  209. workload: str, tpl: dict, container: dict, fails: Failures
  210. ) -> None:
  211. """Item 7: pepper/TOTP are 0400 files under /run/secrets, never inline env."""
  212. cname = container.get("name", "?")
  213. envs = env_by_name(container)
  214. for ref in container.get("envFrom") or []:
  215. if isinstance(ref, dict) and "secretRef" in ref:
  216. fails.add(
  217. f"{workload}/{cname}: envFrom.secretRef injects every secret into the "
  218. "environment; mount the keys as files instead"
  219. )
  220. for name, entry in envs.items():
  221. if "secretKeyRef" in (entry.get("valueFrom") or {}):
  222. fails.add(
  223. f"{workload}/{cname}: env {name} injects a secret via secretKeyRef; "
  224. "mount it as a file and reference the *_FILE form"
  225. )
  226. if name in PASSWORDLESS_DSN_KEYS:
  227. fails.add(
  228. f"{workload}/{cname}: {name} must come from the ConfigMap, not the "
  229. "workload env (an inline value could embed a password)"
  230. )
  231. value = entry.get("value")
  232. if isinstance(value, str) and EMBEDDED_PASSWORD_RE.search(value):
  233. fails.add(
  234. f"{workload}/{cname}: env {name} embeds a password; mount it as a file"
  235. )
  236. for inline in INLINE_SECRET_ENVS:
  237. if inline in envs:
  238. fails.add(
  239. f"{workload}/{cname}: {inline} must not be injected as an env value; "
  240. "use the file-mounted *_FILE form"
  241. )
  242. mount = next(
  243. (
  244. m
  245. for m in container.get("volumeMounts") or []
  246. if isinstance(m, dict) and m.get("mountPath") == SECRETS_MOUNT_PATH
  247. ),
  248. None,
  249. )
  250. if mount is None:
  251. fails.add(
  252. f"{workload}/{cname}: must mount the secret volume at {SECRETS_MOUNT_PATH}"
  253. )
  254. elif mount.get("readOnly") is not True:
  255. fails.add(f"{workload}/{cname}: {SECRETS_MOUNT_PATH} must be readOnly")
  256. items: dict[str, str] = {}
  257. mode: int | None = None
  258. for volume in tpl.get("volumes") or []:
  259. if not isinstance(volume, dict):
  260. continue
  261. secret = volume.get("secret") or {}
  262. if secret.get("secretName") != SECRET_NAME:
  263. continue
  264. mode = octal_mode(secret.get("defaultMode"))
  265. for item in secret.get("items") or []:
  266. if isinstance(item, dict) and item.get("key") and item.get("path"):
  267. items[item["key"]] = item["path"]
  268. if mode != SECRET_VOLUME_MODE:
  269. fails.add(f"{workload}: {SECRET_NAME} volume defaultMode must be 0400")
  270. for env_name, key in FILE_SECRET_ENV_KEYS.items():
  271. if key not in items:
  272. fails.add(f"{workload}: {SECRET_NAME} volume must project key {key!r}")
  273. continue
  274. expected = f"{SECRETS_MOUNT_PATH}/{items[key]}"
  275. entry = envs.get(env_name) or {}
  276. if (entry.get("value") or "").strip() != expected:
  277. fails.add(f"{workload}/{cname}: {env_name} must point at {expected}")
  278. for env_name in OPTIONAL_FILE_SECRET_ENVS:
  279. entry = envs.get(env_name)
  280. if entry is None:
  281. continue
  282. value = (entry.get("value") or "").strip()
  283. match = OPTIONAL_FILE_SECRET_RE.fullmatch(value)
  284. if match is None:
  285. fails.add(
  286. f"{workload}/{cname}: {env_name} must point at "
  287. f"{SECRETS_MOUNT_PATH}/<basename>, not a literal value"
  288. )
  289. continue
  290. basename = match.group("basename")
  291. if basename not in set(items.values()):
  292. fails.add(
  293. f"{workload}/{cname}: {env_name} points at {value} but the "
  294. f"{SECRET_NAME} volume does not project {basename!r}"
  295. )
  296. def policy_ports(rule: dict) -> set[int]:
  297. ports: set[int] = set()
  298. for port in rule.get("ports") or []:
  299. if isinstance(port, dict) and isinstance(port.get("port"), int):
  300. ports.add(port["port"])
  301. return ports
  302. def ip_network_contains(cidr: str, inner: str) -> bool:
  303. try:
  304. outer = ipaddress.ip_network(cidr)
  305. sub = ipaddress.ip_network(inner)
  306. except ValueError:
  307. return False
  308. if outer.version != sub.version:
  309. return False
  310. return (
  311. int(sub.network_address) >= int(outer.network_address)
  312. and int(sub.broadcast_address) <= int(outer.broadcast_address)
  313. )
  314. def check_networkpolicy(doc: dict, fails: Failures) -> None:
  315. """Item 9: default-deny ingress/egress for the app pods."""
  316. name = doc.get("metadata", {}).get("name", "?")
  317. spec = doc.get("spec") or {}
  318. selector = (spec.get("podSelector") or {}).get("matchLabels") or {}
  319. if selector.get("app.kubernetes.io/name") != "d-oauth-server":
  320. fails.add(
  321. f"networkpolicy/{name}: podSelector must match "
  322. "app.kubernetes.io/name: d-oauth-server"
  323. )
  324. if not {"Ingress", "Egress"} <= set(spec.get("policyTypes") or []):
  325. fails.add(f"networkpolicy/{name}: policyTypes must include Ingress and Egress")
  326. ingress_ports: set[int] = set()
  327. for rule in spec.get("ingress") or []:
  328. if isinstance(rule, dict):
  329. ingress_ports |= policy_ports(rule)
  330. if not {PUBLIC_PORT, HEALTH_PORT} <= ingress_ports:
  331. fails.add(
  332. f"networkpolicy/{name}: ingress must admit ports "
  333. f"{PUBLIC_PORT} and {HEALTH_PORT}"
  334. )
  335. egress_ports: set[int] = set()
  336. for rule in spec.get("egress") or []:
  337. if not isinstance(rule, dict):
  338. continue
  339. egress_ports |= policy_ports(rule)
  340. for peer in rule.get("to") or []:
  341. block = (peer or {}).get("ipBlock") or {}
  342. cidr = block.get("cidr")
  343. if not cidr:
  344. continue
  345. if cidr in ("0.0.0.0/0", "::/0"):
  346. fails.add(
  347. f"networkpolicy/{name}: egress peer {cidr} is internet-wide; scope "
  348. "Postgres/Redis to a namespace/pod selector or a specific CIDR"
  349. )
  350. elif ip_network_contains(cidr, METADATA_CIDR) and METADATA_CIDR not in (
  351. block.get("except") or []
  352. ):
  353. fails.add(
  354. f"networkpolicy/{name}: egress ipBlock {cidr} must exclude "
  355. f"{METADATA_CIDR} (cloud metadata)"
  356. )
  357. for port in REQUIRED_EGRESS_PORTS:
  358. if port not in egress_ports:
  359. fails.add(f"networkpolicy/{name}: egress must allow port {port}")
  360. def check_deployment(doc: dict, fails: Failures, strict_digest: bool) -> None:
  361. name = doc.get("metadata", {}).get("name", "?")
  362. spec = doc.get("spec", {})
  363. if spec.get("replicas") != 3:
  364. fails.add(f"deployment/{name}: replicas must be 3")
  365. tpl = spec.get("template", {}).get("spec", {})
  366. if tpl.get("terminationGracePeriodSeconds", 0) < TERMINATION_GRACE_MIN:
  367. fails.add(f"deployment/{name}: terminationGracePeriodSeconds must be >= 45")
  368. pod_sc = tpl.get("securityContext", {})
  369. if pod_sc.get("runAsNonRoot") is not True:
  370. fails.add(f"deployment/{name}: runAsNonRoot must be true")
  371. if pod_sc.get("runAsUser") != 65532:
  372. fails.add(f"deployment/{name}: runAsUser must be 65532")
  373. if (pod_sc.get("seccompProfile") or {}).get("type") != "RuntimeDefault":
  374. fails.add(f"deployment/{name}: seccompProfile.type must be RuntimeDefault")
  375. if tpl.get("automountServiceAccountToken") is not False:
  376. fails.add(f"deployment/{name}: automountServiceAccountToken must be false")
  377. if pod_sc.get("fsGroup") != FS_GROUP:
  378. fails.add(
  379. f"deployment/{name}: fsGroup must be {FS_GROUP} so the 0400 secret "
  380. "files are readable by the runtime user"
  381. )
  382. if not tpl.get("topologySpreadConstraints"):
  383. fails.add(f"deployment/{name}: topologySpreadConstraints required")
  384. for container in tpl.get("containers", []):
  385. cname = container.get("name", "?")
  386. csc = container.get("securityContext", {})
  387. if csc.get("readOnlyRootFilesystem") is not True:
  388. fails.add(f"deployment/{name}/{cname}: readOnlyRootFilesystem must be true")
  389. if "ALL" not in (csc.get("capabilities") or {}).get("drop", []):
  390. fails.add(f"deployment/{name}/{cname}: capabilities.drop must include ALL")
  391. res = container.get("resources", {})
  392. if not res.get("requests") or not res.get("limits"):
  393. fails.add(f"deployment/{name}/{cname}: requests and limits required")
  394. args = " ".join(
  395. str(a)
  396. for a in (
  397. list(container.get("command", [])) + list(container.get("args", []))
  398. )
  399. )
  400. if "migrate" in args:
  401. fails.add(f"deployment/{name}/{cname}: replicas must never migrate")
  402. probes = {
  403. "startupProbe": ("/health/live", True),
  404. "livenessProbe": ("/health/live", True),
  405. "readinessProbe": ("/health/ready", True),
  406. }
  407. for probe_name, (path, _) in probes.items():
  408. probe = container.get(probe_name) or {}
  409. http_get = probe.get("httpGet") or {}
  410. if http_get.get("path") != path or http_get.get("port") != HEALTH_PORT:
  411. fails.add(
  412. f"deployment/{name}/{cname}: {probe_name} must httpGet "
  413. f"{path} on port {HEALTH_PORT} (plaintext health listener)"
  414. )
  415. image = container.get("image", "")
  416. if strict_digest and not DIGEST_RE.search(image):
  417. fails.add(f"deployment/{name}/{cname}: image must be digest-pinned")
  418. check_file_mounted_secrets(f"deployment/{name}", tpl, container, fails)
  419. def check_job(doc: dict, fails: Failures, strict_digest: bool) -> None:
  420. name = doc.get("metadata", {}).get("name", "?")
  421. spec = doc.get("spec", {}).get("template", {}).get("spec", {})
  422. if spec.get("restartPolicy") != "Never":
  423. fails.add(f"job/{name}: restartPolicy must be Never")
  424. if spec.get("automountServiceAccountToken") is not False:
  425. fails.add(f"job/{name}: automountServiceAccountToken must be false")
  426. if (spec.get("securityContext") or {}).get("fsGroup") != FS_GROUP:
  427. fails.add(
  428. f"job/{name}: fsGroup must be {FS_GROUP} so the 0400 secret files are "
  429. "readable by the runtime user"
  430. )
  431. containers = spec.get("containers", [])
  432. if not any(
  433. "migrate" in " ".join(str(a) for a in (list(c.get("command", [])) + list(c.get("args", []))))
  434. for c in containers
  435. ):
  436. fails.add(f"job/{name}: exactly one migrate Job must run `/d-oauth-server migrate`")
  437. for container in containers:
  438. if strict_digest and not DIGEST_RE.search(container.get("image", "")):
  439. fails.add(f"job/{name}: image must be digest-pinned")
  440. check_file_mounted_secrets(f"job/{name}", spec, container, fails)
  441. def check_pdb(doc: dict, fails: Failures) -> None:
  442. if doc.get("spec", {}).get("minAvailable") != 2:
  443. fails.add("poddisruptionbudget: minAvailable must be 2")
  444. def check_service(doc: dict, fails: Failures) -> None:
  445. ports = doc.get("spec", {}).get("ports", [])
  446. exposed = {p.get("port") for p in ports} | {p.get("targetPort") for p in ports}
  447. if HEALTH_PORT in exposed:
  448. fails.add("service: must not expose the plaintext health port 8081")
  449. def check_l7_mtls(docs: list[dict], fails: Failures) -> None:
  450. """An L7 ingress that terminates TLS strips the client certificate."""
  451. ingresses = by_kind(docs, "Ingress")
  452. if not ingresses:
  453. return
  454. client_auth = mtls_enabled(docs)
  455. for ing in ingresses:
  456. name = ing.get("metadata", {}).get("name", "?")
  457. annotations = (ing.get("metadata", {}) or {}).get("annotations", {}) or {}
  458. terminates = bool((ing.get("spec", {}) or {}).get("tls"))
  459. wants_client_cert = client_auth or any(a in annotations for a in CLIENT_CERT_ANNOTATIONS)
  460. if terminates and wants_client_cert:
  461. fails.add(
  462. f"ingress/{name}: L7 TLS termination strips the client certificate "
  463. "while in-process client-cert auth is enabled — use L4/TCP "
  464. "passthrough (NLB gateway / TLSRoute); forwarded client-cert "
  465. "headers are never trusted"
  466. )
  467. def main() -> int:
  468. parser = argparse.ArgumentParser()
  469. parser.add_argument("--manifest-dir", default="deploy/k8s")
  470. parser.add_argument("--strict-digest", action="store_true")
  471. args = parser.parse_args()
  472. fails = Failures()
  473. docs, load_fails = load_manifests(Path(args.manifest_dir))
  474. fails.items.extend(load_fails.items)
  475. kinds = {d.get("kind") for d in docs}
  476. for required in (
  477. "Deployment",
  478. "Service",
  479. "ConfigMap",
  480. "Secret",
  481. "Job",
  482. "PodDisruptionBudget",
  483. "NetworkPolicy",
  484. ):
  485. if required not in kinds:
  486. fails.add(f"missing required kind: {required}")
  487. check_pem_and_placeholders(docs, fails)
  488. required_dsn_configmaps = referenced_configmaps(docs)
  489. for doc in by_kind(docs, "ConfigMap"):
  490. name = (doc.get("metadata") or {}).get("name")
  491. check_configmap(doc, fails, requires_dsn=name in required_dsn_configmaps)
  492. for doc in by_kind(docs, "Deployment"):
  493. check_deployment(doc, fails, args.strict_digest)
  494. for doc in by_kind(docs, "Job"):
  495. check_job(doc, fails, args.strict_digest)
  496. for doc in by_kind(docs, "PodDisruptionBudget"):
  497. check_pdb(doc, fails)
  498. for doc in by_kind(docs, "Service"):
  499. check_service(doc, fails)
  500. for doc in by_kind(docs, "NetworkPolicy"):
  501. check_networkpolicy(doc, fails)
  502. check_l7_mtls(docs, fails)
  503. if fails.ok():
  504. print(f"manifest lint: OK ({len(docs)} documents, strict-digest={args.strict_digest})")
  505. return 0
  506. print(f"manifest lint: {len(fails.items)} failure(s):")
  507. for item in fails.items:
  508. print(f" - {item}")
  509. return 1
  510. if __name__ == "__main__":
  511. sys.exit(main())