whitehat-security/src/whitehat_security/plane.py
tegwick 7e83a66573 Admit E3 and capacity on the test plane
Register in-process E3 and capacity fixtures, keep live database and
substrate targets pending, and ask ops-mason for namespace-only provision.
No packets, no credentials, no cancelled engagement IDs.

Assistant: grok
Assistant-Session: 01a02670-3345-76f2-a014-70fde8e2a2bb
2026-08-22 09:40:27 +02:00

285 lines
11 KiB
Python

from __future__ import annotations
import json
import os
import time
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, Protocol
from .engagement import AuthorizationError, Engagement
PLANE_NAMESPACE = "whitehat"
STANDING_E2_TTL = 900
TERMINAL_IDS = frozenset({
"WH-ENG-20260821-AUDIT-E2",
"WH-ENG-20260821-TENANT-E2",
})
APPROVAL_CLASSES = {
"fixture-e2": frozenset({"e2-differential"}),
"live-e2": frozenset({"e2-differential"}),
"fixture-e3": frozenset({"e3-rls"}),
"e3": frozenset({"e3-rls"}),
"fixture-capacity": frozenset({"p1-noisy-neighbour", "p2-noisy-neighbour"}),
"capacity": frozenset({"p1-noisy-neighbour", "p2-noisy-neighbour"}),
}
FIXTURE_CLASSES = frozenset({"fixture-e2", "fixture-e3", "fixture-capacity"})
def repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def retired_ids(engagements_dir: str | Path | None = None) -> set[str]:
retired = set(TERMINAL_IDS)
root = Path(engagements_dir) if engagements_dir else repo_root() / "engagements"
if not root.exists():
return retired
for path in root.glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
continue
if data.get("status") == "cancelled" and data.get("engagement_id"):
retired.add(data["engagement_id"])
return retired
class KillSwitch:
def __init__(self, path: str | Path | None = None) -> None:
default = os.environ.get("WHITEHAT_KILL_SWITCH_PATH", str(repo_root() / "plane" / "KILL"))
self.path = Path(path) if path is not None else Path(default)
def engaged(self) -> bool:
flag = os.environ.get("WHITEHAT_KILL_SWITCH", "").strip().lower()
if flag in {"1", "true", "yes", "on"}:
return True
return self.path.exists()
class RateWatcher:
def __init__(self, *, per_minute: int, max_concurrency: int, max_requests: int) -> None:
if max_concurrency != 1:
raise AuthorizationError("v0.1 permits exactly one in-flight API/database operation")
if not 1 <= per_minute <= 60:
raise AuthorizationError("API rate ceiling must be between 1 and 60 per minute")
if max_requests < 1:
raise AuthorizationError("request ceiling must be at least 1")
self.per_minute = per_minute
self.max_requests = max_requests
self._times: list[float] = []
self._in_flight = 0
self._count = 0
def acquire(self) -> None:
if self._in_flight:
raise AuthorizationError("concurrency ceiling reached")
now = time.monotonic()
self._times = [stamp for stamp in self._times if now - stamp < 60]
if len(self._times) >= self.per_minute:
raise AuthorizationError("rate ceiling reached")
if self._count >= self.max_requests:
raise AuthorizationError("request ceiling reached")
self._times.append(now)
self._count += 1
self._in_flight = 1
def release(self) -> None:
self._in_flight = 0
@dataclass(frozen=True)
class IdentityHandle:
role: str
mount_path: str
lease_id: str
expires_at: str
def __repr__(self) -> str:
return (
f"IdentityHandle(role={self.role!r}, mount_path={self.mount_path!r}, "
f"lease_id={self.lease_id!r}, expires_at={self.expires_at!r})"
)
class CredentialBroker(Protocol):
def project(self, engagement: Engagement, registration: dict[str, Any]
) -> tuple[IdentityHandle, ...]:
...
def revoke(self, lease_id: str) -> None:
...
class UnconnectedCustodyBroker:
"""Fail-closed live broker. It never requests or returns credential values."""
def project(self, engagement: Engagement, registration: dict[str, Any]
) -> tuple[IdentityHandle, ...]:
raise AuthorizationError(
"live custody broker is not connected; no credential was requested"
)
def revoke(self, lease_id: str) -> None:
raise AuthorizationError(
f"live custody broker is not connected; lease {lease_id} was not created"
)
class LocalBroker:
"""In-process projection for fixture-e2. Secret bytes never leave this object."""
def __init__(self) -> None:
self._secrets: dict[str, bytes] = {}
self._leases: dict[str, tuple[str, str]] = {}
def project(self, engagement: Engagement, registration: dict[str, Any]
) -> tuple[IdentityHandle, ...]:
if registration.get("applicability") != "applicable":
raise AuthorizationError("broker will not project identities for a non-applicable target")
count = int(registration["identities"]["count"])
lease_id = os.urandom(8).hex()
ttl = min(int(engagement.raw["credential_max_ttl_seconds"]), STANDING_E2_TTL)
expires_at = (datetime.now(UTC) + timedelta(seconds=ttl)).isoformat().replace("+00:00", "Z")
handles: list[IdentityHandle] = []
if count >= 1:
role = "owner" if count == 2 else "runtime"
path = f"/var/run/secrets/whitehat/{lease_id}/{'token-b' if count == 2 else 'runtime'}"
handles.append(IdentityHandle(role, path, lease_id, expires_at))
self._secrets[path] = os.urandom(32)
if count == 2:
path = f"/var/run/secrets/whitehat/{lease_id}/token-a"
handles.append(IdentityHandle("attacker", path, lease_id, expires_at))
self._secrets[path] = os.urandom(32)
self._leases[lease_id] = tuple(handle.mount_path for handle in handles)
return tuple(handles)
def revoke(self, lease_id: str) -> None:
paths = self._leases.pop(lease_id, None)
if paths is None:
raise AuthorizationError("lease is unknown; cleanup cannot be proven")
for path in paths:
self._secrets.pop(path, None)
@dataclass
class PlaneLease:
engagement: Engagement
registration: dict[str, Any]
identities: tuple[IdentityHandle, ...]
watcher: RateWatcher
lease_id: str
broker_name: str
def permits(self, *, technique: str, route: str) -> None:
self.engagement.permits(technique=technique, route=route)
if route not in self.registration["routes"]:
raise AuthorizationError(f"route not registered: {route}")
def default_broker(engagement: Engagement) -> CredentialBroker:
if (engagement.raw.get("environment") == "fixture"
and engagement.raw.get("approval_class") in FIXTURE_CLASSES):
return LocalBroker()
return UnconnectedCustodyBroker()
def admit(*, engagement: Engagement, registration: dict[str, Any],
broker: CredentialBroker | None = None,
kill_switch: KillSwitch | None = None,
now: datetime | None = None,
retired: set[str] | None = None) -> PlaneLease:
engagement.validate(now=now)
switch = kill_switch or KillSwitch()
if switch.engaged():
raise AuthorizationError("kill switch is engaged")
engagement_id = engagement.raw["engagement_id"]
if engagement_id in (retired if retired is not None else retired_ids()):
raise AuthorizationError("engagement id is retired and must not be reused")
if registration.get("applicability") != "applicable":
raise AuthorizationError(
f"target is {registration.get('applicability', 'unregistered')}"
)
target_id = engagement.raw.get("target_id")
if not target_id:
raise AuthorizationError("engagement missing target_id for plane admission")
if target_id != registration["target_id"]:
raise AuthorizationError("engagement target_id does not match registration")
approval_class = engagement.raw.get("approval_class")
allowed = APPROVAL_CLASSES.get(approval_class)
if allowed is None:
raise AuthorizationError("engagement missing or unknown approval_class")
if approval_class not in registration["approval_classes"]:
raise AuthorizationError("approval_class is not registered for this target")
for technique in engagement.raw["techniques"]:
if technique not in allowed:
raise AuthorizationError(
f"technique {technique} not in approval class {approval_class}"
)
for route in engagement.raw["routes"]:
if route not in registration["routes"]:
raise AuthorizationError(f"route not registered: {route}")
_enforce_class_envelope(approval_class, engagement)
selected = broker or default_broker(engagement)
count = int(registration["identities"]["count"])
if count:
identities = selected.project(engagement, registration)
if len(identities) != count:
raise AuthorizationError(f"broker must project exactly {count} identities")
expected = {2: {"owner", "attacker"}, 1: {"runtime"}}[count]
if {handle.role for handle in identities} != expected:
raise AuthorizationError(f"broker must project {sorted(expected)} identities")
if any(hasattr(handle, "secret") and getattr(handle, "secret") for handle in identities):
raise AuthorizationError("broker exposed a credential value")
lease_id = identities[0].lease_id
else:
if approval_class not in FIXTURE_CLASSES:
selected.project(engagement, registration)
identities = ()
lease_id = os.urandom(8).hex()
if hasattr(selected, "_leases"):
selected._leases[lease_id] = ()
watcher = RateWatcher(
per_minute=engagement.raw["rate_limit_per_minute"],
max_concurrency=engagement.raw["max_concurrency"],
max_requests=int(engagement.raw.get("maximum_requests")
or engagement.raw["rate_limit_per_minute"]),
)
return PlaneLease(
engagement=engagement,
registration=registration,
identities=identities,
watcher=watcher,
lease_id=lease_id,
broker_name=type(selected).__name__,
)
def _enforce_class_envelope(approval_class: str, engagement: Engagement) -> None:
environment = engagement.raw["environment"]
if approval_class in FIXTURE_CLASSES:
if environment != "fixture":
raise AuthorizationError(f"{approval_class} requires environment=fixture")
return
if environment == "fixture":
raise AuthorizationError(f"{approval_class} cannot use the fixture environment")
if engagement.raw.get("plane_namespace") != PLANE_NAMESPACE:
raise AuthorizationError(f"{approval_class} requires the whitehat plane namespace")
if not engagement.raw.get("runner_image_digest"):
raise AuthorizationError(f"{approval_class} requires a pinned runner image digest")
if engagement.raw["credential_max_ttl_seconds"] > STANDING_E2_TTL:
raise AuthorizationError("credential TTL must be at most 900 seconds")
if approval_class == "e3" and not engagement.raw.get("database"):
raise AuthorizationError("live E3 requires a named database")
if approval_class == "capacity" and engagement.raw.get("aggressor_ceiling") is None:
raise AuthorizationError("live capacity requires an aggressor_ceiling")
def cleanup(lease: PlaneLease, broker: CredentialBroker) -> dict[str, str]:
broker.revoke(lease.lease_id)
return {
"fixtures": "delete only recorded fixture_ids",
"credential_revocation": "revoked",
"lease_id": lease.lease_id,
}