From 99f6293403bc4ae661e30c43dea21ece36d0cc74 Mon Sep 17 00:00:00 2001 From: Ronald Hensbergen Date: Thu, 29 Oct 2026 19:15:25 +0100 Subject: [PATCH 1/4] feat(k8s): generate NetworkPolicies and per-module ServiceAccounts (#75) Complete the Kubernetes production resources for the helm render target: - One NetworkPolicy per module, default-deny for ingress and egress, with allow rules derived from the plan's contract bindings: consumer egress and matching provider ingress on the contract port, same-module traffic, cluster DNS egress, and ingress from all sources on exactly the exposed ports of NodePort/LoadBalancer services. A binding without port evidence falls back to the provider's advertised ports, or warns W128 when the rule cannot be port-scoped. - One ServiceAccount per module; every workload references it. Least privilege by default: no Role, no RoleBinding, and no mounted API token unless the module declares implementation.kubernetes.serviceAccount. rbac rules (new schema section); malformed rules fail closed as E147. - Extend scan_k8s_security with CDS-K8S-006 (wildcard RBAC grants) and CDS-K8S-007 (NodePort/LoadBalancer ingress exposure notes). - Regenerate the golden chart hashes; add renderer and security tests. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- CHANGELOG.md | 15 ++ cli/k8s_renderer.py | 346 +++++++++++++++++++++++++++++++ cli/k8s_security.py | 54 ++++- cli/resources/module.schema.json | 68 ++++++ tests/golden/k8s-chart.sha256 | 20 +- tests/test_k8s_renderer.py | 299 ++++++++++++++++++++++++++ tests/test_k8s_security.py | 76 +++++++ 7 files changed, 869 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 740d3e92..3c1926d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ The format is based on Keep a Changelog. ### Added +- Added NetworkPolicy and ServiceAccount generation to the `helm` render + target, completing the Kubernetes production resources (issue #75): + every module's chart now ships a default-deny NetworkPolicy whose allow + rules are derived from the plan's contract bindings (consumer-to- + provider egress and matching provider ingress on the contract port, + same-module traffic, and cluster DNS egress; NodePort/LoadBalancer + services open ingress on exactly their exposed ports), and every + workload runs under a dedicated per-module ServiceAccount with opt-in + least-privilege RBAC via the new + `implementation.kubernetes.serviceAccount.rbac` rules — no rules means + no Role, no RoleBinding, and no mounted API token. Extended the + Kubernetes security scan with CDS-K8S-006 (wildcard RBAC grants) and + CDS-K8S-007 (NodePort/LoadBalancer ingress exposure), and added a W128 + warning when a binding carries no port evidence and its egress rule + cannot be port-scoped. - Added a single-node Garage S3-compatible object-storage module and provider-neutral `object-storage` contract, with persistent data and metadata volumes and profile-secret-backed credentials. The module is diff --git a/cli/k8s_renderer.py b/cli/k8s_renderer.py index 7fa6b065..f829dc9e 100644 --- a/cli/k8s_renderer.py +++ b/cli/k8s_renderer.py @@ -13,6 +13,13 @@ Secret values never reach disk here. `${CDS_*}` references are rewritten to Kubernetes `$(VAR)` expansions backed by `secretKeyRef` entries, so the chart is safe to commit and the values are supplied at install time. + +Every module's pods run under a dedicated ServiceAccount (with opt-in, +least-privilege RBAC) and are isolated by a generated NetworkPolicy: +default-deny for ingress and egress, with allow rules derived from the plan's +contract bindings (issue #75). Cross-module connectivity therefore stays +explicit on Kubernetes, mirroring the contract-first design used everywhere +else in CDS. """ from __future__ import annotations @@ -165,6 +172,17 @@ def render_helm( ) ) + # RBAC rule shapes are re-checked before anything renders (E147): a plan + # loaded from a file bypasses the module schema, and shipping a Role + # that grants something other than what was declared must not happen. + rbac_rules_by_module: dict[str, list[dict[str, Any]]] = {} + for module in renderable: + rbac_rules, rbac_diags = _rbac_rules( + module["id"], module["implementation"]["kubernetes"] + ) + rbac_rules_by_module[module["id"]] = rbac_rules + diagnostics.extend(rbac_diags) + if any(diagnostic.level == "error" for diagnostic in diagnostics): return {}, diagnostics @@ -180,6 +198,7 @@ def render_helm( files: dict[str, str] = {} manifests: list[tuple[str, dict[str, Any]]] = [] secret_keys: set[str] = set() + module_surfaces: dict[str, list[dict[str, Any]]] = {} for module in renderable: module_id = module["id"] @@ -199,6 +218,8 @@ def render_helm( ) _merge_init_db_env(compose_services, module, secrets) + module_surfaces[module_id] = _workload_surfaces(k8s, context) + ( module_values, module_manifests, @@ -225,6 +246,16 @@ def render_helm( files.update(module_files) secret_keys.update(module_secret_keys) diagnostics.extend(module_diags) + manifests.append(( + f"{module_id}-service-account.yaml", + _service_account_manifest( + module_id, release, rbac_rules_by_module.get(module_id, []) + ), + )) + + manifests.extend(_network_policy_manifests( + renderable, module_surfaces, release, diagnostics + )) if secret_keys: manifests.append( @@ -405,6 +436,7 @@ def _render_module( if init_containers: pod_spec["initContainers"] = init_containers pod_spec["containers"] = containers + pod_spec["serviceAccountName"] = _service_account_name(module_id) psc = workload.get("podSecurityContext") if psc: pod_spec["securityContext"] = psc @@ -1249,6 +1281,320 @@ def _secret_manifest(release: str, keys: list[str]) -> str: ) +# --------------------------------------------------------------------------- +# Service accounts, RBAC, and network policies (issue #75) +# --------------------------------------------------------------------------- + +_RBAC_API_GROUP = "rbac.authorization.k8s.io" +_RBAC_API_VERSION = f"{_RBAC_API_GROUP}/v1" + +# Egress to the cluster DNS service is required by every workload that +# resolves any Service name; it is the only egress allowed without a +# contract binding. +_DNS_EGRESS_RULE: dict[str, Any] = { + "to": [ + { + "namespaceSelector": { + "matchLabels": {"kubernetes.io/metadata.name": "kube-system"} + }, + "podSelector": {"matchLabels": {"k8s-app": "kube-dns"}}, + } + ], + "ports": [ + {"protocol": "UDP", "port": 53}, + {"protocol": "TCP", "port": 53}, + ], +} + + +def _service_account_name(module_id: str) -> str: + return "{{ .Release.Name }}-" + _k8s_name(module_id) + + +def _rbac_rules( + module_id: str, k8s: dict[str, Any] +) -> tuple[list[dict[str, Any]], list[Diagnostic]]: + """ + Read the module's optional `serviceAccount.rbac` rules. + + Least privilege means zero rules by default: without declared rules a + module's workloads get no Role, no RoleBinding, and no mounted API token. + A malformed rule fails closed with E147 instead of silently rendering a + Role that grants something other than what was declared. + """ + diagnostics: list[Diagnostic] = [] + declared = (k8s.get("serviceAccount") or {}).get("rbac") + if not declared: + return [], diagnostics + if not isinstance(declared, list): + return [], [_rbac_shape_diagnostic(module_id, "rbac")] + + rules: list[dict[str, Any]] = [] + for index, rule in enumerate(declared): + well_formed = ( + isinstance(rule, dict) + and all( + isinstance(rule.get(field), list) and rule[field] + for field in ("apiGroups", "resources", "verbs") + ) + ) + if not well_formed: + diagnostics.append(_rbac_shape_diagnostic(module_id, f"rbac[{index}]")) + continue + rules.append(rule) + return rules, diagnostics + + +def _rbac_shape_diagnostic(module_id: str, where: str) -> Diagnostic: + return Diagnostic( + level="error", + code="E147", + message=( + f'RBAC rule "{where}" of module "{module_id}" must be a mapping with ' + 'non-empty "apiGroups", "resources", and "verbs" lists. Kubernetes ' + "would reject anything else at install time, so the renderer fails " + "closed instead of shipping a broken Role." + ), + path=f"module:{module_id}.implementation.kubernetes.serviceAccount.{where}", + ) + + +def _service_account_manifest( + module_id: str, release: str, rbac_rules: list[dict[str, Any]] +) -> str: + """ + One ServiceAccount per module, plus a Role and RoleBinding only when the + module declared RBAC rules. The documents are joined by hand because the + renderer emits one file per concern, not one object per file. + """ + name = _service_account_name(module_id) + service_account: dict[str, Any] = { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": {"name": name, "labels": _labels(release, module_id)}, + } + if not rbac_rules: + # Without RBAC rules the workloads have no business talking to the + # API server, so no token is mounted into their pods at all. + service_account["automountServiceAccountToken"] = False + + documents = [service_account] + if rbac_rules: + role_name = name + "-role" + documents.append( + { + "apiVersion": _RBAC_API_VERSION, + "kind": "Role", + "metadata": {"name": role_name, "labels": _labels(release, module_id)}, + "rules": rbac_rules, + } + ) + documents.append( + { + "apiVersion": _RBAC_API_VERSION, + "kind": "RoleBinding", + "metadata": { + "name": role_name + "-binding", + "labels": _labels(release, module_id), + }, + "subjects": [{"kind": "ServiceAccount", "name": name}], + "roleRef": { + "apiGroup": _RBAC_API_GROUP, + "kind": "Role", + "name": role_name, + }, + } + ) + return "---\n".join(_dump(document) for document in documents) + + +def _workload_surfaces( + k8s: dict[str, Any], context: dict[str, Any] +) -> list[dict[str, Any]]: + """ + Summarize the enabled workloads' exposure for NetworkPolicy generation: + the Service type and the container ports reachable through it. + """ + surfaces: list[dict[str, Any]] = [] + for workload_raw in (k8s.get("workloads") or {}).values(): + workload = _substitute_values(deepcopy(workload_raw), context) + enabled_from = workload.get("enabledFrom") + if enabled_from and _resolve_expr(enabled_from, context) is False: + continue + service = workload.get("service") or {} + ports = { + number + for number in ( + _container_port(port) + for port in service.get("ports", []) or [] + if isinstance(port, dict) + ) + if number is not None + } + surfaces.append({ + "type": service.get("type") or "ClusterIP", + "ports": sorted(ports), + }) + return surfaces + + +def _container_port(port: dict[str, Any]) -> int | None: + return _int_or_none(port.get("targetPort", port.get("port"))) + + +def _int_or_none(value: Any) -> int | None: + if isinstance(value, bool): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _network_policy_manifests( + modules: list[dict[str, Any]], + surfaces: dict[str, list[dict[str, Any]]], + release: str, + diagnostics: list[Diagnostic], +) -> list[tuple[str, dict[str, Any]]]: + """ + One NetworkPolicy per module: default-deny ingress and egress, then allow + rules derived from the plan's contract bindings. A binding from module C + to provider P yields an egress rule on C's pods and an ingress rule on + P's pods, both restricted to the binding's port. Within a module, pods + may talk freely (a module is the unit of composition and declares its own + internal wiring); DNS egress is the only other unconditional rule. + """ + module_ids = {module["id"] for module in modules} + egress_edges: dict[str, set[tuple[str, tuple[int, ...] | None]]] = {} + ingress_edges: dict[str, set[tuple[str, tuple[int, ...] | None]]] = {} + + for module in modules: + consumer_id = module["id"] + for consume_name, consume in (module.get("consumes") or {}).items(): + provider_id = str(consume.get("contractRef", "")).split(".", 1)[0] + if provider_id not in module_ids or provider_id == consumer_id: + continue + ports = _binding_ports( + consume, provider_id, surfaces, consumer_id, consume_name, diagnostics + ) + egress_edges.setdefault(consumer_id, set()).add((provider_id, ports)) + ingress_edges.setdefault(provider_id, set()).add((consumer_id, ports)) + + manifests: list[tuple[str, dict[str, Any]]] = [] + for module in modules: + module_id = module["id"] + ingress: list[dict[str, Any]] = [ + {"from": [_module_peer(module_id)]} + ] + for consumer_id, ports in _sorted_edges(ingress_edges.get(module_id)): + rule: dict[str, Any] = {"from": [_module_peer(consumer_id)]} + if ports is not None: + rule["ports"] = _tcp_ports(ports) + ingress.append(rule) + for surface in surfaces.get(module_id, []): + # A NodePort/LoadBalancer Service is an intended entry point: + # traffic arrives from sources no binding can name, so ingress + # is opened to all sources on exactly the exposed ports. + if surface["type"] in ("NodePort", "LoadBalancer") and surface["ports"]: + ingress.append({"ports": _tcp_ports(tuple(surface["ports"]))}) + + egress: list[dict[str, Any]] = [ + {"to": [_module_peer(module_id)]}, + deepcopy(_DNS_EGRESS_RULE), + ] + for provider_id, ports in _sorted_edges(egress_edges.get(module_id)): + rule = {"to": [_module_peer(provider_id)]} + if ports is not None: + rule["ports"] = _tcp_ports(ports) + egress.append(rule) + + manifests.append(( + f"{module_id}-network-policy.yaml", + { + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": { + "name": "{{ .Release.Name }}-" + module_id + "-network-policy", + "labels": _labels(release, module_id), + }, + "spec": { + "podSelector": {"matchLabels": { + "cds.dev/module": module_id, + "app.kubernetes.io/instance": "{{ .Release.Name }}", + }}, + "policyTypes": ["Ingress", "Egress"], + "ingress": ingress, + "egress": egress, + }, + }, + )) + return manifests + + +def _binding_ports( + consume: dict[str, Any], + provider_id: str, + surfaces: dict[str, list[dict[str, Any]]], + consumer_id: str, + consume_name: str, + diagnostics: list[Diagnostic], +) -> tuple[int, ...] | None: + """ + The ports a binding justifies: the contract's port when it is numeric, + otherwise the provider's advertised container ports. When neither is + available the rule cannot be port-scoped; that broader rule is allowed + but reported as W128 so the contract can be tightened. + """ + spec = (consume.get("contract") or {}).get("spec") or {} + port = _int_or_none(spec.get("port")) + if port is not None: + return (port,) + advertised = tuple(sorted({ + number + for surface in surfaces.get(provider_id, []) + for number in surface["ports"] + })) + if advertised: + return advertised + diagnostics.append(Diagnostic( + level="warning", + code="W128", + message=( + f'Contract "{consumer_id}.{consume_name}" carries no numeric port and ' + f'provider module "{provider_id}" advertises no service ports, so the ' + "generated NetworkPolicy allows egress to it on all ports. Declare the " + "port in the contract to keep the rule port-scoped." + ), + path=f"module:{consumer_id}.consumes.{consume_name}", + )) + return None + + +def _sorted_edges( + edges: set[tuple[str, tuple[int, ...] | None]] | None, +) -> list[tuple[str, tuple[int, ...] | None]]: + return sorted( + edges or set(), + key=lambda edge: (edge[0], edge[1] is None, edge[1] or ()), + ) + + +def _tcp_ports(ports: tuple[int, ...]) -> list[dict[str, Any]]: + return [{"protocol": "TCP", "port": port} for port in ports] + + +def _module_peer(module_id: str) -> dict[str, Any]: + return { + "podSelector": { + "matchLabels": { + "cds.dev/module": module_id, + "app.kubernetes.io/instance": "{{ .Release.Name }}", + } + } + } + + def _labels(release: str, module_id: str) -> dict[str, str]: return { "app.kubernetes.io/managed-by": "cds", diff --git a/cli/k8s_security.py b/cli/k8s_security.py index ec5c32d0..08b4ebf8 100644 --- a/cli/k8s_security.py +++ b/cli/k8s_security.py @@ -78,6 +78,52 @@ def scan_k8s_security(plan: dict[str, Any]) -> list[dict[str, Any]]: ["Declare both requests and limits for the container."], category="configuration-management", )) + service_type = (workload.get("service") or {}).get("type") + if service_type in ("NodePort", "LoadBalancer"): + findings.append(_finding( + "CDS-K8S-007", + "low", + ( + f'Workload "{module_id}/{workload_name}" exposes a ' + f"{service_type} service; the generated NetworkPolicy must " + "allow ingress from all sources on its ports." + ), + f"{path}.service.type", + module_id, + [ + "Confirm the workload is meant to be reachable cluster-wide " + "or externally; prefer ClusterIP with an explicit ingress " + "controller otherwise." + ], + )) + for index, rule in enumerate( + (kubernetes.get("serviceAccount") or {}).get("rbac") or [] + ): + if not isinstance(rule, dict): + continue + wildcard_fields = [ + field + for field in ("apiGroups", "resources", "verbs") + if isinstance(rule.get(field), list) and "*" in rule[field] + ] + if wildcard_fields: + findings.append(_finding( + "CDS-K8S-006", + "medium", + ( + f'RBAC rule {index} of module "{module_id}" grants wildcard ' + f'{" and ".join(wildcard_fields)}; least-privilege rules name ' + "every API group, resource, and verb explicitly." + ), + f"module:{module_id}.implementation.kubernetes.serviceAccount." + f"rbac.{index}", + module_id, + [ + "Replace the wildcard with the explicit API groups, " + "resources, and verbs the workload needs." + ], + category="configuration-management", + )) return findings @@ -124,11 +170,13 @@ def _finding( # declarative rule-set engine has no scope for the plan's # Kubernetes-specific implementation), so their compliance category # can't be looked up the way cli.security.run_security_validation() - # does for rule-set findings. CDS-K8S-001-004 are container/pod + # does for rule-set findings. CDS-K8S-001-004 and CDS-K8S-007 (the + # NodePort/LoadBalancer ingress exposure note) are container/pod # runtime-hardening posture checks, so they default to the matching # cli.security_common.COMPLIANCE_CATEGORIES value; CDS-K8S-005 - # (resource requests/limits) overrides this below since it's a - # capacity-governance concern rather than runtime-hardening posture. + # (resource requests/limits) and CDS-K8S-006 (wildcard RBAC rules) + # override this below since they're capacity-governance and + # privilege-governance concerns rather than runtime-hardening posture. "category": category, "message": message, "path": path, diff --git a/cli/resources/module.schema.json b/cli/resources/module.schema.json index a0eff417..0b02b2da 100644 --- a/cli/resources/module.schema.json +++ b/cli/resources/module.schema.json @@ -293,6 +293,74 @@ "$ref": "#/$defs/kubernetesConfigMap" }, "description": "Bind-mounted files promoted to ConfigMaps, keyed by the compose bind source path." + }, + "serviceAccount": { + "$ref": "#/$defs/kubernetesServiceAccount" + } + } + }, + "kubernetesServiceAccount": { + "type": "object", + "additionalProperties": false, + "description": "Settings for the module's generated ServiceAccount. The ServiceAccount is always created; RBAC rules are optional and empty by default (least privilege).", + "properties": { + "rbac": { + "type": "array", + "items": { + "$ref": "#/$defs/kubernetesRbacRule" + }, + "description": "RBAC rules granted to the module's ServiceAccount via a generated Role and RoleBinding. No rules means no API access and no mounted token." + } + } + }, + "kubernetesRbacRule": { + "type": "object", + "additionalProperties": false, + "required": [ + "apiGroups", + "resources", + "verbs" + ], + "description": "One rbac.authorization.k8s.io rule, rendered verbatim into the module's Role.", + "properties": { + "apiGroups": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "API groups the rule applies to, e.g. [\"\"] for the core group." + }, + "resources": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Resources the rule applies to, e.g. [\"pods\"]." + }, + "verbs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Verbs the rule grants, e.g. [\"get\", \"list\"]." + }, + "resourceNames": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Optional allowlist restricting the rule to named resource instances." } } }, diff --git a/tests/golden/k8s-chart.sha256 b/tests/golden/k8s-chart.sha256 index 5d4ee7b5..f57aeae3 100644 --- a/tests/golden/k8s-chart.sha256 +++ b/tests/golden/k8s-chart.sha256 @@ -4,17 +4,25 @@ b54b292ee6053f1e91ff3fc37d34a3011e9dd70ee3c5102279296d60496073e1 templates/NOTE 7285f18613f92d3bd012548731f906be978c700dd71f6b8bd0a018ef3da88282 templates/dagster-configmap-definitions.yaml 90affcbae52e8ed1dcf9a0d2db25a7a60406a7e887213d77292acc0ae1d42da1 templates/dagster-configmap-tender-analytics.yaml 1dea9d39ceb44a33a925a30f30953f6a0956d0bab6b942ea3ef292352a78221c templates/dagster-configmap-workspace.yaml -c134b42057e9f822ef31bf1aa00fc5bd0b7c568a058e4f4aa493f66da6f21c02 templates/dagster-daemon-deployment.yaml -f89d270842bc846d844a9fff48eaf6f346b73e99abaed460ecb3c3eb9e8aa00b templates/dagster-user-code-deployment.yaml +ec2275453c3bbf4c8292ce14f1f7b9816787af61566e413f9486aee99ddbb59c templates/dagster-daemon-deployment.yaml +2cbec17d64b462e88f77dc607b41a8fb2969e69cf1a17d20a36e0ffe1500be83 templates/dagster-network-policy.yaml +4db339b6526913e3dcf62ced827665e30d77a58ce9ad4740789ebb553c66bf16 templates/dagster-service-account.yaml +03ffb240f230bd30641f12f187b1c9053ca33d217a4385461af2998fb001007b templates/dagster-user-code-deployment.yaml 6baf098e39e19c454002dedffb15390ece299be215b9a41a9d9b613b595a72a1 templates/dagster-user-code-service.yaml -e8be1faad26f288a1d1c4db1d7fe56bb2aff3c0651666bcf58b4973c688f97e3 templates/dagster-webserver-deployment.yaml +a632d5daf52583a0de31e8725f76428bbb7f11b5deb00f044e8773bba92022bb templates/dagster-webserver-deployment.yaml e57c9b36e467239f0d2dff56d9ef912795ca302b388a8bd6bb133a1a97285efc templates/dagster-webserver-service.yaml -291b4e808012e0cf5152a11a58f1211278f9dc8e6f852389d17ac59a2a8b7122 templates/keydb-keydb-deployment.yaml +c83398cca2df9dd35c276c91f9f1db0cdfdfb955d4c73b25725d3aa1def2d270 templates/keydb-keydb-deployment.yaml 62f9934421dfd70fdeb0a4b5f4f550ba85f4f625c8d9546c8da64b511c9f5cd5 templates/keydb-keydb-service.yaml +dd98ea2f66b079e63264670181bb08dce4f3294375660266aca1f7845a1bba16 templates/keydb-network-policy.yaml +f5b0528691e8edd8c37de5e5e380d6f6a403d97f08d5f3f7737d0521ea68e610 templates/keydb-service-account.yaml db0f1f4d5d7b7a75514d94268837ab3b88e25aa498a570e863e95777e1bdf95b templates/postgres-configmap-init-db.yaml +f6b15802ad15d4b870affb7b9310cc266562ca54e3096fbebc9e3977741ce2ee templates/postgres-network-policy.yaml f4a75078867a6e94b32a85784e19c353b9525c52abbc36be9320f5e68ab6e535 templates/postgres-postgres-service.yaml -20ee85f59cd38fac561c3425414bf51053f29be4a9d09d16d31e6bebfcf4b974 templates/postgres-postgres-statefulset.yaml +e04699efdfcb178f5c421e90b228f5daa45dbae56c6417f0fc160e536edc7be2 templates/postgres-postgres-statefulset.yaml +08ab20e2b3bb2fdd5a84918c42e049d4236c8740b93fd89b109255b697f92e68 templates/postgres-service-account.yaml ca1c5e4df9f6a24175713ca7b3f9a38a306d0f817dba53699c9bf7a0ba02ef39 templates/secret.yaml -d45d64f497565a3dc9e74454ebee400868c19fd2d86ae6c0138361693c889cab templates/superset-superset-deployment.yaml +31a3e0b82d35f463cb7268530e2f4564690315e03090f283e1a9139b4270de9f templates/superset-network-policy.yaml +01d48eaeb9aa706ade8053e600d3083c5632fa610f0a72c4f5c385a9b806cb6b templates/superset-service-account.yaml +c3059afa6e5fae4438a0f81bb2c8609d3b57e065f6df6a4ddedab33d6def63e9 templates/superset-superset-deployment.yaml f290c6ba065f7cfb050515869936dfe627f6a58249323fb51bab22e65cd97cdc templates/superset-superset-service.yaml 4ed795e855da70396218c81805e4e8f2fe46938cd6ba63f76d1c28c0e3a70ec5 values.yaml diff --git a/tests/test_k8s_renderer.py b/tests/test_k8s_renderer.py index a7eadf44..bc5c105a 100644 --- a/tests/test_k8s_renderer.py +++ b/tests/test_k8s_renderer.py @@ -682,3 +682,302 @@ def test_plan_file_with_bool_enabled_from_on_workload_fails_closed(self) -> None "module:demo.implementation.kubernetes.workloads.app.enabledFrom", ) self.assertEqual(files, {}) + + +class NetworkPolicyAndServiceAccountTest(unittest.TestCase): + """The issue #75 resources: per-module ServiceAccounts and contract-derived NetworkPolicies.""" + + def module( + self, + module_id: str, + *, + service_type: str = "ClusterIP", + with_service: bool = True, + ) -> dict: + module: dict = { + "id": module_id, + "implementation": { + "kind": "docker-compose", + "targets": ["docker-compose", "kubernetes"], + "compose": {"services": {"app": {"image": "example/app:1"}}}, + "kubernetes": { + "workloads": { + "app": { + "kind": "Deployment", + "containers": ["app"], + } + } + }, + }, + } + if with_service: + module["implementation"]["kubernetes"]["workloads"]["app"]["service"] = { + "type": service_type, + "ports": [{"name": "http", "port": 8080}], + } + return module + + def bound_plan(self) -> dict: + provider = self.module("demo") + consumer = self.module("consumer") + consumer["consumes"] = { + "backend": { + "contractRef": "demo.http", + "contract": { + "kind": "http", + "spec": {"host": "demo-app", "port": 8080}, + }, + } + } + isolated = self.module("isolated") + return { + "metadata": {"name": "test-chart", "version": "1.2.3"}, + "runtime": {"namespace": "test"}, + "secrets": {}, + "modules": [provider, consumer, isolated], + } + + def render(self, plan: dict) -> tuple[dict, list]: + files, diagnostics = render_helm(plan) + errors = [d.format() for d in diagnostics if d.level == "error"] + self.assertEqual(errors, []) + return files, diagnostics + + def policy(self, files: dict, module_id: str) -> dict: + return yaml.safe_load(files[f"templates/{module_id}-network-policy.yaml"]) + + def test_policies_default_deny_and_allow_only_contract_bindings(self) -> None: + files, _ = self.render(self.bound_plan()) + + for module_id in ("demo", "consumer", "isolated"): + with self.subTest(module=module_id): + policy = self.policy(files, module_id) + self.assertEqual(policy["kind"], "NetworkPolicy") + self.assertEqual(policy["spec"]["policyTypes"], ["Ingress", "Egress"]) + self.assertEqual( + policy["spec"]["podSelector"]["matchLabels"]["cds.dev/module"], + module_id, + ) + + demo = self.policy(files, "demo") + consumer_rules = [ + rule + for rule in demo["spec"]["ingress"] + if rule.get("from") + and rule["from"][0]["podSelector"]["matchLabels"].get("cds.dev/module") + == "consumer" + ] + self.assertEqual( + consumer_rules, + [{ + "from": [{ + "podSelector": { + "matchLabels": { + "cds.dev/module": "consumer", + "app.kubernetes.io/instance": "{{ .Release.Name }}", + } + } + }], + "ports": [{"protocol": "TCP", "port": 8080}], + }], + ) + + consumer = self.policy(files, "consumer") + egress = consumer["spec"]["egress"] + # Same-module egress, DNS egress, and exactly one binding-derived rule. + self.assertEqual(len(egress), 3) + self.assertEqual( + egress[1]["to"][0]["namespaceSelector"]["matchLabels"], + {"kubernetes.io/metadata.name": "kube-system"}, + ) + self.assertEqual( + egress[1]["to"][0]["podSelector"]["matchLabels"], + {"k8s-app": "kube-dns"}, + ) + self.assertEqual( + egress[2], + { + "to": [{ + "podSelector": { + "matchLabels": { + "cds.dev/module": "demo", + "app.kubernetes.io/instance": "{{ .Release.Name }}", + } + } + }], + "ports": [{"protocol": "TCP", "port": 8080}], + }, + ) + + isolated = self.policy(files, "isolated") + # No policy allows traffic a binding does not justify: the isolated + # module talks to nobody but itself and the cluster DNS. + self.assertEqual(len(isolated["spec"]["ingress"]), 1) + self.assertEqual(len(isolated["spec"]["egress"]), 2) + self.assertNotIn("demo", yaml.safe_dump(isolated["spec"])) + self.assertNotIn("consumer", yaml.safe_dump(isolated["spec"])) + + def test_policies_allow_same_module_traffic_and_dns_on_every_module(self) -> None: + files, _ = self.render(self.bound_plan()) + + for module_id in ("demo", "consumer", "isolated"): + policy = self.policy(files, module_id) + with self.subTest(module=module_id): + self.assertEqual( + policy["spec"]["ingress"][0]["from"][0]["podSelector"][ + "matchLabels" + ]["cds.dev/module"], + module_id, + ) + dns = policy["spec"]["egress"][1] + self.assertEqual( + dns["ports"], + [{"protocol": "UDP", "port": 53}, {"protocol": "TCP", "port": 53}], + ) + + def nodeport_plan(self) -> dict: + plan = self.bound_plan() + plan["modules"][0] = self.module("demo", service_type="NodePort") + return plan + + def test_nodeport_service_opens_ingress_on_its_ports_only(self) -> None: + # The NodePort Service is an intended entry point: ingress is + # opened to all sources, but only on the exposed ports. + files, _ = self.render(self.nodeport_plan()) + + demo = self.policy(files, "demo") + open_rules = [ + rule for rule in demo["spec"]["ingress"] if "from" not in rule + ] + self.assertEqual( + open_rules, + [{"ports": [{"protocol": "TCP", "port": 8080}]}], + ) + + def test_workloads_run_under_module_service_accounts(self) -> None: + files, _ = self.render(self.bound_plan()) + + for module_id in ("demo", "consumer", "isolated"): + with self.subTest(module=module_id): + documents = list(yaml.safe_load_all( + files[f"templates/{module_id}-service-account.yaml"] + )) + self.assertEqual(len(documents), 1) + service_account = documents[0] + self.assertEqual(service_account["kind"], "ServiceAccount") + self.assertEqual( + service_account["metadata"]["name"], + f"{{{{ .Release.Name }}}}-{module_id}", + ) + # Least privilege without RBAC rules: no API token at all. + self.assertFalse( + service_account["automountServiceAccountToken"] + ) + # The deployment carries Helm's `toYaml` expansion for + # resources, so it is checked textually rather than parsed. + workload = files[f"templates/{module_id}-app-deployment.yaml"] + self.assertIn( + f"serviceAccountName: '{{{{ .Release.Name }}}}-{module_id}'\n", + workload, + ) + + def test_declared_rbac_rules_render_role_and_binding(self) -> None: + plan = self.bound_plan() + rule = { + "apiGroups": [""], + "resources": ["pods"], + "verbs": ["get", "list"], + } + plan["modules"][0]["implementation"]["kubernetes"]["serviceAccount"] = { + "rbac": [rule] + } + + files, _ = self.render(plan) + + documents = list(yaml.safe_load_all( + files["templates/demo-service-account.yaml"] + )) + self.assertEqual( + [document["kind"] for document in documents], + ["ServiceAccount", "Role", "RoleBinding"], + ) + service_account, role, binding = documents + self.assertNotIn("automountServiceAccountToken", service_account) + self.assertEqual(role["rules"], [rule]) + self.assertEqual( + binding["subjects"], + [{"kind": "ServiceAccount", "name": "{{ .Release.Name }}-demo"}], + ) + self.assertEqual( + binding["roleRef"], + { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "Role", + "name": "{{ .Release.Name }}-demo-role", + }, + ) + + def test_malformed_rbac_rule_fails_closed(self) -> None: + # Plans can be loaded from files and bypass the module schema, so + # the renderer re-checks the RBAC rule shape: anything Kubernetes + # would reject at install time is an error, not a broken Role. + plan = self.bound_plan() + plan["modules"][0]["implementation"]["kubernetes"]["serviceAccount"] = { + "rbac": [{"apiGroups": [""], "resources": ["pods"]}] + } + + files, diagnostics = render_helm(plan) + + self.assertEqual(files, {}) + e147 = [d for d in diagnostics if d.code == "E147"] + self.assertEqual(len(e147), 1) + self.assertEqual( + e147[0].path, + "module:demo.implementation.kubernetes.serviceAccount.rbac[0]", + ) + + def test_binding_without_port_falls_back_to_provider_service_ports(self) -> None: + plan = self.bound_plan() + contract = plan["modules"][1]["consumes"]["backend"]["contract"] + contract["spec"].pop("port") + + files, diagnostics = self.render(plan) + + self.assertNotIn("W128", {d.code for d in diagnostics}) + consumer = self.policy(files, "consumer") + self.assertEqual( + consumer["spec"]["egress"][2]["ports"], + [{"protocol": "TCP", "port": 8080}], + ) + + def test_binding_without_any_port_evidence_warns_and_stays_module_scoped(self) -> None: + # E084 requires the binding host to be an exposed Service, but that + # Service can belong to a different module than the contractRef + # names; when the provider itself advertises no ports, the egress + # rule cannot be port-scoped and W128 says so. + plan = self.bound_plan() + plan["modules"][0] = self.module("demo", with_service=False) + contract = plan["modules"][1]["consumes"]["backend"]["contract"] + contract["spec"].pop("port") + contract["spec"]["host"] = "isolated-app" + + files, diagnostics = render_helm(plan) + + self.assertFalse(any(d.level == "error" for d in diagnostics)) + w128 = [d for d in diagnostics if d.code == "W128"] + self.assertEqual(len(w128), 1) + self.assertEqual(w128[0].path, "module:consumer.consumes.backend") + consumer = self.policy(files, "consumer") + self.assertEqual( + consumer["spec"]["egress"][2], + { + "to": [{ + "podSelector": { + "matchLabels": { + "cds.dev/module": "demo", + "app.kubernetes.io/instance": "{{ .Release.Name }}", + } + } + }] + }, + ) diff --git a/tests/test_k8s_security.py b/tests/test_k8s_security.py index 7da0a2be..e142d8a6 100644 --- a/tests/test_k8s_security.py +++ b/tests/test_k8s_security.py @@ -150,6 +150,82 @@ def test_container_override_can_establish_non_root_constraint(self) -> None: self.assertEqual(scan_k8s_security(plan), []) + def test_wildcard_rbac_rule_is_flagged(self) -> None: + plan = self.secure_plan() + kubernetes = plan["modules"][0]["implementation"]["kubernetes"] + kubernetes["serviceAccount"] = { + "rbac": [ + { + "apiGroups": ["*"], + "resources": ["pods"], + "verbs": ["get", "list", "*"], + } + ] + } + + findings = scan_k8s_security(plan) + + matching = [f for f in findings if f["rule_id"] == "CDS-K8S-006"] + self.assertEqual(len(matching), 1) + self.assertEqual(matching[0]["severity"], "medium") + self.assertEqual(matching[0]["category"], "configuration-management") + self.assertIn("apiGroups", matching[0]["message"]) + self.assertIn("verbs", matching[0]["message"]) + self.assertEqual( + matching[0]["path"], + "module:demo.implementation.kubernetes.serviceAccount.rbac.0", + ) + + def test_explicit_rbac_rule_is_not_flagged(self) -> None: + plan = self.secure_plan() + kubernetes = plan["modules"][0]["implementation"]["kubernetes"] + kubernetes["serviceAccount"] = { + "rbac": [ + { + "apiGroups": [""], + "resources": ["pods"], + "verbs": ["get", "list"], + "resourceNames": ["my-pod"], + } + ] + } + + self.assertEqual(scan_k8s_security(plan), []) + + def test_nodeport_service_exposure_is_flagged(self) -> None: + plan = self.secure_plan() + workload = plan["modules"][0]["implementation"]["kubernetes"]["workloads"][ + "app" + ] + workload["service"] = { + "type": "NodePort", + "ports": [{"name": "http", "port": 8080}], + } + + findings = scan_k8s_security(plan) + + matching = [f for f in findings if f["rule_id"] == "CDS-K8S-007"] + self.assertEqual(len(matching), 1) + self.assertEqual(matching[0]["severity"], "low") + self.assertEqual(matching[0]["category"], "system-hardening") + self.assertIn("NodePort", matching[0]["message"]) + self.assertEqual( + matching[0]["path"], + "module:demo.implementation.kubernetes.workloads.app.service.type", + ) + + def test_clusterip_service_is_not_flagged(self) -> None: + plan = self.secure_plan() + workload = plan["modules"][0]["implementation"]["kubernetes"]["workloads"][ + "app" + ] + workload["service"] = { + "type": "ClusterIP", + "ports": [{"name": "http", "port": 8080}], + } + + self.assertEqual(scan_k8s_security(plan), []) + if __name__ == "__main__": unittest.main() From ebcb94169e6d82c8b6f2a6ed03cb0eb2a39992a6 Mon Sep 17 00:00:00 2001 From: Ronald Hensbergen Date: Thu, 29 Oct 2026 19:20:42 +0100 Subject: [PATCH 2/4] fix(k8s): fail closed on malformed serviceAccount blocks and allow the core API group in RBAC rules (#75) Self-review fixes for the NetworkPolicy/ServiceAccount PR: - A plan loaded from a file bypasses the module schema, so a non-mapping implementation.kubernetes.serviceAccount block crashed both the helm renderer and scan_k8s_security with a raw AttributeError. The renderer now fails closed with E147, and the scan ignores the malformed block (shape errors are the renderer's diagnostics to report). The NetworkPolicy binding derivation and workload surface collection skip non-dict consumes entries and service blocks for the same reason. - module.schema.json rejected apiGroups: [""] (the Kubernetes core group, which the schema's own description uses as the example) because the items carried minLength: 1; the constraint is dropped for apiGroups only, and a schema regression test pins the spelling. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- cli/k8s_renderer.py | 25 +++++++++++++++++++++-- cli/k8s_security.py | 10 ++++++--- cli/resources/module.schema.json | 3 +-- tests/test_k8s_renderer.py | 35 ++++++++++++++++++++++++++++++++ tests/test_k8s_security.py | 9 ++++++++ 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/cli/k8s_renderer.py b/cli/k8s_renderer.py index f829dc9e..4bed7dde 100644 --- a/cli/k8s_renderer.py +++ b/cli/k8s_renderer.py @@ -1323,7 +1323,24 @@ def _rbac_rules( Role that grants something other than what was declared. """ diagnostics: list[Diagnostic] = [] - declared = (k8s.get("serviceAccount") or {}).get("rbac") + service_account = k8s.get("serviceAccount") + if service_account is None: + return [], diagnostics + if not isinstance(service_account, dict): + # A plan loaded from a file bypasses the module schema; a + # non-mapping block must fail closed, not crash on `.get`. + return [], [Diagnostic( + level="error", + code="E147", + message=( + f'The "serviceAccount" block of module "{module_id}" must be a ' + 'mapping with an optional "rbac" rules list. Kubernetes would ' + "reject anything else at install time, so the renderer fails " + "closed instead of shipping a broken Role." + ), + path=f"module:{module_id}.implementation.kubernetes.serviceAccount", + )] + declared = service_account.get("rbac") if not declared: return [], diagnostics if not isinstance(declared, list): @@ -1421,7 +1438,9 @@ def _workload_surfaces( enabled_from = workload.get("enabledFrom") if enabled_from and _resolve_expr(enabled_from, context) is False: continue - service = workload.get("service") or {} + service = workload.get("service") + if not isinstance(service, dict): + service = {} ports = { number for number in ( @@ -1472,6 +1491,8 @@ def _network_policy_manifests( for module in modules: consumer_id = module["id"] for consume_name, consume in (module.get("consumes") or {}).items(): + if not isinstance(consume, dict): + continue provider_id = str(consume.get("contractRef", "")).split(".", 1)[0] if provider_id not in module_ids or provider_id == consumer_id: continue diff --git a/cli/k8s_security.py b/cli/k8s_security.py index 08b4ebf8..ce5eeec3 100644 --- a/cli/k8s_security.py +++ b/cli/k8s_security.py @@ -96,9 +96,13 @@ def scan_k8s_security(plan: dict[str, Any]) -> list[dict[str, Any]]: "controller otherwise." ], )) - for index, rule in enumerate( - (kubernetes.get("serviceAccount") or {}).get("rbac") or [] - ): + service_account = kubernetes.get("serviceAccount") or {} + declared_rbac = ( + service_account.get("rbac") + if isinstance(service_account, dict) + else None + ) + for index, rule in enumerate(declared_rbac or []): if not isinstance(rule, dict): continue wildcard_fields = [ diff --git a/cli/resources/module.schema.json b/cli/resources/module.schema.json index 0b02b2da..1f03c0af 100644 --- a/cli/resources/module.schema.json +++ b/cli/resources/module.schema.json @@ -328,8 +328,7 @@ "minItems": 1, "uniqueItems": true, "items": { - "type": "string", - "minLength": 1 + "type": "string" }, "description": "API groups the rule applies to, e.g. [\"\"] for the core group." }, diff --git a/tests/test_k8s_renderer.py b/tests/test_k8s_renderer.py index bc5c105a..b02feace 100644 --- a/tests/test_k8s_renderer.py +++ b/tests/test_k8s_renderer.py @@ -1,6 +1,7 @@ import contextlib import hashlib import io +import json import os import tempfile import unittest @@ -9,6 +10,7 @@ from unittest import mock import yaml +from jsonschema import Draft202012Validator from cli.k8s_renderer import _configmap_checksum, render_helm from cli.main import _render_helm_chart @@ -936,6 +938,39 @@ def test_malformed_rbac_rule_fails_closed(self) -> None: "module:demo.implementation.kubernetes.serviceAccount.rbac[0]", ) + def test_malformed_service_account_block_fails_closed(self) -> None: + # Same bypass class as the RBAC rules: a non-mapping + # `serviceAccount` block must fail closed as E147, not crash the + # renderer with an AttributeError from `.get()`. + plan = self.bound_plan() + plan["modules"][0]["implementation"]["kubernetes"]["serviceAccount"] = "default" + + files, diagnostics = render_helm(plan) + + self.assertEqual(files, {}) + e147 = [d for d in diagnostics if d.code == "E147"] + self.assertEqual(len(e147), 1) + self.assertEqual( + e147[0].path, + "module:demo.implementation.kubernetes.serviceAccount", + ) + + def test_module_schema_accepts_the_core_api_group(self) -> None: + # The Kubernetes core API group is spelled "" (the schema's own + # description uses it as the example); `minLength` on the items + # would reject exactly that spelling. + schema = json.loads( + (Path(__file__).resolve().parent.parent / "cli" / "resources" + / "module.schema.json").read_text(encoding="utf-8") + ) + validator = Draft202012Validator(schema["$defs"]["kubernetesRbacRule"]) + rule = { + "apiGroups": [""], + "resources": ["pods"], + "verbs": ["get", "list"], + } + self.assertEqual(list(validator.iter_errors(rule)), []) + def test_binding_without_port_falls_back_to_provider_service_ports(self) -> None: plan = self.bound_plan() contract = plan["modules"][1]["consumes"]["backend"]["contract"] diff --git a/tests/test_k8s_security.py b/tests/test_k8s_security.py index e142d8a6..82acc432 100644 --- a/tests/test_k8s_security.py +++ b/tests/test_k8s_security.py @@ -214,6 +214,15 @@ def test_nodeport_service_exposure_is_flagged(self) -> None: "module:demo.implementation.kubernetes.workloads.app.service.type", ) + def test_malformed_service_account_block_does_not_crash_the_scan(self) -> None: + # Plan files bypass the module schema; a non-mapping + # `serviceAccount` block is a shape error for the renderer to + # report (E147), not a reason for the scan to crash. + plan = self.secure_plan() + plan["modules"][0]["implementation"]["kubernetes"]["serviceAccount"] = "default" + + self.assertEqual(scan_k8s_security(plan), []) + def test_clusterip_service_is_not_flagged(self) -> None: plan = self.secure_plan() workload = plan["modules"][0]["implementation"]["kubernetes"]["workloads"][ From f69bf9f68e58de891937b126620094c65998facd Mon Sep 17 00:00:00 2001 From: Ronald Hensbergen Date: Fri, 30 Oct 2026 18:21:41 +0100 Subject: [PATCH 3/4] fix(k8s): review follow-ups for NetworkPolicies and ServiceAccounts Resolves the PR review findings on #75 in one pass: - E147 renumbered to E148: main (via #1020) already uses E147 for the object-storage backup same-destination guard; two unrelated diagnostics must not share a code. - External-egress/ingress escape hatch: modules can declare custom NetworkPolicy allow rules via implementation.kubernetes.networkPolicy.ingress/.egress, rendered verbatim after the binding-derived rules, for destinations no binding can name (an external API a job reads from, a cross-namespace ingress controller). Malformed shapes fail closed with E149 (plan-file bypass of the module schema), and CDS-K8S-008 records every external-egress rule as a low finding so intentional openings stay visible in the security scan. - The contract's Service-facing port is now mapped through the provider's Service targetPort, so a `port: X, targetPort: Y` remap can no longer silently break a port-scoped rule. - CDS-K8S-007's recommendation now matches the behavior: an external ingress controller needs explicit networkPolicy.ingress rules because the generated policies deny it by default. - Documented the network-isolation baseline in docs/kubernetes.md (default-deny posture, binding-derived allows, same-module rationale, DNS egress, NodePort/LB exposure, the passthrough and its security visibility), updated the stale "Network policy not emitted" boundary row, added E148/E149/W128 to the README troubleshooting table, and extended the CHANGELOG entry. - W129 (binding to an unrendered provider) was considered and rejected as dead code: E072 already fails the whole Helm render for any plan module lacking a Kubernetes implementation, so the silent-skip cannot occur. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- CHANGELOG.md | 17 +++- README.md | 3 + cli/k8s_renderer.py | 131 +++++++++++++++++++++++++++---- cli/k8s_security.py | 49 +++++++++++- cli/resources/module.schema.json | 24 ++++++ docs/kubernetes.md | 40 +++++++++- tests/test_k8s_renderer.py | 111 ++++++++++++++++++++++++-- tests/test_k8s_security.py | 71 +++++++++++++++++ 8 files changed, 417 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c1926d9..e1d68a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,11 +18,20 @@ The format is based on Keep a Changelog. workload runs under a dedicated per-module ServiceAccount with opt-in least-privilege RBAC via the new `implementation.kubernetes.serviceAccount.rbac` rules — no rules means - no Role, no RoleBinding, and no mounted API token. Extended the - Kubernetes security scan with CDS-K8S-006 (wildcard RBAC grants) and - CDS-K8S-007 (NodePort/LoadBalancer ingress exposure), and added a W128 + no Role, no RoleBinding, and no mounted API token (malformed rules fail + closed with E148). Custom allow rules for destinations no binding can + name — an external API or database a job reads from, a cross-namespace + ingress controller — are opt-in via + `implementation.kubernetes.networkPolicy.ingress`/`egress`, rendered + verbatim after the binding-derived rules (malformed shapes fail closed + with E149), and the contract's Service-facing port is mapped through the + provider's Service `targetPort` so a port remap cannot silently break a + rule. Extended the Kubernetes security scan with CDS-K8S-006 (wildcard + RBAC grants), CDS-K8S-007 (NodePort/LoadBalancer ingress exposure), and + CDS-K8S-008 (external-egress passthrough rules), and added a W128 warning when a binding carries no port evidence and its egress rule - cannot be port-scoped. + cannot be port-scoped; documented the network-isolation baseline in + `docs/kubernetes.md`. - Added a single-node Garage S3-compatible object-storage module and provider-neutral `object-storage` contract, with persistent data and metadata volumes and profile-secret-backed credentials. The module is diff --git a/README.md b/README.md index cc3b7c9a..7a092725 100644 --- a/README.md +++ b/README.md @@ -794,6 +794,9 @@ Common errors from `cds validate`, `cds plan`, and `cds render`, and how to fix | `[E137] ... accepts unauthenticated clients` | A production profile runs a `cache-service` provider that does not advertise `authentication: required` (no password credential configured). | Supply a cache password through a `secrets.` reference (e.g. keydb `config.passwordFrom`), declared `required: true` in `spec.secrets.values`. | | `[E138] ... Credential field ... must reference a secret alias` / `... is empty` / `... uses a ${CDS_*} profile input` / `... contains a plaintext value` | A credential-named config field of a production database/cache provider is not a `secrets.` reference, or its alias is not declared `required: true` (issue #206). | Supply the credential through a secret reference declared `required: true` so a missing value fails closed at startup. | | `[W105] ... legacy md5 host authentication` | A production `sql-database` provider advertises `md5`, which validates but is a deprecated password method. | Prefer `scram-sha-256` for production profiles. | +| `[E148] ... RBAC rule ... must be a mapping with non-empty "apiGroups", "resources", and "verbs" lists` / `... The "serviceAccount" block ... must be a mapping ...` | A module's `implementation.kubernetes.serviceAccount` block (or one of its `rbac` rules) is malformed -- a plan loaded from a file bypasses the module schema, so the Helm renderer fails closed instead of shipping a broken Role (#75). | Fix the `serviceAccount`/`rbac` shape in the module (or the saved plan). | +| `[E149] ... The "networkPolicy"/... block ... must be a mapping with optional "ingress"/"egress" lists ...` | A module's custom NetworkPolicy allow rules are malformed (plan-file bypass of the module schema) (#75). | Fix the `networkPolicy` shape; each rule must be a mapping. | +| `[W128] ... carries no numeric port and provider module ... advertises no service ports ...` | A contract binding carries no port evidence, so its generated NetworkPolicy egress rule allows all ports to the provider (#75). | Declare the port in the contract so the rule stays port-scoped. | | `[W097] ... config.image.tag is "latest" with config.image.source "registry"` | `config.image.tag: latest` under `source: registry` still validates, but drifts silently between deploys instead of pinning a reproducible version. | Pin an explicit tag from `publish-images.yml`'s output (or `tests/fixtures/signed-images.json`) instead of `latest`. | | `[E127] ... Unknown confinement key ...` / `... must be 'default', 'unconfined' or 'file:' ...` / `... Invalid SELinux label(s) ...` | A `security.runtime.confinement` block (or a module instance's confinement) is malformed: an unsupported key, an invalid `seccomp`/`apparmor`/`selinux` value, or a `file:` seccomp profile path that is missing or resolves outside the profile directory. | Use `seccomp: default`/`unconfined`/`file:`, `apparmor: runtime/default`/`unconfined`/``, and `selinux: disable` or a `label: value` list; see `docs/runtime-confinement.md`. | | `[W071] ... has kind "docker-compose" but no "compose" definition` | A module implementation declares the `docker-compose` kind without an `implementation.compose` block, so the renderer skips the module. | Add the `implementation.compose` block (services, volumes) to the module's `module.yaml`, or set a supported implementation `kind`. | diff --git a/cli/k8s_renderer.py b/cli/k8s_renderer.py index 4bed7dde..66d3b887 100644 --- a/cli/k8s_renderer.py +++ b/cli/k8s_renderer.py @@ -172,10 +172,13 @@ def render_helm( ) ) - # RBAC rule shapes are re-checked before anything renders (E147): a plan + # RBAC rule shapes are re-checked before anything renders (E148): a plan # loaded from a file bypasses the module schema, and shipping a Role # that grants something other than what was declared must not happen. rbac_rules_by_module: dict[str, list[dict[str, Any]]] = {} + # Custom NetworkPolicy allow rules (E149) are validated for the same + # reason: they are merged verbatim into the module's policy. + network_policy_passthrough: dict[str, tuple[list[dict[str, Any]], list[dict[str, Any]]]] = {} for module in renderable: rbac_rules, rbac_diags = _rbac_rules( module["id"], module["implementation"]["kubernetes"] @@ -183,6 +186,12 @@ def render_helm( rbac_rules_by_module[module["id"]] = rbac_rules diagnostics.extend(rbac_diags) + passthrough, passthrough_diags = _network_policy_passthrough( + module["id"], module["implementation"]["kubernetes"] + ) + network_policy_passthrough[module["id"]] = passthrough + diagnostics.extend(passthrough_diags) + if any(diagnostic.level == "error" for diagnostic in diagnostics): return {}, diagnostics @@ -254,7 +263,11 @@ def render_helm( )) manifests.extend(_network_policy_manifests( - renderable, module_surfaces, release, diagnostics + renderable, + module_surfaces, + release, + diagnostics, + network_policy_passthrough, )) if secret_keys: @@ -1331,7 +1344,7 @@ def _rbac_rules( # non-mapping block must fail closed, not crash on `.get`. return [], [Diagnostic( level="error", - code="E147", + code="E148", message=( f'The "serviceAccount" block of module "{module_id}" must be a ' 'mapping with an optional "rbac" rules list. Kubernetes would ' @@ -1365,7 +1378,7 @@ def _rbac_rules( def _rbac_shape_diagnostic(module_id: str, where: str) -> Diagnostic: return Diagnostic( level="error", - code="E147", + code="E148", message=( f'RBAC rule "{where}" of module "{module_id}" must be a mapping with ' 'non-empty "apiGroups", "resources", and "verbs" lists. Kubernetes ' @@ -1376,6 +1389,61 @@ def _rbac_shape_diagnostic(module_id: str, where: str) -> Diagnostic: ) +def _network_policy_passthrough( + module_id: str, k8s: dict[str, Any] +) -> tuple[tuple[list[dict[str, Any]], list[dict[str, Any]]], list[Diagnostic]]: + """ + Read the module's optional `networkPolicy.ingress`/`networkPolicy.egress` + custom allow rules, rendered verbatim into the module's NetworkPolicy. + + These exist because contract bindings cannot express everything a real + deployment needs: an external API or database a job reads from, or a + cross-namespace ingress controller fronting a ClusterIP service, would + otherwise be silently denied by the default-deny baseline. A malformed + block fails closed with E149 (a plan loaded from a file bypasses the + module schema), mirroring the RBAC shape check. + """ + diagnostics: list[Diagnostic] = [] + empty: tuple[list[dict[str, Any]], list[dict[str, Any]]] = ([], []) + network_policy = k8s.get("networkPolicy") + if network_policy is None: + return empty, diagnostics + if not isinstance(network_policy, dict): + return empty, [ + _network_policy_shape_diagnostic(module_id, "networkPolicy") + ] + + rules: dict[str, list[dict[str, Any]]] = {} + for direction in ("ingress", "egress"): + declared = network_policy.get(direction) + if declared is None: + rules[direction] = [] + continue + if not isinstance(declared, list) or not all( + isinstance(rule, dict) for rule in declared + ): + return empty, [ + _network_policy_shape_diagnostic(module_id, f"networkPolicy.{direction}") + ] + rules[direction] = declared + return (rules["ingress"], rules["egress"]), diagnostics + + +def _network_policy_shape_diagnostic(module_id: str, where: str) -> Diagnostic: + return Diagnostic( + level="error", + code="E149", + message=( + f'The "{where}" block of module "{module_id}" must be a mapping with ' + 'optional "ingress"/"egress" lists of NetworkPolicy rule objects ' + "(mappings). Kubernetes would reject anything else at install time, " + "so the renderer fails closed instead of shipping a broken " + "NetworkPolicy." + ), + path=f"module:{module_id}.implementation.kubernetes.{where}", + ) + + def _service_account_manifest( module_id: str, release: str, rbac_rules: list[dict[str, Any]] ) -> str: @@ -1430,7 +1498,10 @@ def _workload_surfaces( ) -> list[dict[str, Any]]: """ Summarize the enabled workloads' exposure for NetworkPolicy generation: - the Service type and the container ports reachable through it. + the Service type, the container ports reachable through it, and each + declared Service port -> targetPort mapping (the contract port is + Service-facing while NetworkPolicy ports match the destination pod + port, so a remap must translate one to the other). """ surfaces: list[dict[str, Any]] = [] for workload_raw in (k8s.get("workloads") or {}).values(): @@ -1441,18 +1512,23 @@ def _workload_surfaces( service = workload.get("service") if not isinstance(service, dict): service = {} - ports = { - number - for number in ( - _container_port(port) - for port in service.get("ports", []) or [] - if isinstance(port, dict) - ) - if number is not None - } + ports: set[int] = set() + port_map: dict[int, set[int]] = {} + for port in service.get("ports", []) or []: + if not isinstance(port, dict): + continue + target_port = _container_port(port) + service_port = _int_or_none(port.get("port")) + if target_port is not None: + ports.add(target_port) + if service_port is not None and target_port is not None: + port_map.setdefault(service_port, set()).add(target_port) surfaces.append({ "type": service.get("type") or "ClusterIP", "ports": sorted(ports), + "port_map": { + number: sorted(targets) for number, targets in port_map.items() + }, }) return surfaces @@ -1475,6 +1551,7 @@ def _network_policy_manifests( surfaces: dict[str, list[dict[str, Any]]], release: str, diagnostics: list[Diagnostic], + passthrough: dict[str, tuple[list[dict[str, Any]], list[dict[str, Any]]]], ) -> list[tuple[str, dict[str, Any]]]: """ One NetworkPolicy per module: default-deny ingress and egress, then allow @@ -1483,6 +1560,14 @@ def _network_policy_manifests( P's pods, both restricted to the binding's port. Within a module, pods may talk freely (a module is the unit of composition and declares its own internal wiring); DNS egress is the only other unconditional rule. + + Custom `networkPolicy.ingress`/`networkPolicy.egress` rules are appended + after the derived rules: bindings cannot name external endpoints or + cross-namespace clients (an external API a job reads from, an ingress + controller fronting a ClusterIP service), which would otherwise be + silently denied. A binding whose provider is absent from the chart + cannot occur silently: E072 already fails the whole render for any plan + module that lacks a Kubernetes implementation. """ module_ids = {module["id"] for module in modules} egress_edges: dict[str, set[tuple[str, tuple[int, ...] | None]]] = {} @@ -1530,6 +1615,13 @@ def _network_policy_manifests( rule["ports"] = _tcp_ports(ports) egress.append(rule) + # Custom allow rules come after the derived ones: they name + # destinations and clients no binding can express (external + # endpoints, cross-namespace ingress controllers). + custom_ingress, custom_egress = passthrough.get(module_id, ([], [])) + ingress.extend(deepcopy(rule) for rule in custom_ingress) + egress.extend(deepcopy(rule) for rule in custom_egress) + manifests.append(( f"{module_id}-network-policy.yaml", { @@ -1566,10 +1658,21 @@ def _binding_ports( otherwise the provider's advertised container ports. When neither is available the rule cannot be port-scoped; that broader rule is allowed but reported as W128 so the contract can be tightened. + + The contract's port is Service-facing, while NetworkPolicy ports match + the destination POD port: when the provider's Services declare that + port with a different targetPort, the rule is generated for the + targetPort(s), so a `port: X, targetPort: Y` remap cannot silently + break the rule. """ spec = (consume.get("contract") or {}).get("spec") or {} port = _int_or_none(spec.get("port")) if port is not None: + mapped: set[int] = set() + for surface in surfaces.get(provider_id, []): + mapped.update(surface.get("port_map", {}).get(port, [])) + if mapped: + return tuple(sorted(mapped)) return (port,) advertised = tuple(sorted({ number diff --git a/cli/k8s_security.py b/cli/k8s_security.py index ce5eeec3..28114395 100644 --- a/cli/k8s_security.py +++ b/cli/k8s_security.py @@ -93,7 +93,10 @@ def scan_k8s_security(plan: dict[str, Any]) -> list[dict[str, Any]]: [ "Confirm the workload is meant to be reachable cluster-wide " "or externally; prefer ClusterIP with an explicit ingress " - "controller otherwise." + "controller plus the cross-namespace ingress rules that " + "controller needs (implementation.kubernetes.networkPolicy." + "ingress), since the generated NetworkPolicy denies it by " + "default." ], )) service_account = kubernetes.get("serviceAccount") or {} @@ -128,6 +131,43 @@ def scan_k8s_security(plan: dict[str, Any]) -> list[dict[str, Any]]: ], category="configuration-management", )) + network_policy = kubernetes.get("networkPolicy") or {} + if isinstance(network_policy, dict): + for index, rule in enumerate(network_policy.get("egress") or []): + if not isinstance(rule, dict): + continue + # An egress rule without a "to" restriction (or with an + # ipBlock peer) opens external egress that the default-deny + # baseline would otherwise block. Selector-based peers stay + # cluster-internal and are not flagged. + destinations = rule.get("to") + opens_external = True + if isinstance(destinations, list): + opens_external = any( + isinstance(peer, dict) and "ipBlock" in peer + for peer in destinations + ) + if opens_external: + findings.append(_finding( + "CDS-K8S-008", + "low", + ( + f'Custom egress rule {index} of module "{module_id}" opens ' + "external egress (no destination restriction or an " + "ipBlock peer), which the default-deny NetworkPolicy " + "baseline would otherwise block." + ), + f"module:{module_id}.implementation.kubernetes.networkPolicy." + f"egress.{index}", + module_id, + [ + "Confirm the external destination is intended; scope the " + "rule to the minimum: the smallest ipBlock cidr (with " + "except where possible) or a selector-based peer, and a " + "TCP port list." + ], + category="configuration-management", + )) return findings @@ -178,9 +218,10 @@ def _finding( # NodePort/LoadBalancer ingress exposure note) are container/pod # runtime-hardening posture checks, so they default to the matching # cli.security_common.COMPLIANCE_CATEGORIES value; CDS-K8S-005 - # (resource requests/limits) and CDS-K8S-006 (wildcard RBAC rules) - # override this below since they're capacity-governance and - # privilege-governance concerns rather than runtime-hardening posture. + # (resource requests/limits), CDS-K8S-006 (wildcard RBAC rules), and + # CDS-K8S-008 (unrestricted custom egress) override this below since + # they're capacity-, privilege-, and network-governance concerns + # rather than runtime-hardening posture. "category": category, "message": message, "path": path, diff --git a/cli/resources/module.schema.json b/cli/resources/module.schema.json index 1f03c0af..4e98a214 100644 --- a/cli/resources/module.schema.json +++ b/cli/resources/module.schema.json @@ -296,6 +296,30 @@ }, "serviceAccount": { "$ref": "#/$defs/kubernetesServiceAccount" + }, + "networkPolicy": { + "$ref": "#/$defs/kubernetesNetworkPolicy" + } + } + }, + "kubernetesNetworkPolicy": { + "type": "object", + "additionalProperties": false, + "description": "Custom allow rules merged verbatim into the module's generated NetworkPolicy (issue #75). The generated policy is default-deny for ingress and egress; these rules exist because contract bindings cannot name external endpoints (e.g. an external API a job reads from) or cross-namespace clients (e.g. an ingress controller fronting a ClusterIP service), which would otherwise be silently denied.", + "properties": { + "egress": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Additional NetworkPolicy egress rules (networking.k8s.io/v1 rule objects), rendered verbatim after the binding-derived rules. Scope each rule to the minimum: a specific ipBlock cidr or a namespace/pod selector peer, and a TCP port list; the security scan reports every external-egress rule as a low finding (CDS-K8S-008)." + }, + "ingress": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Additional NetworkPolicy ingress rules (networking.k8s.io/v1 rule objects), rendered verbatim after the binding-derived rules, e.g. to admit a cross-namespace ingress controller that no contract binding can name." } } }, diff --git a/docs/kubernetes.md b/docs/kubernetes.md index e76acecc..acdc8c4f 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -14,6 +14,7 @@ including k3s through k3d. - [Use the isolated k3d harness](#use-the-isolated-k3d-harness) - [Access local services](#access-local-services) - [Translation boundaries](#translation-boundaries) +- [Network isolation and least privilege](#network-isolation-and-least-privilege) - [Troubleshooting](#troubleshooting) ## Prerequisites @@ -180,7 +181,7 @@ guess at semantics that differ between runtimes. | Compose healthcheck | Readiness, liveness, and startup probes | | Host port binding | Not translated; use a Service or port-forward | | Compose network | Not translated; Kubernetes DNS and Services provide reachability | -| Network policy | Not emitted; the contract graph does not claim exhaustive traffic yet | +| Network policy | Emitted per module: default-deny ingress/egress with contract-derived allow rules (see [Network isolation](#network-isolation-and-least-privilege)) | | `pids_limit` | Not translated by the current schema | | Local `build:` | Build first, then import into k3d | @@ -188,6 +189,43 @@ Dagster's Compose-only Unix socket does not cross Pod boundaries. The Kubernetes implementation uses Dagster gRPC over a ClusterIP Service, as recorded in ADR 0002. +## Network isolation and least privilege + +Every module in a rendered chart ships a per-module ServiceAccount and a +NetworkPolicy (issue #75): + +- The NetworkPolicy is default-deny for ingress and egress. Allow rules are + derived from the plan's contract bindings: a binding from consumer to + provider opens egress on the consumer's pods and matching ingress on the + provider's pods, scoped to the binding's port. The contract's port is + Service-facing, so it is mapped through the provider's Service + `targetPort`: a `port: X, targetPort: Y` remap keeps the rule correct. A + binding without a numeric port falls back to the provider's advertised + service ports; with no port evidence at all the rule stays module-scoped + and the render reports `W128` asking for the port to be declared. +- Within a module, pods may talk freely on any port: a module is the unit + of composition and declares its own internal wiring (which is why the + Superset init job and the webserver share one policy). +- Egress to the cluster DNS service (kube-system `kube-dns`, UDP+TCP 53) is + the only other unconditional rule. +- NodePort and LoadBalancer services are intended entry points: ingress is + opened to all sources on exactly their exposed ports, and CDS-K8S-007 + flags that exposure. +- The ServiceAccount mounts no API token by default; RBAC is opt-in via + `implementation.kubernetes.serviceAccount.rbac` (malformed rules fail + closed with E148), and wildcard grants are flagged by CDS-K8S-006. + +### When the baseline denies something you need + +Bindings cannot name external endpoints or cross-namespace clients: a job +reading an external API or database, or an ingress controller fronting a +ClusterIP service, would be silently denied by the default-deny baseline. +Declare those rules via `implementation.kubernetes.networkPolicy.ingress`/ +`egress` -- they are rendered verbatim after the binding-derived rules, and +malformed shapes fail closed with E149. The security scan records every +external-egress rule as a low CDS-K8S-008 finding, so intentional openings +stay visible in `cds security --target helm`. + ## Troubleshooting - If Pods remain Pending, inspect node taints. Docker disk pressure requires diff --git a/tests/test_k8s_renderer.py b/tests/test_k8s_renderer.py index b02feace..3f6f0e1e 100644 --- a/tests/test_k8s_renderer.py +++ b/tests/test_k8s_renderer.py @@ -931,10 +931,10 @@ def test_malformed_rbac_rule_fails_closed(self) -> None: files, diagnostics = render_helm(plan) self.assertEqual(files, {}) - e147 = [d for d in diagnostics if d.code == "E147"] - self.assertEqual(len(e147), 1) + e148 = [d for d in diagnostics if d.code == "E148"] + self.assertEqual(len(e148), 1) self.assertEqual( - e147[0].path, + e148[0].path, "module:demo.implementation.kubernetes.serviceAccount.rbac[0]", ) @@ -948,10 +948,10 @@ def test_malformed_service_account_block_fails_closed(self) -> None: files, diagnostics = render_helm(plan) self.assertEqual(files, {}) - e147 = [d for d in diagnostics if d.code == "E147"] - self.assertEqual(len(e147), 1) + e148 = [d for d in diagnostics if d.code == "E148"] + self.assertEqual(len(e148), 1) self.assertEqual( - e147[0].path, + e148[0].path, "module:demo.implementation.kubernetes.serviceAccount", ) @@ -1016,3 +1016,102 @@ def test_binding_without_any_port_evidence_warns_and_stays_module_scoped(self) - }] }, ) + + def test_network_policy_passthrough_renders_verbatim(self) -> None: + # Bindings cannot name external endpoints or cross-namespace + # clients; the passthrough rules are appended after the derived + # rules so both can coexist. + plan = self.bound_plan() + plan["modules"][1]["implementation"]["kubernetes"]["networkPolicy"] = { + "egress": [{ + "to": [{"ipBlock": {"cidr": "10.8.0.0/16"}}], + "ports": [{"protocol": "TCP", "port": 443}], + }], + "ingress": [{ + "from": [{ + "namespaceSelector": { + "matchLabels": {"kubernetes.io/metadata.name": "ingress"} + } + }], + "ports": [{"protocol": "TCP", "port": 8080}], + }], + } + + files, _ = self.render(plan) + + consumer = self.policy(files, "consumer") + self.assertEqual( + consumer["spec"]["egress"][-1], + { + "to": [{"ipBlock": {"cidr": "10.8.0.0/16"}}], + "ports": [{"protocol": "TCP", "port": 443}], + }, + ) + self.assertEqual( + consumer["spec"]["ingress"][-1], + { + "from": [{ + "namespaceSelector": { + "matchLabels": {"kubernetes.io/metadata.name": "ingress"} + } + }], + "ports": [{"protocol": "TCP", "port": 8080}], + }, + ) + + def test_malformed_network_policy_block_fails_closed(self) -> None: + # A plan loaded from a file bypasses the module schema: a malformed + # passthrough block must fail closed as E149, not crash or render a + # broken NetworkPolicy. + plan = self.bound_plan() + plan["modules"][0]["implementation"]["kubernetes"]["networkPolicy"] = "allow-all" + + files, diagnostics = render_helm(plan) + + self.assertEqual(files, {}) + e149 = [d for d in diagnostics if d.code == "E149"] + self.assertEqual(len(e149), 1) + self.assertEqual( + e149[0].path, + "module:demo.implementation.kubernetes.networkPolicy", + ) + + def test_malformed_passthrough_rule_fails_closed(self) -> None: + plan = self.bound_plan() + plan["modules"][0]["implementation"]["kubernetes"]["networkPolicy"] = { + "egress": [{"to": []}, "anywhere"] + } + + files, diagnostics = render_helm(plan) + + self.assertEqual(files, {}) + e149 = [d for d in diagnostics if d.code == "E149"] + self.assertEqual(len(e149), 1) + self.assertEqual( + e149[0].path, + "module:demo.implementation.kubernetes.networkPolicy.egress", + ) + + def test_contract_port_maps_through_service_target_port(self) -> None: + # The contract's port is Service-facing while NetworkPolicy ports + # match the destination pod port: a `port: 80, targetPort: 8080` + # remap must generate rules for 8080, not 80. + plan = self.bound_plan() + plan["modules"][0]["implementation"]["kubernetes"]["workloads"]["app"]["service"] = { + "type": "ClusterIP", + "ports": [{"name": "http", "port": 80, "targetPort": 8080}], + } + plan["modules"][1]["consumes"]["backend"]["contract"]["spec"]["port"] = 80 + + files, _ = self.render(plan) + + consumer = self.policy(files, "consumer") + demo = self.policy(files, "demo") + self.assertEqual( + consumer["spec"]["egress"][2]["ports"], + [{"protocol": "TCP", "port": 8080}], + ) + self.assertEqual( + demo["spec"]["ingress"][1]["ports"], + [{"protocol": "TCP", "port": 8080}], + ) diff --git a/tests/test_k8s_security.py b/tests/test_k8s_security.py index 82acc432..5fff6863 100644 --- a/tests/test_k8s_security.py +++ b/tests/test_k8s_security.py @@ -235,6 +235,77 @@ def test_clusterip_service_is_not_flagged(self) -> None: self.assertEqual(scan_k8s_security(plan), []) + def test_unrestricted_custom_egress_is_flagged(self) -> None: + # The passthrough exists precisely to open what bindings cannot + # name; an unrestricted rule (no "to", or an ipBlock peer) re-opens + # external egress and is reported as CDS-K8S-008. + plan = self.secure_plan() + plan["modules"][0]["implementation"]["kubernetes"]["networkPolicy"] = { + "egress": [ + {"ports": [{"protocol": "TCP", "port": 443}]}, + {"to": [{"ipBlock": {"cidr": "0.0.0.0/0"}}]}, + ] + } + + findings = scan_k8s_security(plan) + flagged = [f for f in findings if f["rule_id"] == "CDS-K8S-008"] + + self.assertEqual(len(flagged), 2) + self.assertEqual( + {f["path"].rsplit(".", 1)[-1] for f in flagged}, + {"0", "1"}, + ) + self.assertEqual( + {f["category"] for f in flagged}, + {"configuration-management"}, + ) + + def test_selector_scoped_custom_egress_is_not_flagged(self) -> None: + # Selector-based peers stay cluster-internal: no external egress, + # no finding. + plan = self.secure_plan() + plan["modules"][0]["implementation"]["kubernetes"]["networkPolicy"] = { + "egress": [{ + "to": [{ + "namespaceSelector": { + "matchLabels": {"kubernetes.io/metadata.name": "team"} + } + }], + "ports": [{"protocol": "TCP", "port": 443}], + }], + "ingress": [{ + "from": [{"namespaceSelector": {"matchLabels": {"a": "b"}}}], + "ports": [{"protocol": "TCP", "port": 8080}], + }], + } + + self.assertEqual(scan_k8s_security(plan), []) + + def test_narrow_ipblock_custom_egress_is_still_recorded(self) -> None: + # Any ipBlock peer leaves the cluster, so even a tightly scoped rule + # is recorded as a low finding: the scan is the audit record of an + # intentionally opened external destination, and the recommendation + # is to narrow it, not to silence it. + plan = self.secure_plan() + plan["modules"][0]["implementation"]["kubernetes"]["networkPolicy"] = { + "egress": [{ + "to": [{"ipBlock": {"cidr": "10.8.0.0/16"}}], + "ports": [{"protocol": "TCP", "port": 443}], + }], + } + + findings = [ + f for f in scan_k8s_security(plan) if f["rule_id"] == "CDS-K8S-008" + ] + self.assertEqual(len(findings), 1) + self.assertEqual(findings[0]["severity"], "low") + + def test_malformed_network_policy_block_does_not_crash_the_scan(self) -> None: + plan = self.secure_plan() + plan["modules"][0]["implementation"]["kubernetes"]["networkPolicy"] = "allow-all" + + self.assertEqual(scan_k8s_security(plan), []) + if __name__ == "__main__": unittest.main() From bfa7c9d87710cc4fac12bc11acbe72d57e694ce4 Mon Sep 17 00:00:00 2001 From: Ronald Hensbergen Date: Fri, 30 Oct 2026 18:55:16 +0100 Subject: [PATCH 4/4] docs(changelog): mark the helm NetworkPolicy baseline as breaking The entry read as purely additive, but existing --target helm deployments pick up the default-deny baseline on their next render, silently cutting external and cross-namespace traffic until passthrough rules are declared. Marks the entry per the CHANGELOG's existing **Breaking:** convention and adds the migration pointer to docs/kubernetes.md. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- CHANGELOG.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d68a21..f5f4f181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,10 @@ The format is based on Keep a Changelog. ### Added -- Added NetworkPolicy and ServiceAccount generation to the `helm` render - target, completing the Kubernetes production resources (issue #75): - every module's chart now ships a default-deny NetworkPolicy whose allow +- **Breaking (for existing `--target helm` deployments):** Added + NetworkPolicy and ServiceAccount generation to the `helm` render target, + completing the Kubernetes production resources (issue #75): every + module's chart now ships a default-deny NetworkPolicy whose allow rules are derived from the plan's contract bindings (consumer-to- provider egress and matching provider ingress on the contract port, same-module traffic, and cluster DNS egress; NodePort/LoadBalancer @@ -31,7 +32,10 @@ The format is based on Keep a Changelog. CDS-K8S-008 (external-egress passthrough rules), and added a W128 warning when a binding carries no port evidence and its egress rule cannot be port-scoped; documented the network-isolation baseline in - `docs/kubernetes.md`. + `docs/kubernetes.md`. Charts rendered before this change will deny + external and cross-namespace traffic on their next render until the new + `networkPolicy` rules are declared (see the "When the baseline denies + something you need" section of `docs/kubernetes.md`). - Added a single-node Garage S3-compatible object-storage module and provider-neutral `object-storage` contract, with persistent data and metadata volumes and profile-secret-backed credentials. The module is