From 7e83a665735f11fccc4aac4904a510a18d759ce5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 22 Aug 2026 09:40:27 +0200 Subject: [PATCH] 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 --- Makefile | 1 + SCOPE.md | 7 +- docs/e3-cadence.md | 5 + docs/operator-runbook.md | 4 +- docs/rules-of-engagement.md | 12 ++- docs/test-plane.md | 12 ++- engagements/2026-08-22-plane-provision.md | 31 ++++++ evidence/README.md | 7 +- evidence/offline-e3-calibration.json | 99 +++++++++++++++++ schemas/target-registration.schema.json | 4 +- src/whitehat_security/cli.py | 12 ++- src/whitehat_security/e3.py | 48 ++++++++- src/whitehat_security/plane.py | 100 +++++++++++------- src/whitehat_security/targets.py | 10 +- targets/README.md | 4 + targets/fixture-capacity.json | 29 +++++ targets/fixture-e3.json | 39 +++++++ targets/platform-pg-e3.json | 22 ++++ targets/shared-substrate-capacity.json | 21 ++++ tests/test_cli.py | 2 +- tests/test_plane.py | 81 ++++++++++++++ .../WHITEHAT-WP-0001-cross-tenant-evidence.md | 25 +++-- 22 files changed, 501 insertions(+), 74 deletions(-) create mode 100644 engagements/2026-08-22-plane-provision.md create mode 100644 evidence/offline-e3-calibration.json create mode 100644 targets/fixture-capacity.json create mode 100644 targets/fixture-e3.json create mode 100644 targets/platform-pg-e3.json create mode 100644 targets/shared-substrate-capacity.json diff --git a/Makefile b/Makefile index 98656c4..9426f7c 100644 --- a/Makefile +++ b/Makefile @@ -10,3 +10,4 @@ test: fixture-evidence: PYTHONPATH=src python3 -m whitehat_security.cli fixtures --output evidence/offline-calibration.json + PYTHONPATH=src python3 -m whitehat_security.cli e3-fixtures --output evidence/offline-e3-calibration.json diff --git a/SCOPE.md b/SCOPE.md index e07f1bc..aef1f30 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -74,9 +74,10 @@ boundary always holds. E2. Live audit-core still needs a new engagement and a provisioned plane. - `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 with a 24-hour E3 cadence and offline evaluator. -- `T06` is in progress with a bounded characterization evaluator; no shared - substrate window is approved. +- `T05` is in progress: 24-hour cadence, offline evaluator and in-process + calibration exist; `platform-pg` is pending a live window. +- `T06` is in progress: the characterization evaluator is calibrated + in-process; `shared-substrate` is pending a live window and ceiling. - `T07` is in progress: reports can be queued to `outbox/`; the first target report has not been produced. - `T08` is the governed test plane contract. Cluster provisioning is outside diff --git a/docs/e3-cadence.md b/docs/e3-cadence.md index 5420314..e79f31f 100644 --- a/docs/e3-cadence.md +++ b/docs/e3-cadence.md @@ -14,8 +14,13 @@ The executable plan is available with: ```sh PYTHONPATH=src python3 -m whitehat_security.cli e3-plan +PYTHONPATH=src python3 -m whitehat_security.cli e3-fixtures ``` +`e3-fixtures` calibrates the seven expected outcomes in-process, including +keeping the SQL-compromise reset as `inconclusive`. It opens no database +connection and is not target assurance. + The run checks the conformance view, unset-GUC reads, wrong-tenant reads and writes, `BYPASSRLS` on the runtime identity, and unsafe `SECURITY DEFINER` paths. The SQL-compromise reset probe is recorded as `inconclusive` under E3: diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 246e5f1..9b61140 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -15,8 +15,8 @@ make fixture-evidence Success requires every read/write probe to pass against the enforcing fixture and to produce a finding against the fixture with its tenant predicate removed. -`evidence/offline-calibration.json` is calibration evidence, not target -assurance. +`make fixture-evidence` also writes `evidence/offline-e3-calibration.json`. +Both files are calibration evidence, not target assurance. ## Test plane diff --git a/docs/rules-of-engagement.md b/docs/rules-of-engagement.md index 6892714..c41e47e 100644 --- a/docs/rules-of-engagement.md +++ b/docs/rules-of-engagement.md @@ -144,11 +144,13 @@ Live runs are admitted only through the test plane documented in projected short-lived identities, registered target, kill switch, rate watcher and lease cleanup. The plane is a control, not authorization. -`fixture-e2` is the in-process class already permitted by §1. `live-e2` still -requires the dated engagement record, target-owner acknowledgement and -technique-specific operator approval required by §1, plus plane admission. -`e3`, recovery, saturation and destructive techniques keep separate explicit -approval classes and are not admitted by the E2 plane. +`fixture-e2`, `fixture-e3` and `fixture-capacity` are in-process classes +already permitted by §1. `live-e2` still requires the dated engagement +record, target-owner acknowledgement and technique-specific operator approval +required by §1, plus plane admission. Live `e3` additionally names the +database and uses one ordinary runtime role with no `BYPASSRLS`. Live +`capacity` additionally names an aggressor ceiling and a substrate window. +Recovery and destructive techniques remain outside this plane. Cancelled engagement identifiers are terminal and must not be reused. diff --git a/docs/test-plane.md b/docs/test-plane.md index 8e7d6e7..1f3bd40 100644 --- a/docs/test-plane.md +++ b/docs/test-plane.md @@ -11,11 +11,15 @@ A live E2 run is admitted only when every item below is true: 2. Known-bad calibration for that target has already failed in-process. 3. The engagement is complete, in window, owner-acknowledged, and not a retired ID. -4. `approval_class` is `live-e2` (or `fixture-e2` for in-process only). +4. `approval_class` matches the registration (`fixture-e2` / `live-e2` / + `fixture-e3` / `e3` / `fixture-capacity` / `capacity`). 5. The runner namespace is `whitehat`, the image digest is pinned, and - engagement labels are present. -6. The custody broker projected exactly two identity *handles* (owner and - attacker). Credential values are not returned to the operator or agent. + 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. 7. The kill switch is clear. 8. The rate watcher is attached. Cleanup revokes the lease. diff --git a/engagements/2026-08-22-plane-provision.md b/engagements/2026-08-22-plane-provision.md new file mode 100644 index 0000000..84b7c48 --- /dev/null +++ b/engagements/2026-08-22-plane-provision.md @@ -0,0 +1,31 @@ +# Plane provision request — not an engagement + +Status: **coordination only; apply nothing from this repository** + +`WHITEHAT-WP-0001-T08` encoded the governed test plane as a contract. Live E2 +still needs the cluster objects. This note asks `ops-mason` to provision the +**namespace, default-deny network policy, and runner service account** from +`plane/`. It does **not** authorize: + +- applying the runner pod +- projecting credentials +- sending packets +- reusing `WH-ENG-20260821-AUDIT-E2` or `WH-ENG-20260821-TENANT-E2` + +## Requested objects + +| Object | File | Notes | +| --- | --- | --- | +| Namespace `whitehat` | `plane/namespace.yaml` | restricted PSS | +| Default-deny NetworkPolicy | `plane/network-policy.yaml` | audit-core egress is documented, not a standing allow for other targets | +| ServiceAccount `whitehat-runner` | `plane/service-account.yaml` | `automountServiceAccountToken: false` | + +Do not create a credential secret. The live custody broker is still +unconnected; whitehat will fail closed until a later engagement ID exists. + +## Next engagement (not this request) + +After the namespace exists, a **new** audit-core E2 ID can be drafted. It will +need two ordinary tenant-scoped `may_read`/`may_write` fixture senders, TTL +≤ 900s, projected into the runner mount without exposing values to the agent. +That is a separate request and uses a new ID. diff --git a/evidence/README.md b/evidence/README.md index eb38226..e7f7605 100644 --- a/evidence/README.md +++ b/evidence/README.md @@ -1,8 +1,9 @@ # Evidence -This directory stores sanitized run artifacts. `offline-calibration.json` is -generated from repository-created fixtures and proves only that the harness -distinguishes known-good from known-bad behavior. +This directory stores sanitized run artifacts. `offline-calibration.json` and +`offline-e3-calibration.json` are generated from repository-created fixtures +and prove only that the harness distinguishes known-good from known-bad +behavior. They are not target assurance. Before committing target evidence, verify that it contains no response body, credential, database URL, real tenant identifier, or real tenant value. A diff --git a/evidence/offline-e3-calibration.json b/evidence/offline-e3-calibration.json new file mode 100644 index 0000000..ac4be13 --- /dev/null +++ b/evidence/offline-e3-calibration.json @@ -0,0 +1,99 @@ +{ + "cadence": { + "interval": "24h", + "maximum_detection_window": "24h plus run and reporting latency", + "reset_triggers": [ + "schema migration", + "role or grant change", + "RLS policy change", + "security-definer function change", + "posture mechanism change" + ], + "triggered_run_deadline": "before deployment promotion" + }, + "ended_at": "2026-08-22T07:39:54.791280Z", + "evidence_class": "fixture", + "known_bad": [ + { + "outcome": "finding", + "probe_id": "conformance-view-empty", + "reason": "expectation failed: zero_rows" + }, + { + "outcome": "finding", + "probe_id": "unset-guc-reads-none", + "reason": "expectation failed: zero_rows" + }, + { + "outcome": "finding", + "probe_id": "tenant-a-cannot-read-b", + "reason": "expectation failed: zero_rows" + }, + { + "outcome": "finding", + "probe_id": "tenant-a-cannot-insert-b", + "reason": "expectation failed: statement_rejected" + }, + { + "outcome": "finding", + "probe_id": "runtime-lacks-bypassrls", + "reason": "expectation failed: false" + }, + { + "outcome": "finding", + "probe_id": "unsafe-definer-inventory-empty", + "reason": "expectation failed: zero_rows" + }, + { + "outcome": "inconclusive", + "probe_id": "sql-compromise-reset", + "reason": "E3-B observation records the documented SQL-compromise limit" + } + ], + "known_good": [ + { + "outcome": "pass", + "probe_id": "conformance-view-empty", + "reason": "expectation met: zero_rows" + }, + { + "outcome": "pass", + "probe_id": "unset-guc-reads-none", + "reason": "expectation met: zero_rows" + }, + { + "outcome": "pass", + "probe_id": "tenant-a-cannot-read-b", + "reason": "expectation met: zero_rows" + }, + { + "outcome": "pass", + "probe_id": "tenant-a-cannot-insert-b", + "reason": "expectation met: statement_rejected" + }, + { + "outcome": "pass", + "probe_id": "runtime-lacks-bypassrls", + "reason": "expectation met: false" + }, + { + "outcome": "pass", + "probe_id": "unsafe-definer-inventory-empty", + "reason": "expectation met: zero_rows" + }, + { + "outcome": "inconclusive", + "probe_id": "sql-compromise-reset", + "reason": "E3-B observation records the documented SQL-compromise limit" + } + ], + "limitations": [ + "Offline E3 calibration evaluates the harness; it is not target assurance.", + "No database connection or live credential was used.", + "sql-compromise-reset is E3's documented limit and stays inconclusive." + ], + "outcome": "pass", + "run_id": "e3-calibration-2026-08-22T07:39:54.791131Z", + "schema_version": "whitehat-e3-calibration/v1", + "started_at": "2026-08-22T07:39:54.791131Z" +} diff --git a/schemas/target-registration.schema.json b/schemas/target-registration.schema.json index 50b6b9a..993c013 100644 --- a/schemas/target-registration.schema.json +++ b/schemas/target-registration.schema.json @@ -14,13 +14,13 @@ "approval_classes": { "type": "array", "minItems": 1, - "items": {"enum": ["fixture-e2", "live-e2", "e3", "capacity"]} + "items": {"enum": ["fixture-e2", "live-e2", "fixture-e3", "e3", "fixture-capacity", "capacity"]} }, "routes": {"type": "array", "items": {"type": "string"}}, "identities": { "type": "object", "properties": { - "count": {"const": 2}, + "count": {"type": "integer", "minimum": 0, "maximum": 2}, "ttl_seconds": {"type": "integer", "minimum": 1, "maximum": 900} } } diff --git a/src/whitehat_security/cli.py b/src/whitehat_security/cli.py index 145568b..e266bac 100644 --- a/src/whitehat_security/cli.py +++ b/src/whitehat_security/cli.py @@ -9,7 +9,7 @@ from pathlib import Path from .audit_fixtures import AuditFixture, audit_probe_suite from .capacity import CapacitySample, characterize from .differential import execute -from .e3 import CADENCE, PROBES +from .e3 import CADENCE, PROBES, e3_calibration from .engagement import AuthorizationError, Engagement from .fixtures import FixtureService, probe_suite from .model import RunReport, utc_now @@ -95,6 +95,8 @@ def main(argv: list[str] | None = None) -> None: deliver.add_argument("report") deliver.add_argument("--outbox", default="outbox") commands.add_parser("e3-plan") + e3_fix = commands.add_parser("e3-fixtures", help="calibrate E3 probes offline") + e3_fix.add_argument("--output") commands.add_parser("capacity-fixture") message = commands.add_parser("risk-message") message.add_argument("report") @@ -164,6 +166,14 @@ def main(argv: list[str] | None = None) -> None: print(json.dumps({"cadence": CADENCE, "probes": [asdict(probe) for probe in PROBES]}, indent=2, sort_keys=True)) return + if args.command == "e3-fixtures": + result = e3_calibration() + rendered = json.dumps(result, indent=2, sort_keys=True) + "\n" + if args.output: + Path(args.output).write_text(rendered, encoding="utf-8") + else: + print(rendered, end="") + raise SystemExit(0 if result["outcome"] == "pass" else 1) if args.command == "capacity-fixture": print(json.dumps(capacity_fixture(), indent=2, sort_keys=True)) return diff --git a/src/whitehat_security/e3.py b/src/whitehat_security/e3.py index b906bad..695ecaa 100644 --- a/src/whitehat_security/e3.py +++ b/src/whitehat_security/e3.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Callable, Literal -from .model import Outcome +from .model import Outcome, utc_now Expectation = Literal["zero_rows", "statement_rejected", "false", "documented_limit"] Query = Callable[[str], object] @@ -49,6 +49,52 @@ def evaluate(probe: E3Probe, *, rows: int = 0, rejected: bool = False, return E3Result(probe.probe_id, "finding", f"expectation failed: {probe.expectation}") +def fixture_results(*, enforce: bool) -> list[E3Result]: + """In-process known-good/known-bad outcomes. No database connection.""" + results: list[E3Result] = [] + for probe in PROBES: + if probe.expectation == "documented_limit": + results.append(evaluate(probe)) + continue + if enforce: + results.append(evaluate(probe, rows=0, rejected=True, boolean=False)) + else: + results.append(evaluate(probe, rows=1, rejected=False, boolean=True)) + return results + + +def e3_calibration() -> dict: + started = utc_now() + good = fixture_results(enforce=True) + bad = fixture_results(enforce=False) + + def expected(result: E3Result, *, enforce: bool) -> bool: + probe = next(item for item in PROBES if item.probe_id == result.probe_id) + if probe.expectation == "documented_limit": + return result.outcome == "inconclusive" + return result.outcome == ("pass" if enforce else "finding") + + ok = all(expected(item, enforce=True) for item in good) and all( + expected(item, enforce=False) for item in bad + ) + return { + "schema_version": "whitehat-e3-calibration/v1", + "evidence_class": "fixture", + "run_id": f"e3-calibration-{started}", + "started_at": started, + "ended_at": utc_now(), + "outcome": "pass" if ok else "finding", + "cadence": CADENCE, + "known_good": [result.__dict__ for result in good], + "known_bad": [result.__dict__ for result in bad], + "limitations": [ + "Offline E3 calibration evaluates the harness; it is not target assurance.", + "No database connection or live credential was used.", + "sql-compromise-reset is E3's documented limit and stays inconclusive.", + ], + } + + CADENCE = { "interval": "24h", "maximum_detection_window": "24h plus run and reporting latency", diff --git a/src/whitehat_security/plane.py b/src/whitehat_security/plane.py index 3041bdf..7f73743 100644 --- a/src/whitehat_security/plane.py +++ b/src/whitehat_security/plane.py @@ -19,9 +19,12 @@ TERMINAL_IDS = frozenset({ 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: @@ -102,7 +105,7 @@ class IdentityHandle: class CredentialBroker(Protocol): def project(self, engagement: Engagement, registration: dict[str, Any] - ) -> tuple[IdentityHandle, IdentityHandle]: + ) -> tuple[IdentityHandle, ...]: ... def revoke(self, lease_id: str) -> None: @@ -113,7 +116,7 @@ class UnconnectedCustodyBroker: """Fail-closed live broker. It never requests or returns credential values.""" def project(self, engagement: Engagement, registration: dict[str, Any] - ) -> tuple[IdentityHandle, IdentityHandle]: + ) -> tuple[IdentityHandle, ...]: raise AuthorizationError( "live custody broker is not connected; no credential was requested" ) @@ -132,20 +135,25 @@ class LocalBroker: self._leases: dict[str, tuple[str, str]] = {} def project(self, engagement: Engagement, registration: dict[str, Any] - ) -> tuple[IdentityHandle, IdentityHandle]: + ) -> 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") - owner = IdentityHandle("owner", f"/var/run/secrets/whitehat/{lease_id}/token-b", - lease_id, expires_at) - attacker = IdentityHandle("attacker", f"/var/run/secrets/whitehat/{lease_id}/token-a", - lease_id, expires_at) - self._secrets[owner.mount_path] = os.urandom(32) - self._secrets[attacker.mount_path] = os.urandom(32) - self._leases[lease_id] = (owner.mount_path, attacker.mount_path) - return owner, attacker + 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) @@ -159,7 +167,7 @@ class LocalBroker: class PlaneLease: engagement: Engagement registration: dict[str, Any] - identities: tuple[IdentityHandle, IdentityHandle] + identities: tuple[IdentityHandle, ...] watcher: RateWatcher lease_id: str broker_name: str @@ -172,7 +180,7 @@ class PlaneLease: def default_broker(engagement: Engagement) -> CredentialBroker: if (engagement.raw.get("environment") == "fixture" - and engagement.raw.get("approval_class") == "fixture-e2"): + and engagement.raw.get("approval_class") in FIXTURE_CLASSES): return LocalBroker() return UnconnectedCustodyBroker() @@ -212,32 +220,26 @@ def admit(*, engagement: Engagement, registration: dict[str, Any], for route in engagement.raw["routes"]: if route not in registration["routes"]: raise AuthorizationError(f"route not registered: {route}") - if approval_class == "live-e2": - if engagement.raw.get("plane_namespace") != PLANE_NAMESPACE: - raise AuthorizationError("live E2 requires the whitehat plane namespace") - if not engagement.raw.get("runner_image_digest"): - raise AuthorizationError("live E2 requires a pinned runner image digest") - if engagement.raw["credential_max_ttl_seconds"] > STANDING_E2_TTL: - raise AuthorizationError("E2 credential TTL must be at most 900 seconds") - if engagement.raw["environment"] == "fixture": - raise AuthorizationError("live E2 cannot use the fixture environment") - elif approval_class == "fixture-e2": - if engagement.raw["environment"] != "fixture": - raise AuthorizationError("fixture-e2 requires environment=fixture") - elif approval_class in {"e3", "capacity"}: - raise AuthorizationError( - f"{approval_class} requires a separate explicit operator window; " - "the E2 plane will not admit it" - ) + _enforce_class_envelope(approval_class, engagement) selected = broker or default_broker(engagement) - identities = selected.project(engagement, registration) - if len(identities) != 2: - raise AuthorizationError("broker must project exactly two identities") - roles = {handle.role for handle in identities} - if roles != {"owner", "attacker"}: - raise AuthorizationError("broker must project owner and attacker identities") - if any(hasattr(handle, "secret") and getattr(handle, "secret") for handle in identities): - raise AuthorizationError("broker exposed a credential value") + 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"], @@ -249,11 +251,31 @@ def admit(*, engagement: Engagement, registration: dict[str, Any], registration=registration, identities=identities, watcher=watcher, - lease_id=identities[0].lease_id, + 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 { diff --git a/src/whitehat_security/targets.py b/src/whitehat_security/targets.py index a248ab4..a0e2f68 100644 --- a/src/whitehat_security/targets.py +++ b/src/whitehat_security/targets.py @@ -37,9 +37,15 @@ def load_registration(path: str | Path) -> dict[str, Any]: if not data["routes"]: raise AuthorizationError(f"{path}: applicable target must register routes") identities = data["identities"] - if identities.get("count") != 2: + count = identities.get("count") + classes = set(data["approval_classes"]) + if classes & {"fixture-e2", "live-e2"} and count != 2: raise AuthorizationError(f"{path}: E2 registration must project two identities") - if identities.get("ttl_seconds", 0) > 900: + if classes & {"fixture-e3", "e3"} and count != 1: + raise AuthorizationError(f"{path}: E3 registration must project one runtime identity") + if classes & {"fixture-capacity", "capacity"} and count not in {0, 1}: + raise AuthorizationError(f"{path}: capacity registration projects at most one aggressor identity") + if count and identities.get("ttl_seconds", 0) > 900: raise AuthorizationError(f"{path}: identity TTL must be at most 900 seconds") if data.get("known_bad_calibration") in {None, "", "pending"}: raise AuthorizationError(f"{path}: applicable target needs completed known-bad calibration") diff --git a/targets/README.md b/targets/README.md index 2e01591..2a50abc 100644 --- a/targets/README.md +++ b/targets/README.md @@ -9,6 +9,10 @@ honest applicability record the test plane admits against. | `audit-core` | applicable | Two ordinary tenant-scoped senders instantiate E2. Live run still needs a new engagement and plane lease. | | `tenant-engine` | not_applicable | No tenant-A identity without tenant-B authority. | | `flex-auth` | pending | Initial envelope; owner has not confirmed the attacker identity. | +| `fixture-e3` | applicable | In-process E3 evaluator. One runtime identity, no `BYPASSRLS`. | +| `platform-pg` | pending | Live E3 needs a named database, ordinary runtime role, and dated window. | +| `fixture-capacity` | applicable | In-process P1/P2 evaluator. Generates no load. | +| `shared-substrate` | pending | Live capacity needs an operator window and aggressor ceiling. | `not_applicable` is a completed artifact, not a deferral. Do not relabel it to close a workplan. Do not reuse cancelled engagement IDs from `engagements/`. diff --git a/targets/fixture-capacity.json b/targets/fixture-capacity.json new file mode 100644 index 0000000..4e13566 --- /dev/null +++ b/targets/fixture-capacity.json @@ -0,0 +1,29 @@ +{ + "schema_version": "whitehat-target/v1", + "target_id": "fixture-capacity", + "posture_claim": "P1/P2", + "attacker_model": "bounded co-resident saturating its own allowance", + "applicability": "applicable", + "applicability_reason": "In-process evaluator created by this repository. It generates no load and records governor binding plus neighbour degradation.", + "approval_classes": ["fixture-capacity"], + "adapter": "src/whitehat_security/capacity.py", + "probe_pack": "src/whitehat_security/capacity.py", + "known_bad_calibration": "src/whitehat_security/capacity.py", + "fixture_lifecycle": { + "create": "in-process samples", + "delete": "drop process state", + "ids_are_synthetic": true + }, + "egress": "in-process", + "routes": ["baseline", "loaded"], + "identities": { + "count": 0, + "role": "none; fixture generates no network load", + "broker_audience": "whitehat-capacity/fixture" + }, + "abort_telemetry": [ + "governor_unbound", + "aggressor_exceeded_ceiling", + "missing_neighbour_sample" + ] +} diff --git a/targets/fixture-e3.json b/targets/fixture-e3.json new file mode 100644 index 0000000..3e22734 --- /dev/null +++ b/targets/fixture-e3.json @@ -0,0 +1,39 @@ +{ + "schema_version": "whitehat-target/v1", + "target_id": "fixture-e3", + "posture_claim": "E3", + "attacker_model": "E3-A omitted-predicate accident; E3-B documented SQL-compromise limit", + "applicability": "applicable", + "applicability_reason": "In-process evaluator created by this repository. It uses one ordinary runtime identity and never grants BYPASSRLS or superuser.", + "approval_classes": ["fixture-e3"], + "adapter": "src/whitehat_security/e3.py", + "probe_pack": "src/whitehat_security/e3.py", + "known_bad_calibration": "src/whitehat_security/e3.py", + "fixture_lifecycle": { + "create": "in-process constructor", + "delete": "drop process state", + "ids_are_synthetic": true + }, + "egress": "in-process", + "routes": [ + "conformance", + "unset_guc", + "wrong_tenant_read", + "wrong_tenant_insert", + "runtime_bypassrls", + "unsafe_definer", + "reset_to_b" + ], + "identities": { + "count": 1, + "role": "ordinary runtime database role without BYPASSRLS", + "ttl_seconds": 900, + "broker_audience": "whitehat-e3/fixture" + }, + "abort_telemetry": [ + "statement_timeout", + "unexpected_or_non_fixture_data", + "missing_attribution", + "privilege_escalation" + ] +} diff --git a/targets/platform-pg-e3.json b/targets/platform-pg-e3.json new file mode 100644 index 0000000..ed4fc56 --- /dev/null +++ b/targets/platform-pg-e3.json @@ -0,0 +1,22 @@ +{ + "schema_version": "whitehat-target/v1", + "target_id": "platform-pg", + "posture_claim": "E3", + "attacker_model": "E3-A omitted-predicate accident; E3-B documented SQL-compromise limit", + "applicability": "pending", + "applicability_reason": "Named in the rules of engagement as outside the initial API-probe envelope. A live run needs the database owner to confirm an ordinary runtime role without BYPASSRLS, the conformance view, and a dated window. This registration does not authorize a connection.", + "approval_classes": ["e3"], + "routes": [], + "identities": { + "count": 1, + "role": "ordinary runtime database role without BYPASSRLS or superuser", + "ttl_seconds": 900, + "broker_audience": "whitehat-e3/platform-pg" + }, + "abort_telemetry": [ + "statement_timeout", + "unexpected_or_non_fixture_data", + "missing_attribution", + "privilege_escalation" + ] +} diff --git a/targets/shared-substrate-capacity.json b/targets/shared-substrate-capacity.json new file mode 100644 index 0000000..4ffad52 --- /dev/null +++ b/targets/shared-substrate-capacity.json @@ -0,0 +1,21 @@ +{ + "schema_version": "whitehat-target/v1", + "target_id": "shared-substrate", + "posture_claim": "P1/P2", + "attacker_model": "bounded co-resident saturating its own allowance", + "applicability": "pending", + "applicability_reason": "No operator-approved substrate window or aggressor ceiling exists. Saturation against a shared rail is an outage if run without those bounds. This registration does not authorize load.", + "approval_classes": ["capacity"], + "routes": [], + "identities": { + "count": 0, + "role": "none until a window names the aggressor consumer", + "broker_audience": "whitehat-capacity/shared-substrate" + }, + "abort_telemetry": [ + "governor_unbound", + "aggressor_exceeded_ceiling", + "missing_neighbour_sample", + "shared_substrate_abort_threshold" + ] +} diff --git a/tests/test_cli.py b/tests/test_cli.py index c6f4241..25e68c3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,7 +16,7 @@ def test_validate_engagement_reports_clean_denial(tmp_path, capsys): def test_validate_targets_accepts_catalog(capsys): main(["validate-targets", "targets"]) - assert capsys.readouterr().out.startswith("validated 4 target registrations") + assert capsys.readouterr().out.startswith("validated 8 target registrations") def test_kill_switch_is_clear_by_default(capsys): diff --git a/tests/test_plane.py b/tests/test_plane.py index 03a8a4d..004ef5f 100644 --- a/tests/test_plane.py +++ b/tests/test_plane.py @@ -8,6 +8,7 @@ from whitehat_security.differential import execute, execute_authorized from whitehat_security.engagement import AuthorizationError, Engagement 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, admit, cleanup, retired_ids, @@ -59,6 +60,10 @@ def test_catalog_loads_honest_applicability(): assert catalog["audit-core"]["applicability"] == "applicable" assert catalog["tenant-engine"]["applicability"] == "not_applicable" assert catalog["flex-auth"]["applicability"] == "pending" + assert catalog["fixture-e3"]["applicability"] == "applicable" + assert catalog["fixture-capacity"]["applicability"] == "applicable" + assert catalog["platform-pg"]["applicability"] == "pending" + assert catalog["shared-substrate"]["applicability"] == "pending" def test_retired_ids_include_cancelled_records(): @@ -171,6 +176,82 @@ def test_e3_is_not_admitted_by_the_e2_plane(tmp_path): ) +def test_fixture_e3_projects_one_runtime_identity(tmp_path): + broker = LocalBroker() + engagement = load_engagement(tmp_path, fixture_record( + target_id="fixture-e3", approval_class="fixture-e3", + techniques=["e3-rls"], routes=["conformance"], + )) + lease = admit( + engagement=engagement, registration=load_registration("targets/fixture-e3.json"), + broker=broker, kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, retired=set(), + ) + assert len(lease.identities) == 1 + assert lease.identities[0].role == "runtime" + assert cleanup(lease, broker)["credential_revocation"] == "revoked" + + +def test_fixture_capacity_projects_no_identities(tmp_path): + broker = LocalBroker() + engagement = load_engagement(tmp_path, fixture_record( + target_id="fixture-capacity", approval_class="fixture-capacity", + techniques=["p1-noisy-neighbour"], routes=["baseline"], + )) + lease = admit( + engagement=engagement, registration=load_registration("targets/fixture-capacity.json"), + broker=broker, kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, retired=set(), + ) + assert lease.identities == () + assert cleanup(lease, broker)["credential_revocation"] == "revoked" + + +def test_pending_e3_target_is_refused(tmp_path): + engagement = load_engagement(tmp_path, fixture_record( + target_id="platform-pg", approval_class="e3", + techniques=["e3-rls"], environment="build", + plane_namespace="whitehat", runner_image_digest="sha256:abc", + database="platform-pg", routes=["conformance"], + )) + with pytest.raises(AuthorizationError, match="pending"): + admit( + engagement=engagement, + registration=load_registration("targets/platform-pg-e3.json"), + broker=LocalBroker(), kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, retired=set(), + ) + + +def test_live_e3_without_database_fails_closed(tmp_path): + registration = load_registration("targets/fixture-e3.json") + registration["target_id"] = "fixture-e3-live" + registration["approval_classes"] = ["e3"] + engagement = load_engagement(tmp_path, fixture_record( + target_id="fixture-e3-live", approval_class="e3", + techniques=["e3-rls"], environment="build", + plane_namespace="whitehat", runner_image_digest="sha256:abc", + routes=["conformance"], + )) + with pytest.raises(AuthorizationError, match="named database"): + admit( + engagement=engagement, registration=registration, + broker=LocalBroker(), kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, retired=set(), + ) + + +def test_e3_calibration_keeps_documented_limit_inconclusive(): + report = e3_calibration() + assert report["outcome"] == "pass" + by_id = {item["probe_id"]: item for item in report["known_good"]} + assert by_id["sql-compromise-reset"]["outcome"] == "inconclusive" + assert by_id["conformance-view-empty"]["outcome"] == "pass" + bad = {item["probe_id"]: item for item in report["known_bad"]} + assert bad["conformance-view-empty"]["outcome"] == "finding" + assert bad["sql-compromise-reset"]["outcome"] == "inconclusive" + + def test_rate_and_concurrency_ceilings(tmp_path): watcher = RateWatcher(per_minute=10, max_concurrency=1, max_requests=1) watcher.acquire() diff --git a/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md b/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md index 8c3f38a..4d0c4db 100644 --- a/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md +++ b/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md @@ -215,12 +215,13 @@ notices. This repo is that something. control the interval between runs *is* the exposure window, and ADR-0003 leaves the number to this repo. Set it, and state the resulting window in the record. -Implementation progress, 2026-08-21: the cadence is fixed at 24 hours plus run +Implementation progress, 2026-08-22: the cadence is fixed at 24 hours plus run and reporting latency, with event-triggered pre-promotion runs after schema, role, RLS or security-definer changes. `src/whitehat_security/e3.py` encodes the -seven expected outcomes and keeps the SQL-compromise GUC reset labeled as E3's -documented limit. A live database run still requires its separate authorized -target and window. +seven expected outcomes, keeps the SQL-compromise GUC reset labeled as E3's +documented limit, and calibrates known-good/known-bad in-process. `platform-pg` +is registered `pending`. A live database run still requires its separate +authorized target, named database, ordinary runtime role, and window. ### T06 — Noisy-neighbour characterisation (the P1/P2 artifact) @@ -244,11 +245,12 @@ acceptable. Runs inside a declared window per T01 — on a single-node rail with a six- consumer connection ceiling, a saturation probe is an outage if run carelessly. -Implementation progress, 2026-08-21: `src/whitehat_security/capacity.py` +Implementation progress, 2026-08-22: `src/whitehat_security/capacity.py` records baseline/loaded latency, errors and throughput per consumer, governor -binding, aggressor peak/ceiling and neighbour degradation. No live load has -been generated; the required operator-approved substrate window and ceilings -do not yet exist. +binding, aggressor peak/ceiling and neighbour degradation. The in-process +fixture is registered as `fixture-capacity`. `shared-substrate` is `pending`. +No live load has been generated; the required operator-approved substrate +window and aggressor ceiling do not yet exist. ### T07 — Reporting into risk-nexus @@ -297,9 +299,10 @@ Completed 2026-08-22 as a repository contract, not a cluster provision: - Rate watcher, lease cleanup, default-deny plane manifests, runner identity. - Automatic outbox delivery of target reports only. -`ops-mason` still has to provision namespace, network policy and a real -custody projection after a new engagement ID exists. That provision is not -this task, and this task does not authorize it. +`ops-mason` was asked on 2026-08-22 to provision only the namespace, default +deny policy and runner service account from `plane/`. That message does not +authorize a pod, a credential, or traffic. A real custody projection still +waits on a new engagement ID. ## Sequencing