diff --git a/SCOPE.md b/SCOPE.md index f1bf006..8ec488b 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -69,8 +69,10 @@ boundary always holds. - `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`. -- `T03` is in progress: `WH-ENG-20260822-AUDIT-E2-01` expired at 18:15Z with - no live run. A new engagement ID is required for a retry. +- `T03` is in progress: `WH-ENG-20260822-AUDIT-E2-01` expired unused; + `WH-ENG-20260822-AUDIT-E2-02` aborted after projection because admission + had no receipt adapter. Zero packets. A later live run needs a new ID and + `admit-plane --receipt`. - `T04` is complete for every applicable E2 probe: generic and audit-core shaped fixtures fail known-bad and pass known-good in-process. - `T05` is in progress: 24-hour cadence, offline evaluator and in-process diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 000bdf4..8e8c05c 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -50,7 +50,7 @@ retired. ```sh PYTHONPATH=src python3 -m whitehat_security.cli validate-engagement engagements/.json - PYTHONPATH=src python3 -m whitehat_security.cli admit-plane engagements/.json targets/.json + PYTHONPATH=src python3 -m whitehat_security.cli admit-plane engagements/.json targets/.json --receipt ``` 7. Confirm target identity/revision and probe attribution before the first diff --git a/docs/test-plane.md b/docs/test-plane.md index 35c462d..6aef80d 100644 --- a/docs/test-plane.md +++ b/docs/test-plane.md @@ -16,10 +16,10 @@ A live E2 run is admitted only when every item below is true: 5. The runner namespace is `whitehat`, the image digest is pinned, and engagement labels are present. Live E3 also names a `database`. Live capacity also names an `aggressor_ceiling`. -6. The custody broker projected the registered number of identity *handles* - (two for E2, one runtime for E3, none for capacity fixtures). Credential - values are not returned to the operator or agent. Live classes fail closed - while the custody broker is unconnected. +6. A value-safe custody **receipt** is supplied to `admit-plane --receipt`. + The receipt names handles, mount keys, expiry and `secret_values_observed: + false`. It must not contain token values. Without a receipt, live admission + fails closed and requests no credential. 7. The kill switch is clear. 8. The rate watcher is attached. Cleanup revokes the lease. @@ -38,7 +38,7 @@ requested. ```sh PYTHONPATH=src python3 -m whitehat_security.cli validate-targets targets -PYTHONPATH=src python3 -m whitehat_security.cli admit-plane engagements/.json targets/.json +PYTHONPATH=src python3 -m whitehat_security.cli admit-plane engagements/.json targets/.json --receipt PYTHONPATH=src python3 -m whitehat_security.cli kill-switch PYTHONPATH=src python3 -m whitehat_security.cli deliver evidence/.json --outbox outbox ``` @@ -47,7 +47,8 @@ Create `plane/KILL` to abort independently of the runner process. ## Retired identifiers -`WH-ENG-20260821-AUDIT-E2`, `WH-ENG-20260821-TENANT-E2`, and -`WH-ENG-20260822-AUDIT-E2-01` are terminal. The last of those expired at -18:15Z with no projection-ready notice and zero packets. A later live E2 run -needs a new ID, a new window, and a fresh attended projection. +`WH-ENG-20260821-AUDIT-E2`, `WH-ENG-20260821-TENANT-E2`, +`WH-ENG-20260822-AUDIT-E2-01`, and `WH-ENG-20260822-AUDIT-E2-02` are terminal. +`-02` projected and then aborted: admission had no receipt adapter, so the +runner sent zero packets. A later live E2 run needs a new ID, a new window, +a custody receipt, and `admit-plane --receipt`. diff --git a/engagements/2026-08-22-audit-core-e2-02.json b/engagements/2026-08-22-audit-core-e2-02.json index 5e4621b..6013f24 100644 --- a/engagements/2026-08-22-audit-core-e2-02.json +++ b/engagements/2026-08-22-audit-core-e2-02.json @@ -1,6 +1,8 @@ { "engagement_id": "WH-ENG-20260822-AUDIT-E2-02", - "status": "approved", + "status": "aborted", + "aborted_at": "2026-08-22T19:21:39Z", + "abort_reason": "Attended projection succeeded at 19:17:54Z and the runner became Ready, but admit-plane failed closed: no live adapter consumed the value-safe custody receipt. Zero packets. Runner deleted 19:21:07Z; custody cleanup 19:21:39Z. Not E2 evidence. Identifier is terminal.", "proposal_at": "2026-08-22T18:36:50Z", "proposal_reason": "Fresh attended retry after WH-ENG-20260822-AUDIT-E2-01 expired with zero packets. The terminal identifier, identities and fixtures are not reused.", "authorization_id": "operator-session-2026-08-22-e2-02-approval", diff --git a/engagements/2026-08-22-audit-core-e2-02.md b/engagements/2026-08-22-audit-core-e2-02.md index 341c546..13e6e01 100644 --- a/engagements/2026-08-22-audit-core-e2-02.md +++ b/engagements/2026-08-22-audit-core-e2-02.md @@ -1,21 +1,13 @@ # WH-ENG-20260822-AUDIT-E2-02 -Status: **approved and waiting**. The engagement is fail-closed until -`2026-08-22T19:15:00Z` and until custody reports projection ready. +Status: **aborted**. Not E2 evidence. -| Gate | State | -| --- | --- | -| Operator approval | `2026-08-22T18:42:50Z` in the coordinating session | -| Owner acknowledgement | `2026-08-22T18:45:39Z` after direct artifact review | -| Foundational plane | applied by ops-mason `c26a6e5` | -| Window | `2026-08-22T19:15:00Z`–`19:30:00Z` | -| Image | `sha256:c2fe39a0185b99be3fc0cb14d2de69772b8e66e20490097c9d11d90cc39719a6` | -| Custody projection | pending; do not mint before 19:15Z | -| Runner pod | not applied | -| Packets | none | +Attended projection succeeded at `2026-08-22T19:17:54Z` and the runner pod +became Ready. Target validation, engagement validation and the kill switch +passed. `admit-plane` then failed closed because this repository had no +adapter for a value-safe custody receipt. The runner was never invoked. +Zero packets. Runner deleted `19:21:07Z`; custody cleanup `19:21:39Z`. +Identifier is terminal. -Offline preflight must fail closed before the window and until the live custody -broker confirms the two exact mounted identities. - -Invocation, including required `--forged-event whitehat-e2-forged-b-20260822-02`: -`engagements/runtime/WH-ENG-20260822-AUDIT-E2-02-invocation.md`. +The fix is `admit-plane --receipt `, which issues a +plane lease from handles only. diff --git a/engagements/README.md b/engagements/README.md index 49c6ca0..370a0e7 100644 --- a/engagements/README.md +++ b/engagements/README.md @@ -14,6 +14,8 @@ from `whitehat admit-plane`. `WH-ENG-20260821-AUDIT-E2` and `WH-ENG-20260821-TENANT-E2` are cancelled and must not be reused. The pod manifest under `runtime/` is bound to a cancelled ID and must not be applied. -`WH-ENG-20260822-AUDIT-E2-01` expired at `2026-08-22T18:15:00Z` with no live -run. Do not reuse it. A later attempt needs a new identifier and window. +`WH-ENG-20260822-AUDIT-E2-01` expired unused. `WH-ENG-20260822-AUDIT-E2-02` +aborted after projection because `admit-plane` had no receipt adapter; zero +packets. Neither identifier may be reused. Live admission requires +`--receipt` of a value-safe custody projection. diff --git a/plane/README.md b/plane/README.md index 1c86c79..131064d 100644 --- a/plane/README.md +++ b/plane/README.md @@ -3,8 +3,8 @@ This repository does not apply cluster objects, request credentials, or send packets. `ops-mason` provisions the foundational plane from these files **before** a fresh engagement is proposed. Cancelled or expired IDs -`WH-ENG-20260821-AUDIT-E2`, `WH-ENG-20260821-TENANT-E2`, and -`WH-ENG-20260822-AUDIT-E2-01` are terminal. +`WH-ENG-20260821-AUDIT-E2`, `WH-ENG-20260821-TENANT-E2`, +`WH-ENG-20260822-AUDIT-E2-01`, and `WH-ENG-20260822-AUDIT-E2-02` are terminal. ## Ordering diff --git a/schemas/projection-receipt.schema.json b/schemas/projection-receipt.schema.json new file mode 100644 index 0000000..853243d --- /dev/null +++ b/schemas/projection-receipt.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "whitehat-projection-receipt/v1", + "type": "object", + "additionalProperties": false, + "required": [ + "engagement_id", "projected_at", "expires_at", "identities", + "mounted_secret", "mounted_keys", "target_ready", "secret_values_observed" + ], + "properties": { + "engagement_id": {"type": "string", "minLength": 1}, + "projected_at": {"type": "string", "format": "date-time"}, + "expires_at": {"type": "string", "format": "date-time"}, + "identities": {"type": "array", "minItems": 1, "items": {"type": "string"}}, + "mounted_secret": {"type": "string", "pattern": "^whitehat/"}, + "mounted_keys": {"type": "array", "items": {"type": "string"}}, + "target_image_matches": {"type": "boolean"}, + "target_ready": {"const": true}, + "secret_values_observed": {"const": false} + } +} diff --git a/src/whitehat_security/cli.py b/src/whitehat_security/cli.py index e266bac..a6db1a8 100644 --- a/src/whitehat_security/cli.py +++ b/src/whitehat_security/cli.py @@ -13,7 +13,7 @@ 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, admit, default_broker, retired_ids +from .plane import KillSwitch, ReceiptBroker, admit, default_broker, retired_ids from .reporting import queue_risk_nexus, risk_nexus_message from .targets import load_catalog, load_registration @@ -90,6 +90,7 @@ def main(argv: list[str] | None = None) -> None: admit_plane = commands.add_parser("admit-plane") admit_plane.add_argument("engagement") admit_plane.add_argument("registration") + admit_plane.add_argument("--receipt", help="value-safe custody projection receipt") commands.add_parser("kill-switch") deliver = commands.add_parser("deliver") deliver.add_argument("report") @@ -138,8 +139,11 @@ def main(argv: list[str] | None = None) -> None: try: record = Engagement.load(args.engagement) registration = load_registration(args.registration) + broker = ( + ReceiptBroker.load(args.receipt) if args.receipt else default_broker(record) + ) lease = admit(engagement=record, registration=registration, - broker=default_broker(record), kill_switch=KillSwitch(), + broker=broker, kill_switch=KillSwitch(), retired=retired_ids()) except (AuthorizationError, OSError, ValueError, json.JSONDecodeError) as error: print(f"not authorized: {error}", file=sys.stderr) diff --git a/src/whitehat_security/engagement.py b/src/whitehat_security/engagement.py index ee319a9..baf711e 100644 --- a/src/whitehat_security/engagement.py +++ b/src/whitehat_security/engagement.py @@ -43,6 +43,10 @@ class Engagement: raise AuthorizationError( "engagement window elapsed with no live run; identifier must not be reused" ) + if self.raw.get("status") == "aborted": + raise AuthorizationError( + "engagement aborted without target evidence; identifier must not be reused" + ) if self.raw.get("status") == "proposed": raise AuthorizationError( "engagement is proposed; operator approval and owner acknowledgement are pending" diff --git a/src/whitehat_security/plane.py b/src/whitehat_security/plane.py index f0dabfc..1720f2d 100644 --- a/src/whitehat_security/plane.py +++ b/src/whitehat_security/plane.py @@ -16,6 +16,7 @@ TERMINAL_IDS = frozenset({ "WH-ENG-20260821-AUDIT-E2", "WH-ENG-20260821-TENANT-E2", "WH-ENG-20260822-AUDIT-E2-01", + "WH-ENG-20260822-AUDIT-E2-02", }) APPROVAL_CLASSES = { "fixture-e2": frozenset({"e2-differential"}), @@ -42,7 +43,7 @@ def retired_ids(engagements_dir: str | Path | None = None) -> set[str]: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError, ValueError): continue - if data.get("status") in {"cancelled", "expired"} and data.get("engagement_id"): + if data.get("status") in {"cancelled", "expired", "aborted"} and data.get("engagement_id"): retired.add(data["engagement_id"]) return retired @@ -113,6 +114,82 @@ class CredentialBroker(Protocol): ... +FORBIDDEN_RECEIPT_KEYS = { + "token", "token_a", "token_b", "password", "secret", "secret_value", + "bearer", "credential", "value", "senders.json", +} + + +def _receipt_contains_secret_material(value: Any) -> bool: + if isinstance(value, dict): + for key, child in value.items(): + lowered = str(key).lower().replace("-", "_") + if lowered in FORBIDDEN_RECEIPT_KEYS: + return True + if _receipt_contains_secret_material(child): + return True + return False + if isinstance(value, list): + return any(_receipt_contains_secret_material(item) for item in value) + return False + + +class ReceiptBroker: + """Consume a value-safe custody receipt. Never reads or stores secret bytes.""" + + def __init__(self, receipt: dict[str, Any]) -> None: + if _receipt_contains_secret_material(receipt): + raise AuthorizationError("projection receipt contains secret material") + if receipt.get("secret_values_observed") is not False: + raise AuthorizationError("projection receipt did not prove secret values were unobserved") + self.receipt = receipt + + @classmethod + def load(cls, path: str | Path) -> "ReceiptBroker": + return cls(json.loads(Path(path).read_text(encoding="utf-8"))) + + 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") + if not self.receipt.get("target_ready"): + raise AuthorizationError("projection receipt does not show a ready target") + expires_at = self.receipt.get("expires_at") + if not expires_at: + raise AuthorizationError("projection receipt missing expires_at") + expiry = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00")) + if expiry.tzinfo is None: + raise AuthorizationError("projection receipt expiry must include a timezone") + if datetime.now(UTC) > expiry.astimezone(UTC): + raise AuthorizationError("projection receipt has expired") + keys = list(self.receipt.get("mounted_keys") or []) + count = int(registration["identities"]["count"]) + if count == 2 and keys != ["token-a", "token-b"]: + raise AuthorizationError("E2 receipt must mount exactly token-a and token-b") + names = list(self.receipt.get("identities") or []) + if len(names) != count: + raise AuthorizationError("projection receipt identity count does not match registration") + secret = str(self.receipt.get("mounted_secret") or "") + if not secret.startswith("whitehat/"): + raise AuthorizationError("projection receipt must mount in namespace whitehat") + lease_id = f"{engagement.raw['engagement_id']}:{self.receipt.get('projected_at', '')}" + if count == 2: + return ( + IdentityHandle("owner", "/var/run/secrets/whitehat/token-b", lease_id, expires_at), + IdentityHandle("attacker", "/var/run/secrets/whitehat/token-a", lease_id, expires_at), + ) + if count == 1: + return ( + IdentityHandle("runtime", "/var/run/secrets/whitehat/runtime", lease_id, expires_at), + ) + return () + + def revoke(self, lease_id: str) -> None: + raise AuthorizationError( + "receipt broker does not hold credentials; custody must revoke the projection" + ) + + class UnconnectedCustodyBroker: """Fail-closed live broker. It never requests or returns credential values.""" diff --git a/tests/test_engagement.py b/tests/test_engagement.py index 159f31b..a9f1710 100644 --- a/tests/test_engagement.py +++ b/tests/test_engagement.py @@ -1,5 +1,6 @@ import json from datetime import UTC, datetime +from pathlib import Path import pytest @@ -63,8 +64,13 @@ def test_pending_owner_acknowledgement_fails_closed(tmp_path): load(tmp_path, data) -def test_retry_record_is_authorized_only_inside_its_window(): - path = "engagements/2026-08-22-audit-core-e2-02.json" +def test_retry_record_is_authorized_only_inside_its_window(tmp_path): + data = json.loads(Path("engagements/2026-08-22-audit-core-e2-02.json").read_text()) + data["status"] = "approved" + data.pop("aborted_at", None) + data.pop("abort_reason", None) + path = tmp_path / "retry.json" + path.write_text(json.dumps(data), encoding="utf-8") with pytest.raises(AuthorizationError, match="has not started"): Engagement.load(path, now=datetime(2026, 8, 22, 19, 14, tzinfo=UTC)) engagement = Engagement.load(path, now=datetime(2026, 8, 22, 19, 15, tzinfo=UTC)) @@ -73,6 +79,14 @@ def test_retry_record_is_authorized_only_inside_its_window(): Engagement.load(path, now=datetime(2026, 8, 22, 19, 31, tzinfo=UTC)) +def test_aborted_record_fails_closed(): + with pytest.raises(AuthorizationError, match="aborted"): + Engagement.load( + "engagements/2026-08-22-audit-core-e2-02.json", + now=datetime(2026, 8, 22, 19, 20, tzinfo=UTC), + ) + + def test_elapsed_record_fails_closed_even_inside_old_window(tmp_path): data = record() data["status"] = "expired" diff --git a/tests/test_plane.py b/tests/test_plane.py index bfcaf7b..9be9c51 100644 --- a/tests/test_plane.py +++ b/tests/test_plane.py @@ -10,7 +10,7 @@ from whitehat_security.fixtures import FixtureService, probe_suite from whitehat_security.model import RunReport from whitehat_security.e3 import e3_calibration from whitehat_security.plane import ( - KillSwitch, LocalBroker, RateWatcher, UnconnectedCustodyBroker, + KillSwitch, LocalBroker, RateWatcher, ReceiptBroker, UnconnectedCustodyBroker, admit, cleanup, retired_ids, ) from whitehat_security.reporting import queue_risk_nexus @@ -71,6 +71,7 @@ def test_retired_ids_include_cancelled_records(): assert "WH-ENG-20260821-AUDIT-E2" in ids assert "WH-ENG-20260821-TENANT-E2" in ids assert "WH-ENG-20260822-AUDIT-E2-01" in ids + assert "WH-ENG-20260822-AUDIT-E2-02" in ids def test_fixture_plane_admits_and_projects_handles_without_secrets(tmp_path): @@ -165,6 +166,63 @@ def test_unconnected_broker_requests_no_credential(tmp_path): ) +def test_receipt_broker_issues_lease_without_secret_values(tmp_path): + engagement = load_engagement(tmp_path, fixture_record( + approval_class="live-e2", environment="build", + target_id="audit-core", + routes=["POST /v1/events"], + plane_namespace="whitehat", + runner_image_digest="sha256:abc", + )) + receipt = { + "engagement_id": "WH-ENG-FIXTURE-1", + "projected_at": "2026-08-22T12:00:00Z", + "expires_at": "2099-01-01T00:00:00Z", + "identities": ["whitehat-e2-a", "whitehat-e2-b"], + "mounted_secret": "whitehat/whitehat-e2-audit-credentials", + "mounted_keys": ["token-a", "token-b"], + "target_ready": True, + "secret_values_observed": False, + } + lease = admit( + engagement=engagement, registration=load_registration("targets/audit-core-e2.json"), + broker=ReceiptBroker(receipt), kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, retired=set(), + ) + assert {handle.role for handle in lease.identities} == {"owner", "attacker"} + rendered = repr(lease) + repr(lease.identities) + json.dumps(receipt) + assert "token_urlsafe" not in rendered + with pytest.raises(AuthorizationError, match="custody must revoke"): + cleanup(lease, ReceiptBroker(receipt)) + + +def test_receipt_broker_rejects_secret_material(tmp_path): + engagement = load_engagement(tmp_path, fixture_record( + approval_class="live-e2", environment="build", + target_id="audit-core", + routes=["POST /v1/events"], + plane_namespace="whitehat", + runner_image_digest="sha256:abc", + )) + receipt = { + "engagement_id": "WH-ENG-FIXTURE-1", + "projected_at": "2026-08-22T12:00:00Z", + "expires_at": "2099-01-01T00:00:00Z", + "identities": ["whitehat-e2-a", "whitehat-e2-b"], + "mounted_secret": "whitehat/whitehat-e2-audit-credentials", + "mounted_keys": ["token-a", "token-b"], + "target_ready": True, + "secret_values_observed": False, + "token": "must-not-appear", + } + with pytest.raises(AuthorizationError, match="secret material"): + admit( + engagement=engagement, registration=load_registration("targets/audit-core-e2.json"), + broker=ReceiptBroker(receipt), kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, retired=set(), + ) + + def test_e3_is_not_admitted_by_the_e2_plane(tmp_path): engagement = load_engagement(tmp_path, fixture_record( approval_class="e3", techniques=["e3-rls"], diff --git a/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md b/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md index 05c77a7..3d8542a 100644 --- a/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md +++ b/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md @@ -163,8 +163,10 @@ target request and are retired. ops-mason applied the foundational plane on 2026-08-22T09:52:55Z; audit-core acknowledged at 2026-08-22T10:04:56Z (`32afceb`) after the fixture/digest correction `1418dac`. Offline preflight recorded a window that elapsed at 18:15Z with no projection-ready notice and -zero packets. That identifier is expired and must not be reused. A later live -E2 run needs a new ID, a new window, and a fresh attended projection. +zero packets. `WH-ENG-20260822-AUDIT-E2-02` projected at 19:17:54Z and the +runner became Ready, then `admit-plane` failed closed because no receipt +adapter existed; zero packets, cleanup complete 19:21:39Z. Both identifiers +are terminal. Live admission now consumes a value-safe custody receipt. Whitehat will not relabel tenant-engine to finish this task. ### T04 — Prove the probes fail