build: add guarded Kubernetes plane executor
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02878-7c21-7692-bcd6-ce2838c4b448
This commit is contained in:
parent
1dd98b4f27
commit
2b318634b6
13 changed files with 1240 additions and 3 deletions
10
README.md
10
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).
|
||||
|
|
|
|||
61
bundles/whitehat-foundational-plane.yaml
Normal file
61
bundles/whitehat-foundational-plane.yaml
Normal file
|
|
@ -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
|
||||
48
docs/kubernetes-plane.md
Normal file
48
docs/kubernetes-plane.md
Normal file
|
|
@ -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/<id>.yaml
|
||||
ops-mason plane preflight --bundle bundles/<id>.yaml
|
||||
ops-mason plane apply --bundle bundles/<id>.yaml \
|
||||
--confirm <approved-plan-id> \
|
||||
--expect-digest <digest-from-preflight>
|
||||
ops-mason plane verify --bundle bundles/<id>.yaml
|
||||
ops-mason plane rollback-plan --bundle bundles/<id>.yaml
|
||||
```
|
||||
|
||||
Rollback output is a plan, never an action. Review live inventory immediately
|
||||
before using it.
|
||||
31
manifests/whitehat-plane/README.md
Normal file
31
manifests/whitehat-plane/README.md
Normal file
|
|
@ -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.
|
||||
9
manifests/whitehat-plane/namespace.yaml
Normal file
9
manifests/whitehat-plane/namespace.yaml
Normal file
|
|
@ -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
|
||||
40
manifests/whitehat-plane/network-policy.yaml
Normal file
40
manifests/whitehat-plane/network-policy.yaml
Normal file
|
|
@ -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
|
||||
9
manifests/whitehat-plane/service-account.yaml
Normal file
9
manifests/whitehat-plane/service-account.yaml
Normal file
|
|
@ -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
|
||||
87
plans/whitehat-foundational-plane.md
Normal file
87
plans/whitehat-foundational-plane.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -11,6 +11,9 @@ dependencies = [
|
|||
"PyYAML>=6.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
ops-mason = "ops_mason.cli:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8"]
|
||||
|
||||
|
|
|
|||
67
src/ops_mason/cli.py
Normal file
67
src/ops_mason/cli.py
Normal file
|
|
@ -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())
|
||||
606
src/ops_mason/kubernetes_plane.py
Normal file
606
src/ops_mason/kubernetes_plane.py
Normal file
|
|
@ -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
|
||||
258
tests/test_kubernetes_plane.py
Normal file
258
tests/test_kubernetes_plane.py
Normal file
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue