diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5c24e9..43e92877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,34 @@ The format is based on Keep a Changelog. ### Added +- **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 + 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 (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; documented the network-isolation baseline in + `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 `Kubernetes E2E` GitHub Actions workflow that runs the existing k3d harness (`make k3d-build` and `make k3d-e2e`) against the example profile on pull requests and pushes touching the chart, CLI, images, harness diff --git a/README.md b/README.md index b4ac26bf..cdec7f44 100644 --- a/README.md +++ b/README.md @@ -795,6 +795,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 0c74c814..fa6c660b 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 @@ -164,6 +171,26 @@ def render_helm( ) ) + # 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"] + ) + 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 @@ -179,6 +206,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"] @@ -198,6 +226,8 @@ def render_helm( ) _merge_init_db_env(compose_services, module, secrets) + module_surfaces[module_id] = _workload_surfaces(k8s, context) + ( module_values, module_manifests, @@ -224,6 +254,20 @@ 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, + network_policy_passthrough, + )) if secret_keys: manifests.append( @@ -404,6 +448,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 @@ -1248,6 +1293,431 @@ 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] = [] + 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="E148", + 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): + 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="E148", + 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 _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: + """ + 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, 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(): + 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") + if not isinstance(service, dict): + service = {} + 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 + + +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], + 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 + 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. + + 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]]] = {} + 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(): + 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 + 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) + + # 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", + { + "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. + + 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 + 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..28114395 100644 --- a/cli/k8s_security.py +++ b/cli/k8s_security.py @@ -78,6 +78,96 @@ 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 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 {} + 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 = [ + 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", + )) + 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 @@ -124,11 +214,14 @@ 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), 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 a0eff417..4e98a214 100644 --- a/cli/resources/module.schema.json +++ b/cli/resources/module.schema.json @@ -293,6 +293,97 @@ "$ref": "#/$defs/kubernetesConfigMap" }, "description": "Bind-mounted files promoted to ConfigMaps, keyed by the compose bind source path." + }, + "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." + } + } + }, + "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" + }, + "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/docs/kubernetes.md b/docs/kubernetes.md index 3bc51ced..d25cd465 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 @@ -187,7 +188,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 | @@ -195,6 +196,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/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..3f6f0e1e 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 @@ -682,3 +684,434 @@ 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, {}) + e148 = [d for d in diagnostics if d.code == "E148"] + self.assertEqual(len(e148), 1) + self.assertEqual( + e148[0].path, + "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, {}) + e148 = [d for d in diagnostics if d.code == "E148"] + self.assertEqual(len(e148), 1) + self.assertEqual( + e148[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"] + 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 }}", + } + } + }] + }, + ) + + 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 7da0a2be..5fff6863 100644 --- a/tests/test_k8s_security.py +++ b/tests/test_k8s_security.py @@ -150,6 +150,162 @@ 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_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"][ + "app" + ] + workload["service"] = { + "type": "ClusterIP", + "ports": [{"name": "http", "port": 8080}], + } + + 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()