fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
#!/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.")
|
||||
Reference in New Issue
Block a user