0954876295
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
83 lines
4.5 KiB
Python
83 lines
4.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate rendered production Compose, never print its environment/secrets."""
|
|
import ipaddress
|
|
import json
|
|
import sys
|
|
from urllib.parse import urlsplit
|
|
|
|
|
|
def proxy_network(value):
|
|
try:
|
|
network = ipaddress.ip_network(value, strict=True)
|
|
except ValueError:
|
|
raise ValueError("Invalid ingress proxy CIDR") from None
|
|
private_ranges = (ipaddress.ip_network(value) for value in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"))
|
|
private = any(network.version == item.version and network.subnet_of(item) for item in private_ranges)
|
|
minimum = (24 if network.version == 4 else 64) if private else network.max_prefixlen
|
|
if network.prefixlen < minimum:
|
|
raise ValueError("Ingress proxy CIDRs must be narrow private networks or individual public proxy addresses")
|
|
return network
|
|
|
|
|
|
def validate(config):
|
|
services = config["services"]
|
|
for name, service in services.items():
|
|
if service.get("network_mode") == "host":
|
|
raise ValueError("Production services must not use host networking")
|
|
if name != "frontend" and service.get("ports"):
|
|
raise ValueError("Only frontend ingress may publish a production port")
|
|
front = services["frontend"]
|
|
ports = front.get("ports", [])
|
|
if len(ports) != 1 or ports[0].get("target") != 80 or str(ports[0].get("published")) != "3000" or ports[0].get("protocol") != "tcp":
|
|
raise ValueError("Production ingress must publish only the documented frontend 3000-to-80 mapping")
|
|
env = front["environment"]
|
|
if env.get("INGRESS_MODE") != "proxy":
|
|
raise ValueError("Production frontend must require the trusted proxy")
|
|
peers = env.get("INGRESS_PROXY_CIDRS", "").split()
|
|
if not peers:
|
|
raise ValueError("INGRESS_PROXY_CIDRS must identify the observed Traefik socket peers")
|
|
for peer in peers:
|
|
proxy_network(peer)
|
|
backend = services["backend"]["environment"]
|
|
if backend.get("ASPNETCORE_ENVIRONMENT") != "Production":
|
|
raise ValueError("Production backend environment must be explicit")
|
|
if backend.get("DOTNET_ENVIRONMENT") not in (None, "Production"):
|
|
raise ValueError("DOTNET_ENVIRONMENT must not override production")
|
|
if backend.get("ASPNETCORE_FORWARDEDHEADERS_ENABLED", "false").lower() != "false":
|
|
raise ValueError("Use explicit proxy trust, not automatic framework forwarding")
|
|
origin = backend.get("App__PublicBaseUrl", "")
|
|
parsed = urlsplit(origin)
|
|
if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password
|
|
or parsed.path not in ("", "/") or parsed.query or parsed.fragment
|
|
or env.get("APP_PUBLIC_BASE_URL") != origin):
|
|
raise ValueError("Frontend and backend must share the canonical HTTPS origin")
|
|
if front.get("build", {}).get("args", {}).get("NEXT_PUBLIC_API_BASE_URL") not in (None, "", "/api"):
|
|
raise ValueError("Production frontend must use the same-origin /api route")
|
|
if backend.get("Proxy__TrustForwardedHeaders") != "true":
|
|
raise ValueError("Production backend must resolve nginx forwarding metadata")
|
|
proxy = config["networks"]["web_proxy"]
|
|
if not proxy.get("internal") or proxy.get("external"):
|
|
raise ValueError("The nginx-to-backend network must be dedicated and internal")
|
|
networks = proxy["ipam"]["config"]
|
|
if len(networks) != 1:
|
|
raise ValueError("Exactly one nginx-to-backend subnet is supported")
|
|
subnet = proxy_network(networks[0]["subnet"])
|
|
if not subnet.is_private:
|
|
raise ValueError("The nginx-to-backend subnet must be private")
|
|
known = {key: value for key, value in backend.items() if key.startswith("Proxy__KnownNetworks__")}
|
|
if known != {"Proxy__KnownNetworks__0": str(subnet)}:
|
|
raise ValueError("Backend trust must exactly match the dedicated proxy subnet")
|
|
members = {name for name, service in services.items() if "web_proxy" in service.get("networks", {})}
|
|
if members != {"frontend", "backend"}:
|
|
raise ValueError("Only frontend and backend may join the trusted proxy network")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
validate(json.load(sys.stdin))
|
|
except (ValueError, KeyError, TypeError) as error:
|
|
# Validation errors describe keys/contracts, never configuration values.
|
|
print(f"Ingress preflight rejected the rendered production configuration: {error}", file=sys.stderr)
|
|
sys.exit(1)
|
|
print("Production ingress configuration validated; observed host/proxy/firewall checks are still required.")
|