From 2b318634b69261f737398feb1f78bc328604eb6c Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 22 Aug 2026 11:23:38 +0200 Subject: [PATCH] build: add guarded Kubernetes plane executor Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02878-7c21-7692-bcd6-ce2838c4b448 --- README.md | 10 + bundles/whitehat-foundational-plane.yaml | 61 ++ docs/kubernetes-plane.md | 48 ++ manifests/whitehat-plane/README.md | 31 + manifests/whitehat-plane/namespace.yaml | 9 + manifests/whitehat-plane/network-policy.yaml | 40 ++ manifests/whitehat-plane/service-account.yaml | 9 + plans/whitehat-foundational-plane.md | 87 +++ pyproject.toml | 3 + src/ops_mason/cli.py | 67 ++ src/ops_mason/kubernetes_plane.py | 606 ++++++++++++++++++ tests/test_kubernetes_plane.py | 258 ++++++++ ...SON-WP-0002-whitehat-foundational-plane.md | 14 +- 13 files changed, 1240 insertions(+), 3 deletions(-) create mode 100644 bundles/whitehat-foundational-plane.yaml create mode 100644 docs/kubernetes-plane.md create mode 100644 manifests/whitehat-plane/README.md create mode 100644 manifests/whitehat-plane/namespace.yaml create mode 100644 manifests/whitehat-plane/network-policy.yaml create mode 100644 manifests/whitehat-plane/service-account.yaml create mode 100644 plans/whitehat-foundational-plane.md create mode 100644 src/ops_mason/cli.py create mode 100644 src/ops_mason/kubernetes_plane.py create mode 100644 tests/test_kubernetes_plane.py diff --git a/README.md b/README.md index bcf8189..c03d6f4 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,13 @@ so that ops-warden always has something real to route to. See `INTENT.md` for the full responsibility boundary against ops-warden, OpenBao, flex-auth, and key-cape. + +## Guarded Kubernetes planes + +Small Kubernetes security foundations can use the fail-closed `ops-mason +plane` workflow. It pins source manifests and object identities, validates +cluster context/RBAC/dependencies/drift, requires an approved construction +plan plus exact digest for apply, and produces metadata-only evidence and a +non-executing rollback plan. + +See [docs/kubernetes-plane.md](docs/kubernetes-plane.md). diff --git a/bundles/whitehat-foundational-plane.yaml b/bundles/whitehat-foundational-plane.yaml new file mode 100644 index 0000000..c17ca6d --- /dev/null +++ b/bundles/whitehat-foundational-plane.yaml @@ -0,0 +1,61 @@ +schema_version: ops-mason.kubernetes-plane/v1 +id: whitehat-foundational-plane +plan: ../plans/whitehat-foundational-plane.md +expected_context: default +expected_namespace: whitehat +source_repo: whitehat-security +source_revision: 4882c2d47a826a305d5c4e05aa7fcc1252c3887e +implementation_revision: 95129d7a35c8999be359b163b554853c9c6afa3a +evidence_path: ../docs/evidence/whitehat-foundational-plane.json +forbidden_kinds: + - Pod + - Secret +manifests: + - path: ../manifests/whitehat-plane/namespace.yaml + source_path: plane/namespace.yaml + sha256: a0919aa0c3d5f92844eeea3834fc684bc6ad112a3c4419be7f759f6b31c1d7a8 + - path: ../manifests/whitehat-plane/network-policy.yaml + source_path: plane/network-policy.yaml + sha256: 05097e33221f93f4d82da7c4d5bb688c5ec5807dc15ab4378263cddad6031610 + - path: ../manifests/whitehat-plane/service-account.yaml + source_path: plane/service-account.yaml + sha256: 42cb5276ea2f0a555ea835fed5c14397fe76fbfa18b1acec1f3168bb1db54a5d +allowed_objects: + - api_version: v1 + kind: Namespace + resource: namespaces + name: whitehat + - api_version: networking.k8s.io/v1 + kind: NetworkPolicy + resource: networkpolicies.networking.k8s.io + namespace: whitehat + name: default-deny + - api_version: networking.k8s.io/v1 + kind: NetworkPolicy + resource: networkpolicies.networking.k8s.io + namespace: whitehat + name: allow-audit-core-e2 + - api_version: v1 + kind: ServiceAccount + resource: serviceaccounts + namespace: whitehat + name: whitehat-runner +dependencies: + - resource: services + namespace: audit-core + name: audit-core + assertions: + - path: /spec/ports/0/port + equals: 8080 + - resource: networkpolicies.networking.k8s.io + namespace: audit-core + name: audit-core-whitehat-ingress + assertions: + - path: /spec/ingress/0/ports/0/port + equals: 8080 + - path: /spec/ingress/0/from/0/namespaceSelector/matchLabels/kubernetes.io~1metadata.name + equals: whitehat + - path: /spec/ingress/0/from/0/podSelector/matchLabels/whitehat.security~1plane + equals: "true" + - path: /spec/ingress/0/from/0/podSelector/matchLabels/whitehat.security~1target + equals: audit-core diff --git a/docs/kubernetes-plane.md b/docs/kubernetes-plane.md new file mode 100644 index 0000000..d31628d --- /dev/null +++ b/docs/kubernetes-plane.md @@ -0,0 +1,48 @@ +# Guarded Kubernetes plane automation + +`ops-mason plane` constructs small security foundations from an immutable, +reviewed bundle. It is not a general Kubernetes deployment wrapper. + +## Safety model + +Every bundle declares its exact object identities, source revisions, expected +cluster context, namespace, dependency assertions, forbidden kinds, manifest +hashes, approved construction plan, and evidence destination. + +Read-only commands (`render`, `preflight`, `verify`, `rollback-plan`) do not +require approval. `apply` refuses unless all of these hold: + +1. the construction plan is `status: approved` and names its approver/date; +2. `--confirm` exactly matches the plan ID; +3. `--expect-digest` exactly matches the freshly rendered bundle digest; +4. the repository is committed and clean; +5. the Kubernetes context, create RBAC, live-object drift, dependency + assertions, client validation, and available server validation pass; +6. the bundle contains exactly the allowlisted objects, no Pod or Secret, no + `data`/`stringData`, no cross-namespace object, and no token-bearing + ServiceAccount. + +The executor applies one pinned manifest at a time with server-side apply and +field manager `ops-mason`. Namespaced manifests receive server dry-run after +the Namespace exists and before they are persisted. A failed partial apply is +reported; the executor does not automatically delete a Namespace. + +Successful apply immediately performs metadata-only verification, checks that +no Pod or Secret exists in the plane namespace, and writes JSON evidence with +object UIDs/resource versions and generated rollback commands. It never reads +or records a Secret value. + +## Commands + +```bash +ops-mason plane render --bundle bundles/.yaml +ops-mason plane preflight --bundle bundles/.yaml +ops-mason plane apply --bundle bundles/.yaml \ + --confirm \ + --expect-digest +ops-mason plane verify --bundle bundles/.yaml +ops-mason plane rollback-plan --bundle bundles/.yaml +``` + +Rollback output is a plan, never an action. Review live inventory immediately +before using it. diff --git a/manifests/whitehat-plane/README.md b/manifests/whitehat-plane/README.md new file mode 100644 index 0000000..0ce792c --- /dev/null +++ b/manifests/whitehat-plane/README.md @@ -0,0 +1,31 @@ +# Whitehat foundational plane source pins + +These three YAML files are byte-for-byte pins of the selected +`whitehat-security` contract files at coordination revision +`4882c2d47a826a305d5c4e05aa7fcc1252c3887e`; the implementation revision is +`95129d7a35c8999be359b163b554853c9c6afa3a`. + +The preserved `CONTRACT ONLY` header means the source repository does not own +or perform the apply. In this repository, only the guarded bundle executor may +apply them, after validating `bundles/whitehat-foundational-plane.yaml` and the +approved construction plan. Do not invoke `kubectl apply` directly on this +directory. + +Pinned SHA-256 digests: + +- `namespace.yaml`: `a0919aa0c3d5f92844eeea3834fc684bc6ad112a3c4419be7f759f6b31c1d7a8` +- `network-policy.yaml`: `05097e33221f93f4d82da7c4d5bb688c5ec5807dc15ab4378263cddad6031610` +- `service-account.yaml`: `42cb5276ea2f0a555ea835fed5c14397fe76fbfa18b1acec1f3168bb1db54a5d` + +Excluded unconditionally: runner Pod, Secret, credential, projected identity, +engagement lease, and traffic. + +Rollback is generated by: + +```bash +ops-mason plane rollback-plan \ + --bundle bundles/whitehat-foundational-plane.yaml +``` + +It never executes deletion. Namespace deletion remains conditional on a fresh +inventory proving that no later or unrelated object entered the namespace. diff --git a/manifests/whitehat-plane/namespace.yaml b/manifests/whitehat-plane/namespace.yaml new file mode 100644 index 0000000..4172df7 --- /dev/null +++ b/manifests/whitehat-plane/namespace.yaml @@ -0,0 +1,9 @@ +# CONTRACT ONLY. Do not apply from this repository. +apiVersion: v1 +kind: Namespace +metadata: + name: whitehat + labels: + app.kubernetes.io/name: whitehat-security + whitehat.security/plane: "true" + pod-security.kubernetes.io/enforce: restricted diff --git a/manifests/whitehat-plane/network-policy.yaml b/manifests/whitehat-plane/network-policy.yaml new file mode 100644 index 0000000..a48d3df --- /dev/null +++ b/manifests/whitehat-plane/network-policy.yaml @@ -0,0 +1,40 @@ +# CONTRACT ONLY. Do not apply from this repository. +# Default deny in namespace whitehat; live E2 adds a named egress rule per +# registered target. This example names audit-core only as the currently +# applicable live E2 target. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: whitehat +spec: + podSelector: {} + policyTypes: ["Ingress", "Egress"] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-audit-core-e2 + namespace: whitehat +spec: + podSelector: + matchLabels: + whitehat.security/target: audit-core + policyTypes: ["Egress"] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: audit-core + ports: + - protocol: TCP + port: 8080 + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 diff --git a/manifests/whitehat-plane/service-account.yaml b/manifests/whitehat-plane/service-account.yaml new file mode 100644 index 0000000..ef0a3b0 --- /dev/null +++ b/manifests/whitehat-plane/service-account.yaml @@ -0,0 +1,9 @@ +# CONTRACT ONLY. Do not apply from this repository. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: whitehat-runner + namespace: whitehat + labels: + app.kubernetes.io/name: whitehat-security +automountServiceAccountToken: false diff --git a/plans/whitehat-foundational-plane.md b/plans/whitehat-foundational-plane.md new file mode 100644 index 0000000..fba600a --- /dev/null +++ b/plans/whitehat-foundational-plane.md @@ -0,0 +1,87 @@ +--- +id: whitehat-foundational-plane +demand_source: "statehub-message:311a274e-9434-4fc3-bc52-8ea15ca68274" +consumer_repo: whitehat-security +credential_type: kubernetes-foundational-plane +status: approved +approved_by: "Bernd Worsch" +approved_at: "2026-08-22" +created: "2026-08-22" +updated: "2026-08-22" +--- + +# Construction plan: Whitehat foundational plane + +## 1. Demand + +Provision the non-running Kubernetes foundation requested by +`whitehat-security` and confirmed by `audit-core`: Namespace `whitehat`, a +default deny boundary, exact audit-core/DNS egress for future labelled runner +Pods, and a no-token `whitehat-runner` ServiceAccount. This is foundation for a +later governed engagement, not an engagement or packet itself. + +## 2. Existing-structure survey + +Read-only inspection on 2026-08-22 found no `whitehat` Namespace. The active +Kubernetes identity can create all four object types. Audit-core Service +`audit-core` listens on TCP 8080 and its `audit-core-whitehat-ingress` policy +already admits only Namespace `whitehat` Pods labelled both +`whitehat.security/plane=true` and `whitehat.security/target=audit-core`. + +The selected Whitehat manifests entered at implementation revision `95129d7` +and are unchanged at requested coordination revision `4882c2d`. Reuse those +objects exactly; do not design a parallel policy shape. + +## 3. Proposed changes + +| # | Action | Object | Reason | +|---|---|---|---| +| 1 | create | Namespace `whitehat` | Dedicated restricted security boundary | +| 2 | create | NetworkPolicy `default-deny` | Deny ingress and egress by default | +| 3 | create | NetworkPolicy `allow-audit-core-e2` | Exact future-runner egress to audit-core:8080 and DNS only | +| 4 | create | ServiceAccount `whitehat-runner` | Stable name with token automount disabled | + +No Pod, Secret, credential, projected identity, custody lease, engagement ID, +or target traffic is created. The two 2026-08-21 engagement IDs remain +cancelled and are never reused. + +## 4. Review notes + +- **Reuse:** source and target policies already agree; no redundant policy is + introduced. +- **Scope:** the egress allow selects only future Pods labelled for audit-core. +- **Identity:** the ServiceAccount cannot receive an automatic API token. +- **Reversal:** delete the two policies and ServiceAccount; delete the + Namespace only after proving it remains foundation-only. +- **Automation:** apply is gated by this approval, an immutable bundle digest, + exact object allowlist, committed inputs, current context, RBAC, dependency + assertions, server dry-run, and metadata-only verification. + +## 5. Executive summary + +**One-line ask:** create a dormant, restricted Whitehat namespace boundary that +can later host a separately approved audit-core E2 runner. + +**Who/what gets access:** no running workload gets access now. A future Pod must +carry the exact audit-core target label before the egress allow selects it. + +**To what:** TCP 8080 in Namespace `audit-core`, plus TCP/UDP 53 in +`kube-system`. All other ingress and egress remains denied. + +**For how long:** the foundation is standing infrastructure. Any future +credential or runner remains separately time-bounded by its engagement. + +**Blast radius:** a wrongly labelled future Pod could reach audit-core:8080 and +DNS, but no other namespace/port through these policies. This plan creates no +credential and sends no request. + +**Cost to reverse:** delete three namespaced objects, inventory the Namespace, +then delete it only if it contains no later or unrelated resources. + +**Decision:** approved by Bernd Worsch on 2026-08-22 via the explicit operator +instruction to implement MASON-WP-0002. Approval is limited to the four objects +above and does not extend to a runner, credential, engagement, or traffic. + +## 6. Build result + +Pending guarded apply and metadata-only verification. diff --git a/pyproject.toml b/pyproject.toml index c1d805a..f028b0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,9 @@ dependencies = [ "PyYAML>=6.0", ] +[project.scripts] +ops-mason = "ops_mason.cli:main" + [project.optional-dependencies] dev = ["pytest>=8"] diff --git a/src/ops_mason/cli.py b/src/ops_mason/cli.py new file mode 100644 index 0000000..b7257fb --- /dev/null +++ b/src/ops_mason/cli.py @@ -0,0 +1,67 @@ +"""Command-line surface for guarded ops-mason builders.""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Sequence + +from ops_mason.kubernetes_plane import ( + PlaneBundle, + PlaneError, + apply, + preflight, + rollback_plan, + verify, +) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="ops-mason") + families = parser.add_subparsers(dest="family", required=True) + plane = families.add_parser("plane", help="guarded Kubernetes security plane") + commands = plane.add_subparsers(dest="command", required=True) + + for name in ("render", "preflight", "verify", "rollback-plan"): + command = commands.add_parser(name) + command.add_argument("--bundle", required=True) + + apply_parser = commands.add_parser("apply") + apply_parser.add_argument("--bundle", required=True) + apply_parser.add_argument("--confirm", required=True, help="exact approved plan id") + apply_parser.add_argument( + "--expect-digest", required=True, help="exact digest returned by preflight" + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + bundle = PlaneBundle.load(args.bundle) + if args.command == "render": + result = bundle.render() + elif args.command == "preflight": + result = preflight(bundle) + elif args.command == "verify": + result = verify(bundle) + elif args.command == "rollback-plan": + result = rollback_plan(bundle) + elif args.command == "apply": + result = apply( + bundle, + confirm_plan_id=args.confirm, + expected_digest=args.expect_digest, + ) + else: # pragma: no cover - argparse enforces the command set + raise AssertionError(args.command) + except (OSError, PlaneError) as exc: + print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr) + return 2 + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/ops_mason/kubernetes_plane.py b/src/ops_mason/kubernetes_plane.py new file mode 100644 index 0000000..0a6c8b0 --- /dev/null +++ b/src/ops_mason/kubernetes_plane.py @@ -0,0 +1,606 @@ +"""Guarded construction of small, source-pinned Kubernetes security planes. + +The executor is deliberately narrower than a generic ``kubectl apply`` wrapper. +It accepts an allowlisted bundle, refuses unexpected or secret-bearing objects, +requires an approved construction plan and an exact bundle digest for writes, +and records metadata-only evidence. +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from collections.abc import Callable, Mapping, Sequence +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import yaml + +from ops_mason.plan import ConstructionPlan + + +class PlaneError(RuntimeError): + """The bundle or live plane is invalid.""" + + +class PlaneRefused(PlaneError): + """A safety or approval gate refused a mutating action.""" + + +@dataclass(frozen=True) +class CommandResult: + returncode: int + stdout: str = "" + stderr: str = "" + + +Runner = Callable[[Sequence[str]], CommandResult] + + +def subprocess_runner(args: Sequence[str]) -> CommandResult: + proc = subprocess.run( + list(args), capture_output=True, text=True, timeout=30, check=False + ) + return CommandResult(proc.returncode, proc.stdout, proc.stderr) + + +@dataclass(frozen=True, order=True) +class ObjectRef: + api_version: str + kind: str + name: str + namespace: str | None + resource: str + + @property + def display(self) -> str: + scope = f"{self.namespace}/" if self.namespace else "" + return f"{self.kind}/{scope}{self.name}" + + +@dataclass(frozen=True) +class ManifestEntry: + path: Path + source_path: str + sha256: str + + +@dataclass(frozen=True) +class Dependency: + resource: str + name: str + namespace: str | None + assertions: tuple[tuple[str, Any], ...] + + +@dataclass +class PlaneBundle: + id: str + path: Path + repo_root: Path + plan_path: Path + expected_context: str + expected_namespace: str + source_repo: str + source_revision: str + implementation_revision: str + manifests: tuple[ManifestEntry, ...] + allowed_objects: tuple[ObjectRef, ...] + forbidden_kinds: frozenset[str] + dependencies: tuple[Dependency, ...] + evidence_path: Path + documents: tuple[dict[str, Any], ...] + + @classmethod + def load(cls, path: str | Path) -> "PlaneBundle": + bundle_path = Path(path).resolve() + try: + raw = yaml.safe_load(bundle_path.read_text()) or {} + except (OSError, yaml.YAMLError) as exc: + raise PlaneError(f"cannot load bundle {bundle_path}: {exc}") from exc + if not isinstance(raw, Mapping): + raise PlaneError("bundle root must be a mapping") + + required = { + "id", + "plan", + "expected_context", + "expected_namespace", + "source_repo", + "source_revision", + "implementation_revision", + "manifests", + "allowed_objects", + "evidence_path", + } + missing = required - set(raw) + if missing: + raise PlaneError(f"bundle missing field(s): {', '.join(sorted(missing))}") + + repo_root = bundle_path.parent.parent.resolve() + + def local_path(value: str) -> Path: + candidate = (bundle_path.parent / value).resolve() + if not candidate.is_relative_to(repo_root): + raise PlaneRefused(f"bundle path escapes repository: {value}") + return candidate + + manifests: list[ManifestEntry] = [] + documents: list[dict[str, Any]] = [] + for item in raw["manifests"]: + if not isinstance(item, Mapping) or not {"path", "source_path", "sha256"} <= set(item): + raise PlaneError("each manifest needs path, source_path, and sha256") + manifest_path = local_path(str(item["path"])) + content = manifest_path.read_bytes() + actual_hash = hashlib.sha256(content).hexdigest() + expected_hash = str(item["sha256"]) + if actual_hash != expected_hash: + raise PlaneRefused( + f"manifest digest mismatch for {manifest_path}: " + f"expected {expected_hash}, got {actual_hash}" + ) + manifests.append( + ManifestEntry(manifest_path, str(item["source_path"]), expected_hash) + ) + try: + loaded = list(yaml.safe_load_all(content.decode())) + except (UnicodeDecodeError, yaml.YAMLError) as exc: + raise PlaneError(f"invalid YAML in {manifest_path}: {exc}") from exc + for doc in loaded: + if not isinstance(doc, dict): + raise PlaneError(f"{manifest_path}: every YAML document must be an object") + documents.append(doc) + + allowed = tuple(_parse_ref(item) for item in raw["allowed_objects"]) + dependencies = tuple(_parse_dependency(item) for item in raw.get("dependencies", [])) + bundle = cls( + id=str(raw["id"]), + path=bundle_path, + repo_root=repo_root, + plan_path=local_path(str(raw["plan"])), + expected_context=str(raw["expected_context"]), + expected_namespace=str(raw["expected_namespace"]), + source_repo=str(raw["source_repo"]), + source_revision=str(raw["source_revision"]), + implementation_revision=str(raw["implementation_revision"]), + manifests=tuple(manifests), + allowed_objects=allowed, + forbidden_kinds=frozenset(str(x) for x in raw.get("forbidden_kinds", ["Pod", "Secret"])), + dependencies=dependencies, + evidence_path=local_path(str(raw["evidence_path"])), + documents=tuple(documents), + ) + bundle.validate() + return bundle + + @property + def digest(self) -> str: + digest = hashlib.sha256() + digest.update(self.path.read_bytes()) + for manifest in self.manifests: + digest.update(manifest.path.read_bytes()) + return digest.hexdigest() + + def validate(self) -> None: + actual_refs = tuple(_document_ref(doc, self.allowed_objects) for doc in self.documents) + if len(set(actual_refs)) != len(actual_refs): + raise PlaneRefused("bundle contains duplicate Kubernetes object identities") + if set(actual_refs) != set(self.allowed_objects): + unexpected = sorted(set(actual_refs) - set(self.allowed_objects)) + missing = sorted(set(self.allowed_objects) - set(actual_refs)) + raise PlaneRefused( + "object allowlist mismatch: " + f"unexpected={[x.display for x in unexpected]}, " + f"missing={[x.display for x in missing]}" + ) + for doc, ref in zip(self.documents, actual_refs, strict=True): + if ref.kind in self.forbidden_kinds: + raise PlaneRefused(f"forbidden Kubernetes kind: {ref.kind}") + forbidden_keys = sorted(_find_keys(doc, {"data", "stringData"})) + if forbidden_keys: + raise PlaneRefused( + f"secret-bearing key(s) forbidden in {ref.display}: {forbidden_keys}" + ) + if ref.namespace and ref.namespace != self.expected_namespace: + raise PlaneRefused( + f"{ref.display} escapes expected namespace {self.expected_namespace}" + ) + if ref.kind == "ServiceAccount": + if doc.get("automountServiceAccountToken") is not False: + raise PlaneRefused( + f"{ref.display} must set automountServiceAccountToken: false" + ) + if doc.get("secrets") or doc.get("imagePullSecrets"): + raise PlaneRefused(f"{ref.display} must not reference credentials") + if ref.kind == "Namespace" and ref.name == self.expected_namespace: + labels = doc.get("metadata", {}).get("labels", {}) + if labels.get("pod-security.kubernetes.io/enforce") != "restricted": + raise PlaneRefused( + f"Namespace/{ref.name} must enforce restricted Pod Security" + ) + + def plan(self) -> ConstructionPlan: + return ConstructionPlan.load(self.plan_path) + + def render(self) -> dict[str, Any]: + return { + "bundle_id": self.id, + "bundle_digest": self.digest, + "source_repo": self.source_repo, + "source_revision": self.source_revision, + "implementation_revision": self.implementation_revision, + "expected_context": self.expected_context, + "objects": [asdict(ref) | {"display": ref.display} for ref in self.allowed_objects], + "forbidden_kinds": sorted(self.forbidden_kinds), + "plan_id": self.plan().id, + "plan_approved": self.plan().is_approved(), + } + + +def _parse_ref(item: Mapping[str, Any]) -> ObjectRef: + required = {"api_version", "kind", "name", "resource"} + if not isinstance(item, Mapping) or not required <= set(item): + raise PlaneError("allowed object needs api_version, kind, name, and resource") + return ObjectRef( + str(item["api_version"]), + str(item["kind"]), + str(item["name"]), + str(item["namespace"]) if item.get("namespace") else None, + str(item["resource"]), + ) + + +def _parse_dependency(item: Mapping[str, Any]) -> Dependency: + if not isinstance(item, Mapping) or not {"resource", "name"} <= set(item): + raise PlaneError("dependency needs resource and name") + assertions: list[tuple[str, Any]] = [] + for assertion in item.get("assertions", []): + if not isinstance(assertion, Mapping) or not {"path", "equals"} <= set(assertion): + raise PlaneError("dependency assertion needs path and equals") + assertions.append((str(assertion["path"]), assertion["equals"])) + return Dependency( + resource=str(item["resource"]), + name=str(item["name"]), + namespace=str(item["namespace"]) if item.get("namespace") else None, + assertions=tuple(assertions), + ) + + +def _document_ref(doc: Mapping[str, Any], allowed: Sequence[ObjectRef]) -> ObjectRef: + metadata = doc.get("metadata") + if not isinstance(metadata, Mapping) or not metadata.get("name"): + raise PlaneError("Kubernetes object missing metadata.name") + identity = ( + str(doc.get("apiVersion", "")), + str(doc.get("kind", "")), + str(metadata["name"]), + str(metadata["namespace"]) if metadata.get("namespace") else None, + ) + matches = [ref for ref in allowed if identity == (ref.api_version, ref.kind, ref.name, ref.namespace)] + if len(matches) == 1: + return matches[0] + resource = f"unmapped:{identity[1].lower()}" + return ObjectRef(*identity, resource) + + +def _find_keys(value: Any, forbidden: set[str], prefix: str = "") -> list[str]: + found: list[str] = [] + if isinstance(value, Mapping): + for key, child in value.items(): + path = f"{prefix}.{key}" if prefix else str(key) + if key in forbidden: + found.append(path) + found.extend(_find_keys(child, forbidden, path)) + elif isinstance(value, list): + for index, child in enumerate(value): + found.extend(_find_keys(child, forbidden, f"{prefix}.{index}")) + return found + + +def _run(runner: Runner, args: Sequence[str], *, allow_not_found: bool = False) -> CommandResult: + result = runner(args) + if result.returncode == 0: + return result + if allow_not_found and "notfound" in result.stderr.lower().replace(" ", ""): + return result + command = " ".join(args) + raise PlaneError(f"`{command}` failed: {result.stderr.strip()[:400]}") + + +def _kubectl_get_args(resource: str, name: str, namespace: str | None) -> list[str]: + args = ["kubectl"] + if namespace: + args += ["-n", namespace] + return args + ["get", resource, name, "-o", "json"] + + +def _json_path(value: Any, path: str) -> Any: + current = value + if path.startswith("/"): + tokens = [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")] + else: + tokens = path.split(".") + for token in tokens: + if isinstance(current, list): + try: + current = current[int(token)] + except (ValueError, IndexError) as exc: + raise PlaneError(f"invalid dependency assertion path: {path}") from exc + elif isinstance(current, Mapping) and token in current: + current = current[token] + else: + raise PlaneError(f"dependency assertion path not found: {path}") + return current + + +def _is_subset(desired: Any, live: Any) -> bool: + if isinstance(desired, Mapping): + return isinstance(live, Mapping) and all( + key in live and _is_subset(value, live[key]) for key, value in desired.items() + ) + if isinstance(desired, list): + return isinstance(live, list) and len(desired) == len(live) and all( + _is_subset(left, right) for left, right in zip(desired, live, strict=True) + ) + return desired == live + + +def _desired_by_ref(bundle: PlaneBundle) -> dict[ObjectRef, dict[str, Any]]: + return { + _document_ref(doc, bundle.allowed_objects): doc for doc in bundle.documents + } + + +def _check_inputs_clean(bundle: PlaneBundle, runner: Runner) -> None: + result = _run( + runner, + ["git", "-C", str(bundle.repo_root), "status", "--porcelain"], + ) + if result.stdout.strip(): + raise PlaneRefused("repository must be committed and clean before Kubernetes mutation") + + +def preflight(bundle: PlaneBundle, runner: Runner = subprocess_runner) -> dict[str, Any]: + context = _run(runner, ["kubectl", "config", "current-context"]).stdout.strip() + if context != bundle.expected_context: + raise PlaneRefused( + f"Kubernetes context mismatch: expected {bundle.expected_context!r}, got {context!r}" + ) + + for ref in bundle.allowed_objects: + args = ["kubectl", "auth", "can-i", "create", ref.resource] + if ref.namespace: + args += ["-n", ref.namespace] + allowed = _run(runner, args).stdout.strip().lower() + if allowed != "yes": + raise PlaneRefused(f"create permission denied for {ref.display}") + + for manifest in bundle.manifests: + _run(runner, ["kubectl", "apply", "--dry-run=client", "-f", str(manifest.path)]) + + desired = _desired_by_ref(bundle) + live_state: list[dict[str, str]] = [] + namespace_exists = False + for ref in bundle.allowed_objects: + result = _run( + runner, + _kubectl_get_args(ref.resource, ref.name, ref.namespace), + allow_not_found=True, + ) + if result.returncode != 0: + state = "absent" + else: + live = json.loads(result.stdout) + state = "exact" if _is_subset(desired[ref], live) else "drift" + if state == "drift": + raise PlaneRefused(f"unmanaged live drift for {ref.display}") + if ref.kind == "Namespace" and ref.name == bundle.expected_namespace: + namespace_exists = True + live_state.append({"object": ref.display, "state": state}) + + server_validated: list[str] = [] + for manifest in bundle.manifests: + docs = list(yaml.safe_load_all(manifest.path.read_text())) + namespaced = any(doc.get("metadata", {}).get("namespace") for doc in docs) + if namespaced and not namespace_exists: + continue + _run( + runner, + [ + "kubectl", + "apply", + "--server-side", + "--dry-run=server", + "--field-manager=ops-mason", + "-f", + str(manifest.path), + ], + ) + server_validated.append(str(manifest.path.relative_to(bundle.repo_root))) + + dependency_evidence: list[dict[str, Any]] = [] + for dependency in bundle.dependencies: + result = _run( + runner, + _kubectl_get_args(dependency.resource, dependency.name, dependency.namespace), + ) + live = json.loads(result.stdout) + checked: dict[str, Any] = {} + for path, expected in dependency.assertions: + actual = _json_path(live, path) + if actual != expected: + raise PlaneRefused( + f"dependency {dependency.resource}/{dependency.name} {path}: " + f"expected {expected!r}, got {actual!r}" + ) + checked[path] = actual + dependency_evidence.append( + { + "resource": dependency.resource, + "namespace": dependency.namespace, + "name": dependency.name, + "assertions": checked, + } + ) + + return { + "ok": True, + "bundle_id": bundle.id, + "bundle_digest": bundle.digest, + "context": context, + "plan_id": bundle.plan().id, + "plan_approved": bundle.plan().is_approved(), + "objects": live_state, + "client_validated_manifests": [ + str(item.path.relative_to(bundle.repo_root)) for item in bundle.manifests + ], + "server_validated_manifests": server_validated, + "deferred_server_validation": [ + str(item.path.relative_to(bundle.repo_root)) + for item in bundle.manifests + if str(item.path.relative_to(bundle.repo_root)) not in server_validated + ], + "dependencies": dependency_evidence, + } + + +def verify(bundle: PlaneBundle, runner: Runner = subprocess_runner) -> dict[str, Any]: + desired = _desired_by_ref(bundle) + objects: list[dict[str, Any]] = [] + for ref in bundle.allowed_objects: + result = _run(runner, _kubectl_get_args(ref.resource, ref.name, ref.namespace)) + live = json.loads(result.stdout) + if not _is_subset(desired[ref], live): + raise PlaneError(f"live object does not match bundle: {ref.display}") + metadata = live.get("metadata", {}) + objects.append( + { + "object": ref.display, + "uid": metadata.get("uid"), + "resource_version": metadata.get("resourceVersion"), + "generation": metadata.get("generation"), + } + ) + + negative_scope: dict[str, int] = {} + for resource in ("pods", "secrets"): + result = _run( + runner, + ["kubectl", "-n", bundle.expected_namespace, "get", resource, "-o", "json"], + ) + count = len(json.loads(result.stdout).get("items", [])) + if count: + raise PlaneError( + f"negative-scope check failed: {count} {resource} exist in " + f"{bundle.expected_namespace}" + ) + negative_scope[resource] = count + + return { + "ok": True, + "bundle_id": bundle.id, + "bundle_digest": bundle.digest, + "context": bundle.expected_context, + "objects": objects, + "negative_scope": negative_scope, + } + + +def rollback_plan(bundle: PlaneBundle) -> dict[str, Any]: + namespaced = [ref for ref in bundle.allowed_objects if ref.namespace] + namespaces = [ref for ref in bundle.allowed_objects if ref.kind == "Namespace"] + commands = [ + " ".join( + ["kubectl", "-n", ref.namespace or "", "delete", ref.resource, ref.name] + ) + for ref in namespaced + ] + namespace_commands = [ + f"kubectl delete {ref.resource} {ref.name}" for ref in namespaces + ] + return { + "bundle_id": bundle.id, + "object_scoped_commands": commands, + "inventory_before_namespace_delete": [ + f"kubectl -n {bundle.expected_namespace} get all,configmaps,secrets,serviceaccounts,networkpolicies" + ], + "conditional_namespace_commands": namespace_commands, + "warning": "Delete the namespace only after proving it contains no later or unrelated objects.", + } + + +def apply( + bundle: PlaneBundle, + *, + confirm_plan_id: str, + expected_digest: str, + runner: Runner = subprocess_runner, +) -> dict[str, Any]: + plan = bundle.plan() + if not plan.is_approved(): + raise PlaneRefused( + f"plan {plan.id!r} is not approved; refusing Kubernetes mutation" + ) + if confirm_plan_id != plan.id: + raise PlaneRefused( + f"confirmation must exactly match approved plan id {plan.id!r}" + ) + if expected_digest != bundle.digest: + raise PlaneRefused( + f"bundle digest confirmation mismatch: expected {bundle.digest}" + ) + _check_inputs_clean(bundle, runner) + before = preflight(bundle, runner) + + for manifest in bundle.manifests: + docs = list(yaml.safe_load_all(manifest.path.read_text())) + namespaced = any(doc.get("metadata", {}).get("namespace") for doc in docs) + if namespaced: + _run( + runner, + [ + "kubectl", + "apply", + "--server-side", + "--dry-run=server", + "--field-manager=ops-mason", + "-f", + str(manifest.path), + ], + ) + _run( + runner, + [ + "kubectl", + "apply", + "--server-side", + "--field-manager=ops-mason", + "-f", + str(manifest.path), + ], + ) + + verified = verify(bundle, runner) + evidence = { + "schema_version": "ops-mason.kubernetes-plane-evidence/v1", + "recorded_at": datetime.now(UTC).isoformat(), + "plan": { + "id": plan.id, + "approved_by": plan.approved_by, + "approved_at": plan.approved_at, + }, + "source": { + "repo": bundle.source_repo, + "revision": bundle.source_revision, + "implementation_revision": bundle.implementation_revision, + }, + "preflight": before, + "verification": verified, + "rollback": rollback_plan(bundle), + } + bundle.evidence_path.parent.mkdir(parents=True, exist_ok=True) + bundle.evidence_path.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n") + return evidence diff --git a/tests/test_kubernetes_plane.py b/tests/test_kubernetes_plane.py new file mode 100644 index 0000000..9fb2dea --- /dev/null +++ b/tests/test_kubernetes_plane.py @@ -0,0 +1,258 @@ +import hashlib +import json +from pathlib import Path + +import pytest +import yaml + +from ops_mason.kubernetes_plane import ( + CommandResult, + PlaneBundle, + PlaneRefused, + _json_path, + apply, + preflight, + rollback_plan, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_whitehat_bundle_is_exactly_four_allowlisted_objects() -> None: + bundle = PlaneBundle.load(ROOT / "bundles/whitehat-foundational-plane.yaml") + assert bundle.digest + assert [ref.display for ref in bundle.allowed_objects] == [ + "Namespace/whitehat", + "NetworkPolicy/whitehat/default-deny", + "NetworkPolicy/whitehat/allow-audit-core-e2", + "ServiceAccount/whitehat/whitehat-runner", + ] + assert bundle.plan().is_approved() + assert {doc["kind"] for doc in bundle.documents} == { + "Namespace", + "NetworkPolicy", + "ServiceAccount", + } + + +def _fixture(tmp_path: Path, *, approved: bool = True, kind: str = "Namespace") -> Path: + (tmp_path / "bundles").mkdir() + (tmp_path / "manifests").mkdir() + (tmp_path / "plans").mkdir() + manifest = tmp_path / "manifests/plane.yaml" + if kind == "Namespace": + document = { + "apiVersion": "v1", + "kind": "Namespace", + "metadata": { + "name": "whitehat", + "labels": {"pod-security.kubernetes.io/enforce": "restricted"}, + }, + } + resource = "namespaces" + namespace = None + else: + document = { + "apiVersion": "v1", + "kind": kind, + "metadata": {"name": "forbidden", "namespace": "whitehat"}, + } + resource = kind.lower() + "s" + namespace = "whitehat" + manifest.write_text(yaml.safe_dump(document, sort_keys=False)) + digest = hashlib.sha256(manifest.read_bytes()).hexdigest() + plan = tmp_path / "plans/plane.md" + status = "approved" if approved else "reviewed" + approval = 'approved_by: "Bernd"\napproved_at: "2026-08-22"\n' if approved else "" + plan.write_text(f"---\nid: plane\nstatus: {status}\n{approval}---\n# Plan\n") + descriptor = { + "schema_version": "ops-mason.kubernetes-plane/v1", + "id": "plane", + "plan": "../plans/plane.md", + "expected_context": "default", + "expected_namespace": "whitehat", + "source_repo": "whitehat-security", + "source_revision": "abc", + "implementation_revision": "def", + "evidence_path": "../evidence/plane.json", + "forbidden_kinds": ["Pod", "Secret"], + "manifests": [ + { + "path": "../manifests/plane.yaml", + "source_path": "plane.yaml", + "sha256": digest, + } + ], + "allowed_objects": [ + { + "api_version": "v1", + "kind": kind, + "resource": resource, + "namespace": namespace, + "name": document["metadata"]["name"], + } + ], + } + bundle_path = tmp_path / "bundles/plane.yaml" + bundle_path.write_text(yaml.safe_dump(descriptor, sort_keys=False)) + return bundle_path + + +def test_bundle_refuses_forbidden_pod_even_when_allowlisted(tmp_path: Path) -> None: + with pytest.raises(PlaneRefused, match="forbidden Kubernetes kind"): + PlaneBundle.load(_fixture(tmp_path, kind="Pod")) + + +def test_bundle_refuses_manifest_hash_drift(tmp_path: Path) -> None: + bundle_path = _fixture(tmp_path) + manifest = tmp_path / "manifests/plane.yaml" + manifest.write_text(manifest.read_text() + "# drift\n") + with pytest.raises(PlaneRefused, match="digest mismatch"): + PlaneBundle.load(bundle_path) + + +def test_bundle_refuses_secret_bearing_key_even_on_other_kind(tmp_path: Path) -> None: + bundle_path = _fixture(tmp_path) + manifest = tmp_path / "manifests/plane.yaml" + document = yaml.safe_load(manifest.read_text()) + document["stringData"] = {"token": "must-never-enter-mason"} + manifest.write_text(yaml.safe_dump(document, sort_keys=False)) + descriptor = yaml.safe_load(bundle_path.read_text()) + descriptor["manifests"][0]["sha256"] = hashlib.sha256(manifest.read_bytes()).hexdigest() + bundle_path.write_text(yaml.safe_dump(descriptor, sort_keys=False)) + with pytest.raises(PlaneRefused, match="secret-bearing"): + PlaneBundle.load(bundle_path) + + +def test_json_pointer_supports_label_keys_with_slashes_and_dots() -> None: + value = {"labels": {"kubernetes.io/metadata.name": "whitehat"}} + assert _json_path(value, "/labels/kubernetes.io~1metadata.name") == "whitehat" + + +class FakeCluster: + def __init__( + self, *, context: str = "default", drift: bool = False, dirty: bool = False + ) -> None: + self.context = context + self.drift = drift + self.dirty = dirty + self.applied = False + self.calls: list[list[str]] = [] + + def __call__(self, args) -> CommandResult: + command = list(args) + self.calls.append(command) + if command[:4] == ["git", "-C", command[2], "status"]: + return CommandResult(0, " M src/ops_mason/kubernetes_plane.py\n" if self.dirty else "") + if command == ["kubectl", "config", "current-context"]: + return CommandResult(0, self.context + "\n") + if command[:4] == ["kubectl", "auth", "can-i", "create"]: + return CommandResult(0, "yes\n") + if "apply" in command: + if "--dry-run=client" not in command and "--dry-run=server" not in command: + self.applied = True + return CommandResult(0, "configured\n") + if command[:3] == ["kubectl", "get", "namespaces"]: + if not self.applied: + return CommandResult(1, "", 'Error from server (NotFound): namespaces "whitehat" not found') + labels = {"pod-security.kubernetes.io/enforce": "baseline" if self.drift else "restricted"} + return CommandResult( + 0, + json.dumps( + { + "apiVersion": "v1", + "kind": "Namespace", + "metadata": { + "name": "whitehat", + "labels": labels, + "uid": "uid-1", + "resourceVersion": "10", + }, + } + ), + ) + if command[:4] == ["kubectl", "-n", "whitehat", "get"]: + return CommandResult(0, json.dumps({"items": []})) + raise AssertionError(f"unexpected command: {command}") + + +def test_preflight_refuses_wrong_context_before_kubectl_apply(tmp_path: Path) -> None: + bundle = PlaneBundle.load(_fixture(tmp_path)) + cluster = FakeCluster(context="wrong") + with pytest.raises(PlaneRefused, match="context mismatch"): + preflight(bundle, cluster) + assert not any("apply" in call for call in cluster.calls) + + +def test_apply_refuses_unapproved_plan_without_calling_runner(tmp_path: Path) -> None: + bundle = PlaneBundle.load(_fixture(tmp_path, approved=False)) + calls = [] + + def runner(args): + calls.append(args) + raise AssertionError("runner must not be called") + + with pytest.raises(PlaneRefused, match="not approved"): + apply(bundle, confirm_plan_id="plane", expected_digest=bundle.digest, runner=runner) + assert calls == [] + + +def test_apply_refuses_digest_mismatch_without_calling_runner(tmp_path: Path) -> None: + bundle = PlaneBundle.load(_fixture(tmp_path)) + calls = [] + + def runner(args): + calls.append(args) + raise AssertionError("runner must not be called") + + with pytest.raises(PlaneRefused, match="digest confirmation mismatch"): + apply(bundle, confirm_plan_id="plane", expected_digest="wrong", runner=runner) + assert calls == [] + + +def test_apply_refuses_dirty_repository_before_kubectl(tmp_path: Path) -> None: + bundle = PlaneBundle.load(_fixture(tmp_path)) + cluster = FakeCluster(dirty=True) + with pytest.raises(PlaneRefused, match="committed and clean"): + apply( + bundle, + confirm_plan_id="plane", + expected_digest=bundle.digest, + runner=cluster, + ) + assert not any(call and call[0] == "kubectl" for call in cluster.calls) + + +def test_apply_runs_guarded_path_and_writes_metadata_only_evidence(tmp_path: Path) -> None: + bundle = PlaneBundle.load(_fixture(tmp_path)) + cluster = FakeCluster() + evidence = apply( + bundle, + confirm_plan_id="plane", + expected_digest=bundle.digest, + runner=cluster, + ) + assert evidence["verification"]["negative_scope"] == {"pods": 0, "secrets": 0} + assert bundle.evidence_path.exists() + text = bundle.evidence_path.read_text() + assert "uid-1" in text + assert "data" not in evidence["verification"] + mutating = [call for call in cluster.calls if "apply" in call and "--dry-run=server" not in call and "--dry-run=client" not in call] + assert len(mutating) == 1 + assert "--field-manager=ops-mason" in mutating[0] + + +def test_preflight_refuses_unmanaged_live_drift(tmp_path: Path) -> None: + bundle = PlaneBundle.load(_fixture(tmp_path)) + cluster = FakeCluster(drift=True) + cluster.applied = True + with pytest.raises(PlaneRefused, match="unmanaged live drift"): + preflight(bundle, cluster) + + +def test_rollback_is_generated_but_never_executed(tmp_path: Path) -> None: + bundle = PlaneBundle.load(_fixture(tmp_path)) + result = rollback_plan(bundle) + assert result["object_scoped_commands"] == [] + assert result["conditional_namespace_commands"] == ["kubectl delete namespaces whitehat"] diff --git a/workplans/MASON-WP-0002-whitehat-foundational-plane.md b/workplans/MASON-WP-0002-whitehat-foundational-plane.md index 890846f..753b0d0 100644 --- a/workplans/MASON-WP-0002-whitehat-foundational-plane.md +++ b/workplans/MASON-WP-0002-whitehat-foundational-plane.md @@ -4,7 +4,7 @@ type: workplan title: "Provision the Whitehat foundational plane and reconcile intake drift" domain: infotech repo: ops-mason -status: ready +status: active owner: codex topic_slug: whitehat-foundational-plane created: "2026-08-22" @@ -55,7 +55,7 @@ set. No unresolved dependency makes the first implementation step guesswork. ```task id: MASON-WP-0002-T01 -status: todo +status: done priority: high state_hub_task_id: "067451e4-50f7-59d9-b804-50f348e22508" ``` @@ -82,11 +82,19 @@ Whitehat to align its README. Do not mint an engagement ID here. four allowed objects, passes dry-run validation, and has an explicit exclusion list and rollback procedure. +**Done (2026-08-22):** added the byte-identical source pins, approved +construction plan, declarative bundle, guarded CLI, safety documentation, and +rollback generator. Thirty tests pass. Live read-only preflight reports all +four objects absent and validates the matching audit-core Service/policy. +Bundle digest: `9636f48f0b994118ff60a8c014e0099486945d66a2b3d3582dc57a09862b2035`. +Whitehat README alignment request: State Hub message +`47032e07-5086-449f-8251-0f69df6cd934`. + ## Provision and verify the foundational plane ```task id: MASON-WP-0002-T02 -status: todo +status: progress priority: high state_hub_task_id: "23f78bc5-4185-5181-9f3c-fd718641d2a5" ```