From 1a38080cddca0acfc6eaf69700dfb481c673d7a0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 22 Aug 2026 22:02:41 +0200 Subject: [PATCH] Add Railiance WP-0025 custody adapter Implement src/whitehat_security/platform_custody.py and focused tests against the four custody schemas. Live admission still fails closed without a value-safe projection receipt. No new engagement or traffic. Assistant: grok Assistant-Session: 01a02670-3345-76f2-a014-70fde8e2a2bb --- SCOPE.md | 3 +- src/whitehat_security/cli.py | 7 +- src/whitehat_security/platform_custody.py | 237 +++++++++++++++++ tests/test_platform_custody_adapter.py | 245 ++++++++++++++++++ ...ITEHAT-WP-0003-platform-custody-adapter.md | 38 +++ 5 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 src/whitehat_security/platform_custody.py create mode 100644 tests/test_platform_custody_adapter.py create mode 100644 workplans/WHITEHAT-WP-0003-platform-custody-adapter.md diff --git a/SCOPE.md b/SCOPE.md index 6ada513..9bd4a44 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -65,7 +65,8 @@ boundary always holds. ## Current state - Repository status: active. -- Active plan: `WHITEHAT-WP-0001`. Meantime polish: `WHITEHAT-WP-0002`. +- Active plan: `WHITEHAT-WP-0001`. Meantime polish: `WHITEHAT-WP-0002` and + `WHITEHAT-WP-0003` (Railiance WP-0025 custody adapter). - `T01` is complete: the rules of engagement were accepted on 2026-08-21. - `T02` is complete: the per-axis attacker model is recorded in `docs/attacker-model.md`. diff --git a/src/whitehat_security/cli.py b/src/whitehat_security/cli.py index a6db1a8..3a50d4c 100644 --- a/src/whitehat_security/cli.py +++ b/src/whitehat_security/cli.py @@ -13,7 +13,8 @@ from .e3 import CADENCE, PROBES, e3_calibration from .engagement import AuthorizationError, Engagement from .fixtures import FixtureService, probe_suite from .model import RunReport, utc_now -from .plane import KillSwitch, ReceiptBroker, admit, default_broker, retired_ids +from .plane import KillSwitch, admit, default_broker, retired_ids +from .platform_custody import broker_from_receipt from .reporting import queue_risk_nexus, risk_nexus_message from .targets import load_catalog, load_registration @@ -91,6 +92,7 @@ def main(argv: list[str] | None = None) -> None: admit_plane.add_argument("engagement") admit_plane.add_argument("registration") admit_plane.add_argument("--receipt", help="value-safe custody projection receipt") + admit_plane.add_argument("--contract", help="WP-0025 projection contract bound to the receipt") commands.add_parser("kill-switch") deliver = commands.add_parser("deliver") deliver.add_argument("report") @@ -140,7 +142,8 @@ def main(argv: list[str] | None = None) -> None: record = Engagement.load(args.engagement) registration = load_registration(args.registration) broker = ( - ReceiptBroker.load(args.receipt) if args.receipt else default_broker(record) + broker_from_receipt(args.receipt, contract_path=args.contract) + if args.receipt else default_broker(record) ) lease = admit(engagement=record, registration=registration, broker=broker, kill_switch=KillSwitch(), diff --git a/src/whitehat_security/platform_custody.py b/src/whitehat_security/platform_custody.py new file mode 100644 index 0000000..df1d00d --- /dev/null +++ b/src/whitehat_security/platform_custody.py @@ -0,0 +1,237 @@ +"""Consume Railiance WP-0025 custody documents. Never reads secret values.""" + +from __future__ import annotations + +import json +import os +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from .engagement import AuthorizationError, Engagement +from .plane import IdentityHandle + +CONTRACT_INTERFACE = "railiance.custody-projection-contract" +BROKER_INTERFACE = "railiance.custody-broker-readiness" +PROJECTION_INTERFACE = "railiance.custody-projection-receipt" +CLEANUP_INTERFACE = "railiance.custody-cleanup-receipt" +SCHEMA_FILES = ( + "custody-projection-contract.schema.json", + "custody-broker-readiness.schema.json", + "custody-projection-receipt.schema.json", + "custody-cleanup-receipt.schema.json", +) +FORBIDDEN_KEYS = { + "token", "tokens", "password", "secret_value", "secret_values", + "bearer", "private_key", "credential", +} + + +def schema_dir() -> Path: + override = os.environ.get("WHITEHAT_CUSTODY_SCHEMA_DIR") + if override: + path = Path(override) + if path.is_dir(): + return path + raise AuthorizationError("WHITEHAT_CUSTODY_SCHEMA_DIR is not a directory") + sibling = Path(__file__).resolve().parents[2].parent / "railiance-platform" / "schemas" + if sibling.is_dir(): + return sibling + raise AuthorizationError("railiance custody schemas are not available") + + +def load_schemas() -> dict[str, dict[str, Any]]: + root = schema_dir() + loaded: dict[str, dict[str, Any]] = {} + for name in SCHEMA_FILES: + path = root / name + try: + loaded[name] = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise AuthorizationError(f"custody schema unavailable: {name}") from error + if loaded[name].get("$schema") != "https://json-schema.org/draft/2020-12/schema": + raise AuthorizationError(f"custody schema {name} has the wrong $schema") + return loaded + + +def _assert_value_safe(value: Any) -> None: + if isinstance(value, dict): + for key, child in value.items(): + if str(key).lower().replace("-", "_") in FORBIDDEN_KEYS: + raise AuthorizationError(f"custody document contains secret material: {key}") + _assert_value_safe(child) + elif isinstance(value, list): + for child in value: + _assert_value_safe(child) + + +def _require(document: dict[str, Any], key: str) -> Any: + if key not in document: + raise AuthorizationError(f"custody document missing {key}") + return document[key] + + +def _timestamp(value: Any, field: str) -> datetime: + if not isinstance(value, str): + raise AuthorizationError(f"{field} must be RFC3339") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise AuthorizationError(f"{field} must be RFC3339") from error + if parsed.tzinfo is None: + raise AuthorizationError(f"{field} must include a timezone") + return parsed.astimezone(UTC) + + +def validate_projection_contract(document: dict[str, Any]) -> dict[str, Any]: + _assert_value_safe(document) + if document.get("interface") != CONTRACT_INTERFACE or document.get("version") != 1: + raise AuthorizationError("unsupported projection contract") + if document.get("status") != "approved": + raise AuthorizationError("projection contract is not approved") + identities = document.get("identities") + if not isinstance(identities, list) or len(identities) != 2: + raise AuthorizationError("projection contract must name two identities") + roles = {item.get("role") for item in identities if isinstance(item, dict)} + handles = {item.get("handle") for item in identities if isinstance(item, dict)} + if roles != {"attacker", "owner"} or handles != {"token-a", "token-b"}: + raise AuthorizationError("projection contract identities are not attacker/owner token-a/token-b") + return document + + +def validate_projection_receipt( + document: dict[str, Any], *, contract: dict[str, Any] | None = None +) -> dict[str, Any]: + _assert_value_safe(document) + if document.get("interface") != PROJECTION_INTERFACE or document.get("version") != 1: + raise AuthorizationError("unsupported projection receipt") + if document.get("state") != "projected": + raise AuthorizationError("projection receipt is not in projected state") + if document.get("secret_values_observed") is not False: + raise AuthorizationError("projection receipt did not prove secret values were unobserved") + if document.get("cleanup_authority") != "railiance-platform": + raise AuthorizationError("cleanup authority must be railiance-platform") + lease_id = _require(document, "lease_id") + if not isinstance(lease_id, str) or not lease_id.startswith("custody:"): + raise AuthorizationError("projection receipt lease_id is invalid") + identities = document.get("identities") + if not isinstance(identities, list) or len(identities) != 2: + raise AuthorizationError("projection receipt must name two identities") + roles = {item.get("role") for item in identities if isinstance(item, dict)} + if roles != {"attacker", "owner"}: + raise AuthorizationError("projection receipt must include attacker and owner") + if contract is not None: + validate_projection_contract(contract) + if document.get("engagement_id") != contract["engagement_id"]: + raise AuthorizationError("projection receipt engagement_id does not match contract") + return document + + +def validate_cleanup_receipt( + document: dict[str, Any], *, projection: dict[str, Any] +) -> dict[str, Any]: + _assert_value_safe(document) + if document.get("interface") != CLEANUP_INTERFACE or document.get("version") != 1: + raise AuthorizationError("unsupported cleanup receipt") + if document.get("state") != "cleaned": + raise AuthorizationError("cleanup receipt is not in cleaned state") + if document.get("secret_values_observed") is not False: + raise AuthorizationError("cleanup receipt did not prove secret values were unobserved") + if document.get("lease_id") != projection.get("lease_id"): + raise AuthorizationError("cleanup receipt lease_id does not match projection") + if document.get("engagement_id") != projection.get("engagement_id"): + raise AuthorizationError("cleanup receipt engagement_id does not match projection") + if document.get("projection_receipt_id") != projection.get("receipt_id"): + raise AuthorizationError("cleanup receipt is not bound to this projection receipt") + if document.get("target_ready") is not True: + raise AuthorizationError("cleanup receipt does not show a ready target") + return document + + +def validate_broker_readiness(document: dict[str, Any]) -> dict[str, Any]: + _assert_value_safe(document) + if document.get("interface") != BROKER_INTERFACE or document.get("version") != 1: + raise AuthorizationError("unsupported broker readiness receipt") + if document.get("owner") != "whitehat-security": + raise AuthorizationError("broker readiness owner must be whitehat-security") + if document.get("secret_values_observed") is not False: + raise AuthorizationError("broker readiness receipt is not value-safe") + return document + + +class PlatformCustodyBroker: + """Issue plane identity handles from a WP-0025 projection receipt.""" + + cleanup_request_supported = True + + def __init__( + self, + receipt: dict[str, Any], + *, + contract: dict[str, Any] | None = None, + cleanup: dict[str, Any] | None = None, + ) -> None: + self.receipt = validate_projection_receipt(receipt, contract=contract) + self.contract = validate_projection_contract(contract) if contract is not None else None + self.cleanup = ( + validate_cleanup_receipt(cleanup, projection=self.receipt) if cleanup is not None else None + ) + + @classmethod + def load( + cls, + receipt_path: str | Path, + *, + contract_path: str | Path | None = None, + cleanup_path: str | Path | None = None, + ) -> "PlatformCustodyBroker": + receipt = json.loads(Path(receipt_path).read_text(encoding="utf-8")) + contract = ( + json.loads(Path(contract_path).read_text(encoding="utf-8")) + if contract_path is not None else None + ) + cleanup = ( + json.loads(Path(cleanup_path).read_text(encoding="utf-8")) + if cleanup_path is not None else None + ) + return cls(receipt, contract=contract, cleanup=cleanup) + + def project(self, engagement: Engagement, registration: dict[str, Any] + ) -> tuple[IdentityHandle, ...]: + if self.receipt.get("engagement_id") != engagement.raw["engagement_id"]: + raise AuthorizationError("projection receipt engagement_id does not match") + expiry = _timestamp(self.receipt["expires_at"], "expires_at") + if datetime.now(UTC) > expiry: + raise AuthorizationError("projection receipt has expired") + count = int(registration["identities"]["count"]) + identities = self.receipt["identities"] + if len(identities) != count: + raise AuthorizationError("projection receipt identity count does not match registration") + handles = [] + for item in sorted(identities, key=lambda row: str(row.get("handle"))): + role = item["role"] + mount_path = item["mount_path"] + if not str(mount_path).startswith("/var/run/secrets/whitehat/"): + raise AuthorizationError("identity mount_path is outside the whitehat mount") + handles.append(IdentityHandle(role, mount_path, self.receipt["lease_id"], + self.receipt["expires_at"])) + return tuple(handles) + + def revoke(self, lease_id: str) -> None: + if self.cleanup is None: + raise AuthorizationError( + "receipt broker does not hold credentials; custody must revoke the projection" + ) + if self.cleanup["lease_id"] != lease_id: + raise AuthorizationError("cleanup receipt lease_id does not match plane lease") + + +def broker_from_receipt( + path: str | Path, *, contract_path: str | Path | None = None +) -> Any: + """Select the WP-0025 adapter or the legacy value-safe receipt broker.""" + data = json.loads(Path(path).read_text(encoding="utf-8")) + if data.get("interface") == PROJECTION_INTERFACE: + return PlatformCustodyBroker.load(path, contract_path=contract_path) + from .plane import ReceiptBroker + return ReceiptBroker(data) diff --git a/tests/test_platform_custody_adapter.py b/tests/test_platform_custody_adapter.py new file mode 100644 index 0000000..9dfe80d --- /dev/null +++ b/tests/test_platform_custody_adapter.py @@ -0,0 +1,245 @@ +import json +from datetime import UTC, datetime + +import pytest + +from whitehat_security.engagement import AuthorizationError, Engagement +from whitehat_security.platform_custody import ( + CLEANUP_INTERFACE, CONTRACT_INTERFACE, PROJECTION_INTERFACE, + PlatformCustodyBroker, load_schemas, validate_broker_readiness, + validate_cleanup_receipt, validate_projection_contract, + validate_projection_receipt, +) +from whitehat_security.plane import KillSwitch, admit + + +NOW = datetime(2026, 8, 22, 12, tzinfo=UTC) + + +def contract() -> dict: + return { + "interface": CONTRACT_INTERFACE, + "version": 1, + "workplan_id": "RAILIANCE-WP-0025", + "engagement_id": "WH-ENG-FIXTURE-1", + "status": "approved", + "engagement_contract_sha256": "1" * 64, + "target": { + "id": "audit-core", + "namespace": "audit-core", + "deployment": "audit-core", + "container": "audit-core", + "sender_external_secret": "audit-core-senders", + "revision": "a" * 40, + "image_digest": "sha256:" + "b" * 64, + "contract_sha256": "c" * 64, + }, + "runner": { + "namespace": "whitehat", + "service_account": "whitehat-runner", + "secret_name": "whitehat-e2-audit-credentials", + "mount_root": "/var/run/secrets/whitehat", + "manifest_sha256": "d" * 64, + }, + "window": { + "starts_at": "2026-08-22T12:00:00Z", + "projection_cutoff": "2026-08-22T12:03:00Z", + "expires_at": "2099-01-01T00:00:00Z", + }, + "authority": { + "remote": "railiance01", + "registry_path": "platform/workloads/audit-core/senders", + "registry_field": "senders.json", + "kv_mount": "platform", + "kv_prefix": "engagements/WH-ENG-FIXTURE-1/audit-core", + "eso_service_account": "external-secrets", + "eso_namespace": "external-secrets", + }, + "identities": [ + { + "handle": "token-a", + "role": "attacker", + "sender_name": "whitehat-e2-a", + "tenant": "tenant-a", + "mount_path": "/var/run/secrets/whitehat/token-a", + "may_read": True, + "may_write": True, + }, + { + "handle": "token-b", + "role": "owner", + "sender_name": "whitehat-e2-b", + "tenant": "tenant-b", + "mount_path": "/var/run/secrets/whitehat/token-b", + "may_read": True, + "may_write": True, + }, + ], + } + + +def projection_receipt() -> dict: + return { + "interface": PROJECTION_INTERFACE, + "version": 1, + "workplan_id": "RAILIANCE-WP-0025", + "state": "projected", + "receipt_id": "sha256:" + "e" * 64, + "lease_id": "custody:" + "f" * 32, + "engagement_id": "WH-ENG-FIXTURE-1", + "target": {"id": "audit-core", "revision": "a" * 40, "image_digest": "sha256:" + "b" * 64}, + "projection_contract_digest": "1" * 64, + "broker_receipt_digest": "2" * 64, + "projected_at": "2026-08-22T12:02:00Z", + "expires_at": "2099-01-01T00:00:00Z", + "identities": [ + { + "handle": "token-a", + "role": "attacker", + "sender_name": "whitehat-e2-a", + "mount_path": "/var/run/secrets/whitehat/token-a", + }, + { + "handle": "token-b", + "role": "owner", + "sender_name": "whitehat-e2-b", + "mount_path": "/var/run/secrets/whitehat/token-b", + }, + ], + "resources": {"names": {"store": "custody-example"}, "uids": {"mounted_secret": "uid"}}, + "cleanup_authority": "railiance-platform", + "secret_values_observed": False, + } + + +def cleanup_receipt() -> dict: + return { + "interface": CLEANUP_INTERFACE, + "version": 1, + "workplan_id": "RAILIANCE-WP-0025", + "state": "cleaned", + "lease_id": "custody:" + "f" * 32, + "engagement_id": "WH-ENG-FIXTURE-1", + "projection_receipt_id": "sha256:" + "e" * 64, + "cleaned_at": "2026-08-22T12:14:00Z", + "removed_resources": ["custody-example"], + "target_ready": True, + "secret_values_observed": False, + } + + +def broker_readiness() -> dict: + return { + "interface": "railiance.custody-broker-readiness", + "version": 1, + "workplan_id": "RAILIANCE-WP-0025", + "owner": "whitehat-security", + "reviewer": "whitehat-owner", + "decision": "approve", + "created_at": "2026-08-22T12:00:00Z", + "engagement_id": "WH-ENG-FIXTURE-1", + "target_id": "audit-core", + "projection_contract_digest": "1" * 64, + "projection_receipt_interface": PROJECTION_INTERFACE, + "interface_artifacts": { + "schemas/custody-projection-contract.schema.json": "a" * 64, + "schemas/custody-broker-readiness.schema.json": "b" * 64, + "schemas/custody-projection-receipt.schema.json": "c" * 64, + "schemas/custody-cleanup-receipt.schema.json": "d" * 64, + }, + "required_roles": ["attacker", "owner"], + "mount_paths": [ + "/var/run/secrets/whitehat/token-a", + "/var/run/secrets/whitehat/token-b", + ], + "adapter": { + "repo": "whitehat-security", + "revision": "a" * 40, + "path": "src/whitehat_security/platform_custody.py", + "sha256": "b" * 64, + "tests_passed": True, + }, + "cleanup_request_supported": True, + "secret_values_observed": False, + } + + +def live_record(): + return { + "engagement_id": "WH-ENG-FIXTURE-1", "authorization_id": "auth-1", + "authorizer": "operator", "approved_at": "2026-08-22T11:00:00Z", + "expires_at": "2099-01-01T00:00:00Z", "target": "https://fixture.invalid", + "target_id": "audit-core", "target_owner": "audit-core", + "environment": "build", "source": "runner", "approval_class": "live-e2", + "plane_namespace": "whitehat", "runner_image_digest": "sha256:abc", + "routes": ["POST /v1/events"], "fixture_ids": ["a", "b"], + "credential_lane": "receipt", "credential_role": "runtime", + "credential_max_ttl_seconds": 900, "techniques": ["e2-differential"], + "prohibited_techniques": ["saturation"], "rate_limit_per_minute": 10, + "max_concurrency": 1, "window_start": "2026-08-22T11:00:00Z", + "window_end": "2099-01-01T00:00:00Z", "operator_contact": "operator", + "abort_contact": "operator", "posture_claim": "E2", + "attacker_model": "E2-authenticated-tenant-a", + "finding_destination": "risk-nexus", + "target_owner_acknowledged_at": "2026-08-22T11:01:00Z", + } + + +def test_four_custody_schemas_are_published(): + schemas = load_schemas() + assert set(schemas) == { + "custody-projection-contract.schema.json", + "custody-broker-readiness.schema.json", + "custody-projection-receipt.schema.json", + "custody-cleanup-receipt.schema.json", + } + assert "custody-projection-contract" in schemas["custody-projection-contract.schema.json"]["$id"] + assert "custody-broker-readiness" in schemas["custody-broker-readiness.schema.json"]["$id"] + assert "custody-projection-receipt" in schemas["custody-projection-receipt.schema.json"]["$id"] + assert "custody-cleanup-receipt" in schemas["custody-cleanup-receipt.schema.json"]["$id"] + + +def test_adapter_validates_all_four_document_kinds(): + assert validate_projection_contract(contract())["engagement_id"] == "WH-ENG-FIXTURE-1" + assert validate_projection_receipt(projection_receipt(), contract=contract())["state"] == "projected" + assert validate_cleanup_receipt(cleanup_receipt(), projection=projection_receipt())["state"] == "cleaned" + assert validate_broker_readiness(broker_readiness())["owner"] == "whitehat-security" + + +def test_adapter_rejects_secret_material(): + tainted = projection_receipt() + tainted["token"] = "never" + with pytest.raises(AuthorizationError, match="secret material"): + validate_projection_receipt(tainted) + + +def test_platform_broker_issues_handles_and_supports_cleanup(tmp_path): + path = tmp_path / "engagement.json" + path.write_text(json.dumps(live_record()), encoding="utf-8") + engagement = Engagement.load(path, now=NOW) + broker = PlatformCustodyBroker(projection_receipt(), contract=contract()) + assert broker.cleanup_request_supported is True + from whitehat_security.targets import load_registration + lease = admit( + engagement=engagement, + registration=load_registration("targets/audit-core-e2.json"), + broker=broker, + kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, + retired=set(), + ) + assert {handle.role for handle in lease.identities} == {"attacker", "owner"} + assert lease.lease_id.startswith("custody:") + with pytest.raises(AuthorizationError, match="custody must revoke"): + broker.revoke(lease.lease_id) + cleaned = PlatformCustodyBroker( + projection_receipt(), contract=contract(), cleanup=cleanup_receipt() + ) + cleaned.revoke(lease.lease_id) + + +def test_cleanup_receipt_must_match_lease(): + with pytest.raises(AuthorizationError, match="lease_id"): + bad = cleanup_receipt() + bad["lease_id"] = "custody:" + "0" * 32 + validate_cleanup_receipt(bad, projection=projection_receipt()) diff --git a/workplans/WHITEHAT-WP-0003-platform-custody-adapter.md b/workplans/WHITEHAT-WP-0003-platform-custody-adapter.md new file mode 100644 index 0000000..dd78225 --- /dev/null +++ b/workplans/WHITEHAT-WP-0003-platform-custody-adapter.md @@ -0,0 +1,38 @@ +--- +id: WHITEHAT-WP-0003 +type: workplan +title: "Consume the Railiance WP-0025 custody broker interface" +domain: infotech +repo: whitehat-security +status: finished +owner: net-kingdom +topic_slug: whitehat-security +created: "2026-08-22" +updated: "2026-08-22" +--- + +# WHITEHAT-WP-0003 — platform custody adapter + +## Goal + +Implement the consumer Railiance asked for after `-02` aborted: a value-safe +adapter at `src/whitehat_security/platform_custody.py` that understands the +four WP-0025 custody schemas and can issue a plane lease from a projection +receipt without seeing token values. + +This plan authorizes no engagement, runner, credential, or traffic. + +## Tasks + +### T01 — Schema-bound adapter and focused tests + +```task +id: WHITEHAT-WP-0003-T01 +status: done +priority: high +``` + +Adapter plus `tests/test_platform_custody_adapter.py`. Cleanup receipts can +acknowledge a lease. Secret material is refused. `admit-plane --receipt` +selects this adapter when the document uses +`railiance.custody-projection-receipt`.