From 95129d7a35c8999be359b163b554853c9c6afa3a Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 22 Aug 2026 00:44:21 +0200 Subject: [PATCH] Add governed test plane and close T04/T08 Encode fail-closed admission, target registrations, and a credential broker that never returns secret values. Calibrate audit-core shaped probes in-process. Send no packets and request no live credentials. Assistant: grok Assistant-Session: 01a02670-3345-76f2-a014-70fde8e2a2bb --- Makefile | 2 + README.md | 13 +- SCOPE.md | 17 +- WORK-RECORDS.md | 3 +- docs/operator-runbook.md | 24 +- docs/reporting.md | 11 +- docs/rules-of-engagement.md | 24 +- docs/session-cutoff-2026-08-22.md | 7 + docs/target-applicability.md | 6 +- docs/test-plane.md | 48 +++ engagements/README.md | 5 + engagements/runtime/README.md | 4 + engagements/template.json | 7 +- evidence/offline-calibration.json | 350 ++++++++++++++++-- outbox/README.md | 5 + plane/README.md | 27 ++ plane/namespace.yaml | 9 + plane/network-policy.yaml | 40 ++ plane/runner-pod.yaml | 53 +++ plane/service-account.yaml | 9 + schemas/target-registration.schema.json | 28 ++ src/whitehat_security/audit_fixtures.py | 83 +++++ src/whitehat_security/cli.py | 58 ++- src/whitehat_security/differential.py | 20 +- src/whitehat_security/plane.py | 263 +++++++++++++ src/whitehat_security/reporting.py | 12 + src/whitehat_security/targets.py | 61 +++ targets/README.md | 14 + targets/audit-core-e2.json | 36 ++ targets/fixture-e2.json | 37 ++ targets/flex-auth-e2.json | 22 ++ targets/tenant-engine-e2.json | 22 ++ tests/test_cli.py | 40 ++ tests/test_plane.py | 234 ++++++++++++ .../WHITEHAT-WP-0001-cross-tenant-evidence.md | 108 ++++-- 35 files changed, 1599 insertions(+), 103 deletions(-) create mode 100644 docs/test-plane.md create mode 100644 engagements/runtime/README.md create mode 100644 outbox/README.md create mode 100644 plane/README.md create mode 100644 plane/namespace.yaml create mode 100644 plane/network-policy.yaml create mode 100644 plane/runner-pod.yaml create mode 100644 plane/service-account.yaml create mode 100644 schemas/target-registration.schema.json create mode 100644 src/whitehat_security/audit_fixtures.py create mode 100644 src/whitehat_security/plane.py create mode 100644 src/whitehat_security/targets.py create mode 100644 targets/README.md create mode 100644 targets/audit-core-e2.json create mode 100644 targets/fixture-e2.json create mode 100644 targets/flex-auth-e2.json create mode 100644 targets/tenant-engine-e2.json create mode 100644 tests/test_plane.py diff --git a/Makefile b/Makefile index 913ebc3..98656c4 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,8 @@ check: test PYTHONPATH=src python3 -m whitehat_security.cli validate-packs probe-packs + PYTHONPATH=src python3 -m whitehat_security.cli validate-targets targets + PYTHONPATH=src python3 -m whitehat_security.cli kill-switch test: PYTHONPATH=src python3 -m pytest -q diff --git a/README.md b/README.md index e904167..7003d58 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ probes most worth having are the ones an author would not think to write. - Operator runbook: [`docs/operator-runbook.md`](docs/operator-runbook.md) - Session cutoff and redesign handoff: [`docs/session-cutoff-2026-08-22.md`](docs/session-cutoff-2026-08-22.md) +- Governed test plane: [`docs/test-plane.md`](docs/test-plane.md) +- Target registrations: [`targets/`](targets/) - Target packs: [`probe-packs/`](probe-packs/) ## Offline calibration @@ -37,8 +39,9 @@ make check make fixture-evidence ``` -This proves that every current differential probe passes an enforcing fixture -and detects the corresponding missing-tenant-predicate fixture. It sends no -network traffic and is not evidence about a target service. Live execution -requires a current target-specific record based on -[`engagements/template.json`](engagements/template.json). +This proves that every current differential probe, including the audit-core +shaped pack, passes an enforcing fixture and detects the corresponding +missing-tenant-predicate fixture. It sends no network traffic and is not +evidence about a target service. Live execution requires a current +target-specific record, a registered applicable target, and plane admission. +See [`docs/test-plane.md`](docs/test-plane.md). diff --git a/SCOPE.md b/SCOPE.md index 152d44c..e07f1bc 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -69,17 +69,20 @@ 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: the differential core and audit-core/tenant-engine - target packs exist, but target run records are still gated. -- `T04` is in progress: all generic read/write operations detect the - known-bad fixture; target-specific calibration follows executable adapters. +- `T03` is in progress: the differential core, audit-core adapter and test + plane admission exist. `tenant-engine` is registered `not_applicable` for + 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. -- `T07` is in progress with a report schema and risk-nexus message formatter; - the first target report has not yet been produced. +- `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 + this repository. - No live probe traffic is authorized; each target still requires its own - engagement record and approvals. + engagement record, plane admission and approvals. ## Relationships diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index b300557..2561b1b 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -12,7 +12,8 @@ | task | WHITEHAT-WP-0001-T01 | done | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | | task | WHITEHAT-WP-0001-T02 | done | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | | task | WHITEHAT-WP-0001-T03 | progress | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | -| task | WHITEHAT-WP-0001-T04 | progress | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | +| task | WHITEHAT-WP-0001-T04 | done | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | | task | WHITEHAT-WP-0001-T05 | progress | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | | task | WHITEHAT-WP-0001-T06 | progress | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | | task | WHITEHAT-WP-0001-T07 | progress | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | +| task | WHITEHAT-WP-0001-T08 | done | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 9d7c4d6..246e5f1 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -18,6 +18,23 @@ and to produce a finding against the fixture with its tenant predicate removed. `evidence/offline-calibration.json` is calibration evidence, not target assurance. +## Test plane + +Live execution is gated by [`docs/test-plane.md`](test-plane.md). Before any +target run: + +```sh +PYTHONPATH=src python3 -m whitehat_security.cli validate-targets targets +PYTHONPATH=src python3 -m whitehat_security.cli kill-switch +``` + +`admit-plane` must succeed against a **new** engagement ID. The default live +broker is unconnected and fails closed without requesting a credential. +Create `plane/KILL` to abort independently of the runner. + +Cancelled IDs `WH-ENG-20260821-AUDIT-E2` and `WH-ENG-20260821-TENANT-E2` are +retired. + ## Prepare a target run 1. Select the target's probe pack and review every route with its owner. @@ -27,10 +44,11 @@ assurance. 4. Provision only the two disposable tenants/objects named in the record. 5. Obtain the ordinary modeled credential through its custody lane. Do not put its value in the record or shell history. -6. Validate within the approved window: +6. Validate within the approved window, then admit the plane: ```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 ``` 7. Confirm target identity/revision and probe attribution before the first @@ -61,7 +79,9 @@ unexpected response by broadening routes, identities, discovery, or privilege. proven; otherwise `pass`. - Validate the record against `schemas/run-report.schema.json`. - Route both passing and finding target reports to `risk-nexus`; never assign - severity. The message formatter is `whitehat risk-message `. + severity. Queue the delivery with `whitehat deliver ` (fixture + calibration is refused). The message formatter is + `whitehat risk-message `. - Schedule the next run from the target's declared cadence. No target run is complete until its dated report and risk-nexus delivery are diff --git a/docs/reporting.md b/docs/reporting.md index 55ec0c8..9a00868 100644 --- a/docs/reporting.md +++ b/docs/reporting.md @@ -18,6 +18,13 @@ The reporter does not edit risk-nexus grading fields. `risk-nexus` decides whether an observation is a finding or note, and owns severity, disclosure, review cadence and escalation. -Offline calibration stays in this repository and is plainly labeled -`evidence_class: fixture`; it is not sent as if it were a target result. +Queue a target report without assigning severity: + +```sh +PYTHONPATH=src python3 -m whitehat_security.cli deliver evidence/.json --outbox outbox +``` + +Offline calibration stays in this repository and is plainly labeled +`evidence_class: fixture`; it is not sent as if it were a target result. The +deliver command refuses fixture evidence. diff --git a/docs/rules-of-engagement.md b/docs/rules-of-engagement.md index 407dfd3..6892714 100644 --- a/docs/rules-of-engagement.md +++ b/docs/rules-of-engagement.md @@ -2,7 +2,7 @@ Status: **accepted** -Version: 0.1 +Version: 0.2 Date: 2026-08-18 @@ -137,7 +137,24 @@ known-bad fixture pass is a finding until independently explained. Every live probe must first fail against its known-bad fixture and pass against the known-good fixture in the same revision. -## 10. Approval +## 10. Governed test plane + +Live runs are admitted only through the test plane documented in +[`test-plane.md`](test-plane.md): dedicated namespace, default-deny networking, +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. + +Cancelled engagement identifiers are terminal and must not be reused. + +This section authorizes no live target and no packet. + +## 11. Approval Approved by: **repository operator**, by explicit approval in the coordinating session @@ -149,3 +166,6 @@ Approved scope: these rules are accepted as the operating boundary for fixture work. It does **not** authorize a live target or a packet to any target. Every live run still requires the engagement record, target-owner acknowledgement and any technique-specific operator approval required by §1. + +v0.2 (2026-08-22) adds §10. It tightens admission; it does not expand +authorization or replace the 2026-08-21 acceptance. diff --git a/docs/session-cutoff-2026-08-22.md b/docs/session-cutoff-2026-08-22.md index e7d66da..e501ce2 100644 --- a/docs/session-cutoff-2026-08-22.md +++ b/docs/session-cutoff-2026-08-22.md @@ -76,3 +76,10 @@ each run: Resume only with new engagement IDs after that plane can issue the correct test identities and enforce the complete envelope. The existing workplan remains the backlog: T01 and T02 are done; T03, T04, T05, T06, and T07 remain in progress. + +## Progress after cutoff + +On 2026-08-22 the plane contract was encoded in this repository: +[`docs/test-plane.md`](test-plane.md), `targets/`, `plane/`, and +`whitehat_security.plane`. No live packet was sent. No credential was +requested. Cancelled engagement IDs remain terminal. diff --git a/docs/target-applicability.md b/docs/target-applicability.md index 9e760d2..45946fe 100644 --- a/docs/target-applicability.md +++ b/docs/target-applicability.md @@ -1,10 +1,12 @@ # Target applicability for WHITEHAT-WP-0001 -Status: active review, 2026-08-22 +Status: encoded in `targets/`, 2026-08-22 The E2 attacker model requires an authenticated identity that is legitimately authorized for tenant A and not tenant B. A target name alone is insufficient; -the target must expose a boundary where that identity exists. +the target must expose a boundary where that identity exists. Machine-readable +records live in [`targets/`](../targets/README.md). The test plane will not +admit a `not_applicable` or `pending` target. ## audit-core — applicable diff --git a/docs/test-plane.md b/docs/test-plane.md new file mode 100644 index 0000000..8e7d6e7 --- /dev/null +++ b/docs/test-plane.md @@ -0,0 +1,48 @@ +# Governed test plane + +This is the missing control from the 2026-08-22 cutoff. It is a fail-closed +admission path, not a standing authorization to probe. + +## What the plane is + +A live E2 run is admitted only when every item below is true: + +1. The target is registered in `targets/` with `applicability: applicable`. +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). +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. +7. The kill switch is clear. +8. The rate watcher is attached. Cleanup revokes the lease. + +If any item is false, `whitehat admit-plane` exits 2 and no identity is +requested. + +## What the plane is not + +- Not authorization. Rules of engagement §1 still apply. +- Not a packet. Manifests in `plane/` are a contract for `ops-mason`. +- Not a secret broker. `UnconnectedCustodyBroker` is the default for every + non-fixture environment and raises before any custody call. +- Not a way to finish tenant-engine E2. That target is `not_applicable`. + +## Operator commands + +```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 kill-switch +PYTHONPATH=src python3 -m whitehat_security.cli deliver evidence/.json --outbox outbox +``` + +Create `plane/KILL` to abort independently of the runner process. + +## Retired identifiers + +`WH-ENG-20260821-AUDIT-E2` and `WH-ENG-20260821-TENANT-E2` are terminal. A new +live run needs a new ID after the cluster plane exists and the custody broker +can project identities without exposing values. diff --git a/engagements/README.md b/engagements/README.md index ed4ac7f..87d34e2 100644 --- a/engagements/README.md +++ b/engagements/README.md @@ -9,3 +9,8 @@ or route. An engagement record contains authorization metadata only. Never put a token, password, database URL, secret path value, or real tenant identifier here. +Live admission also requires a matching file in `targets/` and a plane lease +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. + diff --git a/engagements/runtime/README.md b/engagements/runtime/README.md new file mode 100644 index 0000000..a99a342 --- /dev/null +++ b/engagements/runtime/README.md @@ -0,0 +1,4 @@ +The `audit-core-e2-pod.yaml` manifest is bound to cancelled engagement +`WH-ENG-20260821-AUDIT-E2`. Do not apply it. New live runs use +`plane/runner-pod.yaml` with a new unretired engagement ID after the plane +exists. diff --git a/engagements/template.json b/engagements/template.json index ba20b8c..da88b84 100644 --- a/engagements/template.json +++ b/engagements/template.json @@ -5,10 +5,14 @@ "approved_at": "YYYY-MM-DDTHH:MM:SSZ", "expires_at": "YYYY-MM-DDTHH:MM:SSZ", "target": "https://target.example.invalid", + "target_id": "registered-target-id", "target_owner": "responsible repo or party", "target_owner_acknowledged_at": "YYYY-MM-DDTHH:MM:SSZ", "environment": "build", - "namespace": "namespace if applicable", + "approval_class": "live-e2", + "plane_namespace": "whitehat", + "runner_image_digest": "sha256:pin-me", + "namespace": "whitehat", "source": "probe source and network position", "routes": ["GET /exact/in-scope/route"], "fixture_ids": ["synthetic-tenant-a", "synthetic-tenant-b"], @@ -19,6 +23,7 @@ "prohibited_techniques": ["discovery", "saturation", "real-data collection"], "rate_limit_per_minute": 10, "max_concurrency": 1, + "maximum_requests": 30, "window_start": "YYYY-MM-DDTHH:MM:SSZ", "window_end": "YYYY-MM-DDTHH:MM:SSZ", "operator_contact": "operator contact", diff --git a/evidence/offline-calibration.json b/evidence/offline-calibration.json index 5aeb137..5cb3f96 100644 --- a/evidence/offline-calibration.json +++ b/evidence/offline-calibration.json @@ -1,5 +1,5 @@ { - "ended_at": "2026-08-21T21:51:31.697322Z", + "ended_at": "2026-08-21T22:43:03.740339Z", "evidence_class": "fixture", "expected": { "known_bad": "finding", @@ -12,7 +12,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "eaad30a4dc5b105af70231b5a4e72b9fc61e64a73a24074f919efd6fb5f9e9ae", + "run_digest": "2804e68bca6d4652529b13014b55efa296eb81101726ff8d6634dff4b97c5f32", "schema": [ "$", "$.error:str" @@ -23,7 +23,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 3, - "run_digest": "5b9c2a46c8b84818280ce239e6bb01157bbcdf93165b8bfa97dd2c8c9121280f", + "run_digest": "6095953dfd3d463aa844f1c51d20745bb5c0c5a79ce223c7445e57b6c323cd31", "schema": [ "$", "$.id:str", @@ -36,7 +36,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 3, - "run_digest": "5b9c2a46c8b84818280ce239e6bb01157bbcdf93165b8bfa97dd2c8c9121280f", + "run_digest": "6095953dfd3d463aa844f1c51d20745bb5c0c5a79ce223c7445e57b6c323cd31", "schema": [ "$", "$.id:str", @@ -60,7 +60,7 @@ "content_type": "application/json", "count": 0, "fixture_match_count": 0, - "run_digest": "de0385fce9a44845bc70a21d8561a6b3211538b2c32d9b35789042dfe594f223", + "run_digest": "4a8cefe3ea0fa3fb888d8c578dd227c6a12216868da197f82b1e4de23aedd0f0", "schema": [ "$", "$.items[]" @@ -71,7 +71,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 3, - "run_digest": "3985d8408fbce3de8f6cd6dbf9b2d70257bbaee903cc5bc5a3da61610f6689c0", + "run_digest": "29e86fd84a1c6aa038d8d1278d52c4470135bcc81db39b800940132e2761bc7b", "schema": [ "$", "$.items[]", @@ -86,7 +86,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 3, - "run_digest": "3985d8408fbce3de8f6cd6dbf9b2d70257bbaee903cc5bc5a3da61610f6689c0", + "run_digest": "29e86fd84a1c6aa038d8d1278d52c4470135bcc81db39b800940132e2761bc7b", "schema": [ "$", "$.items[]", @@ -111,7 +111,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "857d9822bdfb939e4f126efa577be8d4a9e64ab023f5d611ca581498a963d048", + "run_digest": "4a2f56562162e6bb6745ca18b8e7325c9565235506d3870bb907ba588ac4fadc", "schema": [ "$", "$.error:str" @@ -122,7 +122,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 2, - "run_digest": "06e7ab37f09fcbcb142cf1d09927885685ef9f9d273f2ec49186c40a16c04c18", + "run_digest": "5e5ea8b63082ebc7bcce23fd5b66098a2e4ee092dba8a1c4c2a562e70e7c9550", "schema": [ "$", "$.id:str", @@ -134,7 +134,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "bf9bfc96947e5da52b13b9e027ad6f5c115da06c05dc7c79b82ea6b3a6514965", + "run_digest": "3a686f08bad5214b56d3e0aa05c141998519809e5cb16499c3f3784a0becbe4a", "schema": [ "$", "$.id:str" @@ -156,7 +156,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "65bb2998672433c35d92bdb9b15965b2eb72776280b5cecc95c9d94e9149af12", + "run_digest": "44a1c9a896f575f82c40a744d41519a1f84469cfe22fc889f6f68756da57d77f", "schema": [ "$", "$.error:str" @@ -167,7 +167,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 3, - "run_digest": "7e3e05697523dde79079b1a5cf6ed50e12177962742b8b7ddcf62d025daad6ad", + "run_digest": "f48030bb08aa0d07380f0cec2ff0f919c5312fa780e3388984e8d4873440189f", "schema": [ "$", "$.id:str", @@ -180,7 +180,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 3, - "run_digest": "49d1b1ff789c3cf3709f0e7fe0041dc6cdbbfc7106ee736bb9d8acfcbb13568b", + "run_digest": "5baf8016840cabb74202e381f5f163a463c1279382d8f74332f3b602f1c9704e", "schema": [ "$", "$.id:str", @@ -205,7 +205,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "15604312df72ed94d0b08f047fbe1e185e3bd6f49e41cb6cb6b779ba0c06be0a", + "run_digest": "6d9c1f7d5d5c20afb8d8337ba4214e04be05e1b2336edc6263720c2f349a04cc", "schema": [ "$", "$.error:str" @@ -216,7 +216,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 1, - "run_digest": "ec66dc06c3b3202bc3e2f233c0eed22848106a5dbb2d39ca90233bfbb853e852", + "run_digest": "32d054c129bb639b1beb7b8ca83a9556b88558facc84132e9fec63950cb4e66d", "schema": [ "$", "$.deleted:str" @@ -227,7 +227,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 3, - "run_digest": "961561666050ffa3e74a3ccf98a27859fd657a962767ff7a507cb6cca7a1cd5b", + "run_digest": "3853f8bf9f473716ce4fced2d44f3a58b411c078afc6f131b4a47eda4929f798", "schema": [ "$", "$.id:str", @@ -245,6 +245,154 @@ "tenant-B fixture state changed", "attacker response differs from the absent-object reference" ] + }, + { + "observations": { + "absent": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 0, + "run_digest": "b6638dcf2a3cf24a99ea503a0661f8ec3011b22689bdd6777c98693e4d49f80c", + "schema": [ + "$", + "$.error:str" + ], + "status": 404 + }, + "attacker": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 3, + "run_digest": "099b50b271a61251f76de35b36f832e9d52711cdd3d2e086464e52f8ab075ec7", + "schema": [ + "$", + "$.correlation_id:str", + "$.id:str", + "$.label:str", + "$.tenant:str" + ], + "status": 200 + }, + "owner": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 3, + "run_digest": "099b50b271a61251f76de35b36f832e9d52711cdd3d2e086464e52f8ab075ec7", + "schema": [ + "$", + "$.correlation_id:str", + "$.id:str", + "$.label:str", + "$.tenant:str" + ], + "status": 200 + } + }, + "operation": "read", + "outcome": "finding", + "probe_id": "audit-event-by-id", + "reasons": [ + "attacker response contains a tenant-B fixture marker", + "attacker response differs from the absent-object reference" + ] + }, + { + "observations": { + "absent": { + "content_type": "application/json", + "count": 0, + "fixture_match_count": 0, + "run_digest": "738fa714a179543df7c4cdf663dcd27d2c636e73a5c6aa006c07b083d513dd0d", + "schema": [ + "$", + "$.events[]" + ], + "status": 200 + }, + "attacker": { + "content_type": "application/json", + "count": 2, + "fixture_match_count": 3, + "run_digest": "02a06a47c2f9de17df2d857b945d5629196e74d40314e0a1842e1f728a440c8d", + "schema": [ + "$", + "$.events[]", + "$.events[]", + "$.events[].correlation_id:str", + "$.events[].id:str", + "$.events[].label:str", + "$.events[].tenant:str" + ], + "status": 200 + }, + "owner": { + "content_type": "application/json", + "count": 2, + "fixture_match_count": 3, + "run_digest": "02a06a47c2f9de17df2d857b945d5629196e74d40314e0a1842e1f728a440c8d", + "schema": [ + "$", + "$.events[]", + "$.events[]", + "$.events[].correlation_id:str", + "$.events[].id:str", + "$.events[].label:str", + "$.events[].tenant:str" + ], + "status": 200 + } + }, + "operation": "read", + "outcome": "finding", + "probe_id": "audit-correlation-slice", + "reasons": [ + "attacker response contains a tenant-B fixture marker" + ] + }, + { + "observations": { + "absent": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 0, + "run_digest": "a8ee76d5c9e51366ae25603241b71d3377f08f3aaded0ff1537b8f1f9640b851", + "schema": [ + "$", + "$.error:str" + ], + "status": 403 + }, + "attacker": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 2, + "run_digest": "f57a7a61abe2d3e7b3af734900863d295a4b6eed8579729b86ad75d73dcb85bb", + "schema": [ + "$", + "$.id:str", + "$.tenant:str" + ], + "status": 202 + }, + "owner": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 0, + "run_digest": "2801a3c1b5f46865ac24d24a748dc7124ac3982b85dd20bdb596c033c008708f", + "schema": [ + "$", + "$.id:str" + ], + "status": 202 + } + }, + "operation": "create", + "outcome": "finding", + "probe_id": "audit-append-as-b", + "reasons": [ + "attacker response contains a tenant-B fixture marker", + "tenant-B fixture state changed" + ] } ], "known_good": [ @@ -254,7 +402,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "eddeeb2191de71535b4d2c06a88807ba797dff4b2c13481f06c522e0315bb7ea", + "run_digest": "056e35eda0bacdda3e2a862a5e325f5fe090b8090f83037b5172200ff7b0e6dc", "schema": [ "$", "$.error:str" @@ -265,7 +413,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "eddeeb2191de71535b4d2c06a88807ba797dff4b2c13481f06c522e0315bb7ea", + "run_digest": "056e35eda0bacdda3e2a862a5e325f5fe090b8090f83037b5172200ff7b0e6dc", "schema": [ "$", "$.error:str" @@ -276,7 +424,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 3, - "run_digest": "13c9649399717d67dec6441a3e6ee046a389a84549fc39538548bb7e2496df37", + "run_digest": "49ea6432de7c9a88d2b32a4601d4fa811436909127084edaed522c28bc627235", "schema": [ "$", "$.id:str", @@ -297,7 +445,7 @@ "content_type": "application/json", "count": 0, "fixture_match_count": 0, - "run_digest": "db750d043a8e87c08f86b35735cbde33060236fbf673934edd16a2ef8077b314", + "run_digest": "61f8492a8388851bdfe14592a200635255cbe24d068f524ae6de9d6b68260c3d", "schema": [ "$", "$.items[]" @@ -308,7 +456,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "a1cbcf47d0c2f17a97abc59e2a559729fc027337f4360be44287764a6b061e7e", + "run_digest": "3add37393c96826ead72b129dbf8ac0efd07c254231a7a5a40640d953e82320e", "schema": [ "$", "$.items[]", @@ -323,7 +471,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 3, - "run_digest": "1ce9addbaf53fb9dd8ced38fd0bbd237e7d338ac3ad31a2d8e06e168620d91c6", + "run_digest": "479d2a3ab215dd0a629ca719163e1454439b782193e5202bf34e65c0c3a7ccf9", "schema": [ "$", "$.items[]", @@ -346,7 +494,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "2375ef32a41c0e05c19b2e362c0b2d1e490b0ecf863b7db9a3fb2c7824650f88", + "run_digest": "e33ae1117bfbf3ea0f2beee3ada7a4b2e7105e3f8ff9ea0f32e1a458e97c8277", "schema": [ "$", "$.error:str" @@ -357,7 +505,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "2375ef32a41c0e05c19b2e362c0b2d1e490b0ecf863b7db9a3fb2c7824650f88", + "run_digest": "e33ae1117bfbf3ea0f2beee3ada7a4b2e7105e3f8ff9ea0f32e1a458e97c8277", "schema": [ "$", "$.error:str" @@ -368,7 +516,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "93a604c1def0003e5ba2ac6bb71607adfaa5ad27e6f314c9e135d22ed91f4d50", + "run_digest": "11a2bc634aa1f2cda1bb7f137339bc47f69c21f025b47fa6fd5795c0840b4adf", "schema": [ "$", "$.id:str" @@ -387,7 +535,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "128ec07ad319e1bd03582118fcb672edee8bdb90cf04259fd1c70565a49dfb24", + "run_digest": "31c30991423c8f2d3dd4757e0fdd188b37f6470a6babad1d9700aef0f451da32", "schema": [ "$", "$.error:str" @@ -398,7 +546,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "128ec07ad319e1bd03582118fcb672edee8bdb90cf04259fd1c70565a49dfb24", + "run_digest": "31c30991423c8f2d3dd4757e0fdd188b37f6470a6babad1d9700aef0f451da32", "schema": [ "$", "$.error:str" @@ -409,7 +557,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 3, - "run_digest": "39041e5495f98db586db32437188e52a4d1afe349f9b29f1fcbb8c2ed163f750", + "run_digest": "fb069aca7ea87059083bee9517d11d411a68e8b1ba88a1bf59394f11c6ab83c2", "schema": [ "$", "$.id:str", @@ -430,7 +578,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "d494c9a631fb3af8ad9a36c369d6633d5baa727b078f1740c1e08fc811834959", + "run_digest": "9205aee8ba243fc42f8ccf89fad98523c85c39adaa2311e9263512b2cc6faa9e", "schema": [ "$", "$.error:str" @@ -441,7 +589,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 0, - "run_digest": "d494c9a631fb3af8ad9a36c369d6633d5baa727b078f1740c1e08fc811834959", + "run_digest": "9205aee8ba243fc42f8ccf89fad98523c85c39adaa2311e9263512b2cc6faa9e", "schema": [ "$", "$.error:str" @@ -452,7 +600,7 @@ "content_type": "application/json", "count": 1, "fixture_match_count": 3, - "run_digest": "a5ea7cb1c5b72a7b06f989181b4ff39debe887134fcc69c0053602dbc82b98da", + "run_digest": "681e463a7d41ce5ff05da5bc75241a0a176e8a6c1fdb19e1e88cc8f9490e023b", "schema": [ "$", "$.id:str", @@ -466,6 +614,142 @@ "outcome": "pass", "probe_id": "delete-b", "reasons": [] + }, + { + "observations": { + "absent": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 0, + "run_digest": "e41d24b31b123d4a33e4d94a1015005694eeb0d00cf243b6238ac76ce28020e7", + "schema": [ + "$", + "$.error:str" + ], + "status": 404 + }, + "attacker": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 0, + "run_digest": "e41d24b31b123d4a33e4d94a1015005694eeb0d00cf243b6238ac76ce28020e7", + "schema": [ + "$", + "$.error:str" + ], + "status": 404 + }, + "owner": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 3, + "run_digest": "4308d43196348f1b1e55796f2e27b89c90371261e7d0472706778ad60562ab70", + "schema": [ + "$", + "$.correlation_id:str", + "$.id:str", + "$.label:str", + "$.tenant:str" + ], + "status": 200 + } + }, + "operation": "read", + "outcome": "pass", + "probe_id": "audit-event-by-id", + "reasons": [] + }, + { + "observations": { + "absent": { + "content_type": "application/json", + "count": 0, + "fixture_match_count": 0, + "run_digest": "014cf19c288b1c2a1411f08523dac6664bd2f62418148b9ff7f6875ab3f30d53", + "schema": [ + "$", + "$.events[]" + ], + "status": 200 + }, + "attacker": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 0, + "run_digest": "603bc2c5c8f8f643d2bc9c1822e5a8c3414b181aab311410c6ae753ec50d56c3", + "schema": [ + "$", + "$.events[]", + "$.events[]", + "$.events[].correlation_id:str", + "$.events[].id:str", + "$.events[].label:str", + "$.events[].tenant:str" + ], + "status": 200 + }, + "owner": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 3, + "run_digest": "e2715b7b38e5f4141f6017e362301cd27219f0e591bab2250679815eb6a3b937", + "schema": [ + "$", + "$.events[]", + "$.events[]", + "$.events[].correlation_id:str", + "$.events[].id:str", + "$.events[].label:str", + "$.events[].tenant:str" + ], + "status": 200 + } + }, + "operation": "read", + "outcome": "pass", + "probe_id": "audit-correlation-slice", + "reasons": [] + }, + { + "observations": { + "absent": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 0, + "run_digest": "ead0052d3e827ac7333f98a802459109362838131fd81e6a14c74809b320195d", + "schema": [ + "$", + "$.error:str" + ], + "status": 403 + }, + "attacker": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 0, + "run_digest": "ead0052d3e827ac7333f98a802459109362838131fd81e6a14c74809b320195d", + "schema": [ + "$", + "$.error:str" + ], + "status": 403 + }, + "owner": { + "content_type": "application/json", + "count": 1, + "fixture_match_count": 0, + "run_digest": "f84797611f1a1c39fbfd93d06e7007352ea47fa87250613c56df52a64a6322c4", + "schema": [ + "$", + "$.id:str" + ], + "status": 202 + } + }, + "operation": "create", + "outcome": "pass", + "probe_id": "audit-append-as-b", + "reasons": [] } ], "limitations": [ @@ -473,7 +757,7 @@ "No network request, database connection, or live credential was used." ], "outcome": "pass", - "run_id": "fixture-calibration-2026-08-21T21:51:31.696239Z", + "run_id": "fixture-calibration-2026-08-21T22:43:03.737244Z", "schema_version": "whitehat-calibration/v1", - "started_at": "2026-08-21T21:51:31.696239Z" + "started_at": "2026-08-21T22:43:03.737244Z" } diff --git a/outbox/README.md b/outbox/README.md new file mode 100644 index 0000000..9fdfcd1 --- /dev/null +++ b/outbox/README.md @@ -0,0 +1,5 @@ +# Outbox + +Queued `risk-nexus` delivery artifacts. Only `evidence_class: target` reports +are written here. This directory is not a network client; it stores the +message that still has to be handed to `risk-nexus`. diff --git a/plane/README.md b/plane/README.md new file mode 100644 index 0000000..3fa0640 --- /dev/null +++ b/plane/README.md @@ -0,0 +1,27 @@ +# Governed test plane + +Contract only. This repository does not apply cluster objects, request +credentials, or send packets. `ops-mason` provisions from these files after a +**new, unretired** engagement ID exists. Cancelled IDs +`WH-ENG-20260821-AUDIT-E2` and `WH-ENG-20260821-TENANT-E2` are terminal. + +## Controls + +1. Namespace `whitehat`, default-deny networking, explicit target egress. +2. Immutable runner identity with required engagement labels and no standing + service-account token. +3. Custody broker projects two short-lived tenant-scoped identities into the + runner mount and revokes them. Values never enter Git, chat, evidence or + State Hub. +4. Pinned runner image digest on the engagement. +5. Independently usable kill switch: create `plane/KILL` or set + `WHITEHAT_KILL_SWITCH=1`. Admission fails closed while it is engaged. +6. Rate watcher and automatic lease cleanup in `whitehat_security.plane`. + +## Standing versus live + +`fixture-e2` is the in-process class already permitted by the rules of +engagement. `live-e2` still needs a dated engagement, target-owner +acknowledgement, plane namespace, pinned digest and a connected custody +broker. `e3` and `capacity` are separate approval classes and will not be +admitted by the E2 plane. diff --git a/plane/namespace.yaml b/plane/namespace.yaml new file mode 100644 index 0000000..4172df7 --- /dev/null +++ b/plane/namespace.yaml @@ -0,0 +1,9 @@ +# CONTRACT ONLY. Do not apply from this repository. +apiVersion: v1 +kind: Namespace +metadata: + name: whitehat + labels: + app.kubernetes.io/name: whitehat-security + whitehat.security/plane: "true" + pod-security.kubernetes.io/enforce: restricted diff --git a/plane/network-policy.yaml b/plane/network-policy.yaml new file mode 100644 index 0000000..a48d3df --- /dev/null +++ b/plane/network-policy.yaml @@ -0,0 +1,40 @@ +# CONTRACT ONLY. Do not apply from this repository. +# Default deny in namespace whitehat; live E2 adds a named egress rule per +# registered target. This example names audit-core only as the currently +# applicable live E2 target. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: whitehat +spec: + podSelector: {} + policyTypes: ["Ingress", "Egress"] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-audit-core-e2 + namespace: whitehat +spec: + podSelector: + matchLabels: + whitehat.security/target: audit-core + policyTypes: ["Egress"] + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: audit-core + ports: + - protocol: TCP + port: 8080 + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 diff --git a/plane/runner-pod.yaml b/plane/runner-pod.yaml new file mode 100644 index 0000000..89b935d --- /dev/null +++ b/plane/runner-pod.yaml @@ -0,0 +1,53 @@ +# CONTRACT ONLY. Do not apply from this repository. +# Replace PLACEHOLDERS after a new unretired engagement is admitted. +# Never reuse WH-ENG-20260821-AUDIT-E2 or WH-ENG-20260821-TENANT-E2. +apiVersion: v1 +kind: Pod +metadata: + name: whitehat-e2-audit + namespace: whitehat + labels: + app.kubernetes.io/name: whitehat-security + app.kubernetes.io/component: whitehat-probe + whitehat.security/plane: "true" + whitehat.security/target: audit-core + whitehat.security/engagement: WH-ENG-PLACEHOLDER +spec: + serviceAccountName: whitehat-runner + automountServiceAccountToken: false + activeDeadlineSeconds: 900 + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: probe + image: PINNED_RUNNER_IMAGE_DIGEST + imagePullPolicy: IfNotPresent + command: ["python3", "-"] + resources: + requests: {cpu: 5m, memory: 16Mi} + limits: {cpu: 50m, memory: 64Mi} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true + volumeMounts: + - name: credentials + mountPath: /var/run/secrets/whitehat + readOnly: true + volumes: + - name: credentials + secret: + secretName: whitehat-e2-audit-credentials + defaultMode: 0440 + items: + - key: token-a + path: token-a + - key: token-b + path: token-b diff --git a/plane/service-account.yaml b/plane/service-account.yaml new file mode 100644 index 0000000..ef0a3b0 --- /dev/null +++ b/plane/service-account.yaml @@ -0,0 +1,9 @@ +# CONTRACT ONLY. Do not apply from this repository. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: whitehat-runner + namespace: whitehat + labels: + app.kubernetes.io/name: whitehat-security +automountServiceAccountToken: false diff --git a/schemas/target-registration.schema.json b/schemas/target-registration.schema.json new file mode 100644 index 0000000..50b6b9a --- /dev/null +++ b/schemas/target-registration.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "whitehat-target/v1", + "type": "object", + "additionalProperties": true, + "required": [ + "schema_version", "target_id", "posture_claim", "attacker_model", + "applicability", "applicability_reason", "approval_classes", "routes", + "identities", "abort_telemetry" + ], + "properties": { + "schema_version": {"const": "whitehat-target/v1"}, + "applicability": {"enum": ["applicable", "not_applicable", "pending"]}, + "approval_classes": { + "type": "array", + "minItems": 1, + "items": {"enum": ["fixture-e2", "live-e2", "e3", "capacity"]} + }, + "routes": {"type": "array", "items": {"type": "string"}}, + "identities": { + "type": "object", + "properties": { + "count": {"const": 2}, + "ttl_seconds": {"type": "integer", "minimum": 1, "maximum": 900} + } + } + } +} diff --git a/src/whitehat_security/audit_fixtures.py b/src/whitehat_security/audit_fixtures.py new file mode 100644 index 0000000..f7e22c1 --- /dev/null +++ b/src/whitehat_security/audit_fixtures.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass + +from .differential import DifferentialProbe +from .model import Response + + +@dataclass +class AuditFixture: + """In-process audit-core shape used to calibrate the E2 pack. No sockets.""" + + enforce_tenant: bool + + def __post_init__(self) -> None: + self.events = { + "event-a": { + "id": "event-a", "tenant": "tenant-a", + "correlation_id": "corr-shared", "label": "fixture-a", + }, + "event-b": { + "id": "event-b", "tenant": "tenant-b", + "correlation_id": "corr-shared", "label": "fixture-b", + }, + } + + def snapshot_events(self) -> object: + return deepcopy(self.events) + + def _visible(self, actor_tenant: str, item: dict) -> bool: + return (not self.enforce_tenant) or item["tenant"] == actor_tenant + + def get(self, actor_tenant: str, event_id: str) -> Response: + item = self.events.get(event_id) + if item is None or not self._visible(actor_tenant, item): + return Response(404, {"error": "not_found"}) + return Response(200, deepcopy(item)) + + def by_correlation(self, actor_tenant: str, correlation_id: str) -> Response: + items = [ + deepcopy(item) for item in self.events.values() + if item["correlation_id"] == correlation_id and self._visible(actor_tenant, item) + ] + return Response(200, {"events": items}) + + def append(self, actor_tenant: str, event: dict) -> Response: + if self.enforce_tenant and event["tenant"] != actor_tenant: + return Response(403, {"error": "forbidden"}) + self.events[event["id"]] = deepcopy(event) + return Response(202, {"id": event["id"], "tenant": event["tenant"]}) + + +def audit_probe_suite(service: AuditFixture) -> list[DifferentialProbe]: + markers = ("event-b", "tenant-b", "fixture-b") + forged = { + "id": "event-forged-b", "tenant": "tenant-b", + "correlation_id": "corr-shared", "label": "fixture-forged", + } + return [ + DifferentialProbe( + "audit-event-by-id", "read", + lambda: service.get("tenant-b", "event-b"), + lambda: service.get("tenant-a", "event-b"), + lambda: service.get("tenant-a", "absent"), + markers, + ), + DifferentialProbe( + "audit-correlation-slice", "read", + lambda: service.by_correlation("tenant-b", "corr-shared"), + lambda: service.by_correlation("tenant-a", "corr-shared"), + lambda: service.by_correlation("tenant-a", "corr-absent"), + markers, hide_existence=False, + ), + DifferentialProbe( + "audit-append-as-b", "create", + lambda: Response(202, {"id": "owner-control"}), + lambda: service.append("tenant-a", forged), + lambda: Response(403, {"error": "forbidden"}), + markers + ("event-forged-b",), + state=service.snapshot_events, hide_existence=False, + ), + ] diff --git a/src/whitehat_security/cli.py b/src/whitehat_security/cli.py index 0914591..145568b 100644 --- a/src/whitehat_security/cli.py +++ b/src/whitehat_security/cli.py @@ -6,19 +6,26 @@ import sys from dataclasses import asdict 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 .engagement import AuthorizationError, Engagement from .fixtures import FixtureService, probe_suite from .model import RunReport, utc_now -from .reporting import risk_nexus_message +from .plane import KillSwitch, admit, default_broker, retired_ids +from .reporting import queue_risk_nexus, risk_nexus_message +from .targets import load_catalog, load_registration def fixture_calibration() -> dict: started = utc_now() - good = [execute(probe) for probe in probe_suite(FixtureService(enforce_tenant=True))] - bad = [execute(probe) for probe in probe_suite(FixtureService(enforce_tenant=False))] + good_generic = probe_suite(FixtureService(enforce_tenant=True)) + bad_generic = probe_suite(FixtureService(enforce_tenant=False)) + good_audit = audit_probe_suite(AuditFixture(enforce_tenant=True)) + bad_audit = audit_probe_suite(AuditFixture(enforce_tenant=False)) + good = [execute(probe) for probe in (*good_generic, *good_audit)] + bad = [execute(probe) for probe in (*bad_generic, *bad_audit)] detected = all(result.outcome == "finding" for result in bad) rejected = all(result.outcome == "pass" for result in good) return { @@ -78,6 +85,15 @@ def main(argv: list[str] | None = None) -> None: engagement.add_argument("path") packs = commands.add_parser("validate-packs") packs.add_argument("path") + targets = commands.add_parser("validate-targets") + targets.add_argument("path") + admit_plane = commands.add_parser("admit-plane") + admit_plane.add_argument("engagement") + admit_plane.add_argument("registration") + commands.add_parser("kill-switch") + deliver = commands.add_parser("deliver") + deliver.add_argument("report") + deliver.add_argument("--outbox", default="outbox") commands.add_parser("e3-plan") commands.add_parser("capacity-fixture") message = commands.add_parser("risk-message") @@ -108,6 +124,42 @@ def main(argv: list[str] | None = None) -> None: validate_pack(path) print(f"validated {len(paths)} probe packs") return + if args.command == "validate-targets": + try: + catalog = load_catalog(args.path) + except (AuthorizationError, OSError, ValueError, json.JSONDecodeError) as error: + print(f"not authorized: {error}", file=sys.stderr) + raise SystemExit(2) from None + print(f"validated {len(catalog)} target registrations") + return + if args.command == "admit-plane": + try: + record = Engagement.load(args.engagement) + registration = load_registration(args.registration) + lease = admit(engagement=record, registration=registration, + broker=default_broker(record), kill_switch=KillSwitch(), + retired=retired_ids()) + except (AuthorizationError, OSError, ValueError, json.JSONDecodeError) as error: + print(f"not authorized: {error}", file=sys.stderr) + raise SystemExit(2) from None + print(f"admitted: {lease.engagement.raw['engagement_id']} lease={lease.lease_id}") + return + if args.command == "kill-switch": + switch = KillSwitch() + if switch.engaged(): + print(f"engaged: {switch.path}") + raise SystemExit(1) + print("clear") + return + if args.command == "deliver": + try: + report = RunReport(**json.loads(Path(args.report).read_text(encoding="utf-8"))) + path = queue_risk_nexus(report, args.outbox) + except (AuthorizationError, OSError, ValueError, json.JSONDecodeError, TypeError) as error: + print(f"not authorized: {error}", file=sys.stderr) + raise SystemExit(2) from None + print(f"queued: {path}") + return if args.command == "e3-plan": print(json.dumps({"cadence": CADENCE, "probes": [asdict(probe) for probe in PROBES]}, indent=2, sort_keys=True)) diff --git a/src/whitehat_security/differential.py b/src/whitehat_security/differential.py index 4587593..53d78ee 100644 --- a/src/whitehat_security/differential.py +++ b/src/whitehat_security/differential.py @@ -2,10 +2,11 @@ from __future__ import annotations import os from dataclasses import dataclass +from datetime import datetime from typing import Callable -from .engagement import Engagement from .model import Observation, ProbeResult, Response, observe +from .plane import PlaneLease Request = Callable[[], Response] State = Callable[[], object] @@ -49,12 +50,17 @@ def execute(probe: DifferentialProbe, *, salt: bytes | None = None) -> ProbeResu ) -def execute_authorized(probe: DifferentialProbe, *, engagement: Engagement, - route: str, salt: bytes | None = None) -> ProbeResult: - """Live entry point: authorization is rechecked immediately before calls.""" - engagement.validate() - engagement.permits(technique="e2-differential", route=route) - return execute(probe, salt=salt) +def execute_authorized(probe: DifferentialProbe, *, lease: PlaneLease, + route: str, salt: bytes | None = None, + now: datetime | None = None) -> ProbeResult: + """Live entry point: plane admission is rechecked immediately before calls.""" + lease.engagement.validate(now=now) + lease.permits(technique="e2-differential", route=route) + lease.watcher.acquire() + try: + return execute(probe, salt=salt) + finally: + lease.watcher.release() def _equivalent_denial(left: Observation, right: Observation) -> bool: diff --git a/src/whitehat_security/plane.py b/src/whitehat_security/plane.py new file mode 100644 index 0000000..3041bdf --- /dev/null +++ b/src/whitehat_security/plane.py @@ -0,0 +1,263 @@ +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"}), + "e3": frozenset({"e3-rls"}), + "capacity": frozenset({"p1-noisy-neighbour", "p2-noisy-neighbour"}), +} + + +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, 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, 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, IdentityHandle]: + if registration.get("applicability") != "applicable": + raise AuthorizationError("broker will not project identities for a non-applicable target") + 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 + + 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, 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") == "fixture-e2"): + 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}") + 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" + ) + 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") + 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=identities[0].lease_id, + broker_name=type(selected).__name__, + ) + + +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, + } diff --git a/src/whitehat_security/reporting.py b/src/whitehat_security/reporting.py index b8bbf68..fd903f0 100644 --- a/src/whitehat_security/reporting.py +++ b/src/whitehat_security/reporting.py @@ -3,6 +3,7 @@ from __future__ import annotations import json from pathlib import Path +from .engagement import AuthorizationError from .model import RunReport @@ -29,3 +30,14 @@ def risk_nexus_message(report: RunReport) -> str: lines.extend(["", "Reporter supplies facts only; risk-nexus owns severity and disclosure."]) return "\n".join(lines) + "\n" + +def queue_risk_nexus(report: RunReport, outbox: str | Path) -> Path: + """Persist a delivery artifact. Fixture calibration is not target assurance.""" + if report.evidence_class != "target": + raise AuthorizationError("fixture evidence is not delivered as target assurance") + directory = Path(outbox) + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{report.run_id}.md" + path.write_text(risk_nexus_message(report), encoding="utf-8") + return path + diff --git a/src/whitehat_security/targets.py b/src/whitehat_security/targets.py new file mode 100644 index 0000000..a248ab4 --- /dev/null +++ b/src/whitehat_security/targets.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .engagement import AuthorizationError + +REQUIRED = { + "schema_version", "target_id", "posture_claim", "attacker_model", + "applicability", "applicability_reason", "approval_classes", "routes", + "identities", "abort_telemetry", +} +APPLICABLE_REQUIRED = { + "adapter", "probe_pack", "known_bad_calibration", "fixture_lifecycle", "egress", +} +APPLICABILITY = {"applicable", "not_applicable", "pending"} + + +def load_registration(path: str | Path) -> dict[str, Any]: + data = json.loads(Path(path).read_text(encoding="utf-8")) + missing = sorted(REQUIRED - data.keys()) + if missing: + raise AuthorizationError(f"{path}: missing {', '.join(missing)}") + if data["schema_version"] != "whitehat-target/v1": + raise AuthorizationError(f"{path}: unsupported schema_version") + if data["applicability"] not in APPLICABILITY: + raise AuthorizationError(f"{path}: invalid applicability") + if not data["applicability_reason"]: + raise AuthorizationError(f"{path}: applicability_reason is required") + if not data["approval_classes"]: + raise AuthorizationError(f"{path}: at least one approval class is required") + if data["applicability"] == "applicable": + missing_live = sorted(APPLICABLE_REQUIRED - data.keys()) + if missing_live: + raise AuthorizationError(f"{path}: applicable target missing {', '.join(missing_live)}") + if not data["routes"]: + raise AuthorizationError(f"{path}: applicable target must register routes") + identities = data["identities"] + if identities.get("count") != 2: + raise AuthorizationError(f"{path}: E2 registration must project two identities") + if 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") + return data + + +def load_catalog(directory: str | Path) -> dict[str, dict[str, Any]]: + root = Path(directory) + paths = sorted(root.glob("*.json")) + if not paths: + raise AuthorizationError(f"{root}: no target registrations found") + catalog: dict[str, dict[str, Any]] = {} + for path in paths: + registration = load_registration(path) + target_id = registration["target_id"] + if target_id in catalog: + raise AuthorizationError(f"duplicate target_id: {target_id}") + catalog[target_id] = registration + return catalog diff --git a/targets/README.md b/targets/README.md new file mode 100644 index 0000000..2e01591 --- /dev/null +++ b/targets/README.md @@ -0,0 +1,14 @@ +# Target registrations + +A target name is not an E2 boundary. Each file in this directory is the +honest applicability record the test plane admits against. + +| `target_id` | Applicability | Meaning | +| --- | --- | --- | +| `fixture-e2` | applicable | In-process known-good/known-bad harness. Offline only. | +| `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. | + +`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/audit-core-e2.json b/targets/audit-core-e2.json new file mode 100644 index 0000000..259be12 --- /dev/null +++ b/targets/audit-core-e2.json @@ -0,0 +1,36 @@ +{ + "schema_version": "whitehat-target/v1", + "target_id": "audit-core", + "posture_claim": "implemented E2; currently evidenced E1", + "attacker_model": "E2-authenticated-tenant-a", + "applicability": "applicable", + "applicability_reason": "audit-core binds each sender token to a tenant set at one choke point. Two ordinary tenant-scoped senders instantiate the E2 model without operator or database privilege.", + "approval_classes": ["live-e2"], + "adapter": "runners/audit_core_e2.py", + "probe_pack": "probe-packs/audit-core-e2.json", + "known_bad_calibration": "src/whitehat_security/audit_fixtures.py", + "fixture_lifecycle": { + "create": "append two synthetic events and one reserved absent id", + "delete": "named immutable audit fixtures retained by target contract unless the owner provides a delete", + "ids_are_synthetic": true + }, + "egress": "http://audit-core.audit-core.svc.cluster.local:8080", + "routes": [ + "GET /readyz", + "POST /v1/events", + "GET /v1/events/{event_id}", + "GET /v1/events?correlation_id={correlation_id}" + ], + "identities": { + "count": 2, + "role": "ordinary tenant-scoped may_read/may_write fixture senders", + "ttl_seconds": 900, + "broker_audience": "whitehat-e2/audit-core" + }, + "abort_telemetry": [ + "p95_latency_ms", + "probe_attributable_5xx", + "unexpected_or_non_fixture_data", + "missing_attribution" + ] +} diff --git a/targets/fixture-e2.json b/targets/fixture-e2.json new file mode 100644 index 0000000..e569f48 --- /dev/null +++ b/targets/fixture-e2.json @@ -0,0 +1,37 @@ +{ + "schema_version": "whitehat-target/v1", + "target_id": "fixture-e2", + "posture_claim": "E2", + "attacker_model": "E2-authenticated-tenant-a", + "applicability": "applicable", + "applicability_reason": "In-process known-good/known-bad service created by this repository. It instantiates tenant A with no tenant B authority.", + "approval_classes": ["fixture-e2"], + "adapter": "src/whitehat_security/fixtures.py", + "probe_pack": "src/whitehat_security/fixtures.py", + "known_bad_calibration": "src/whitehat_security/fixtures.py", + "fixture_lifecycle": { + "create": "in-process constructor", + "delete": "drop process state", + "ids_are_synthetic": true + }, + "egress": "in-process", + "routes": [ + "GET /objects/{id}", + "GET /objects?tenant={tenant_id}", + "POST /objects", + "PATCH /objects/{id}", + "DELETE /objects/{id}" + ], + "identities": { + "count": 2, + "role": "ordinary tenant-scoped fixture actor", + "ttl_seconds": 900, + "broker_audience": "whitehat-e2/fixture" + }, + "abort_telemetry": [ + "p95_latency_ms", + "probe_attributable_5xx", + "unexpected_or_non_fixture_data", + "missing_attribution" + ] +} diff --git a/targets/flex-auth-e2.json b/targets/flex-auth-e2.json new file mode 100644 index 0000000..4e4b63c --- /dev/null +++ b/targets/flex-auth-e2.json @@ -0,0 +1,22 @@ +{ + "schema_version": "whitehat-target/v1", + "target_id": "flex-auth", + "posture_claim": "E2", + "attacker_model": "E2-authenticated-tenant-a", + "applicability": "pending", + "applicability_reason": "Named in the initial envelope. No target-owner confirmation yet that a relationship-inheritance decision can be exercised as tenant A without tenant B authority.", + "approval_classes": ["live-e2"], + "routes": [], + "identities": { + "count": 2, + "role": "ordinary tenant-scoped decision subject; unconfirmed", + "ttl_seconds": 900, + "broker_audience": "whitehat-e2/flex-auth" + }, + "abort_telemetry": [ + "p95_latency_ms", + "probe_attributable_5xx", + "unexpected_or_non_fixture_data", + "missing_attribution" + ] +} diff --git a/targets/tenant-engine-e2.json b/targets/tenant-engine-e2.json new file mode 100644 index 0000000..55998f9 --- /dev/null +++ b/targets/tenant-engine-e2.json @@ -0,0 +1,22 @@ +{ + "schema_version": "whitehat-target/v1", + "target_id": "tenant-engine", + "posture_claim": "implemented E2; currently evidenced E1", + "attacker_model": "E2-authenticated-tenant-a", + "applicability": "not_applicable", + "applicability_reason": "No authenticated tenant-A identity with no tenant-B authority exists. The platform operator is allowed to address every tenant; an unregistered actor tests caller authorization, not E2. Whitehat will not substitute either identity to finish the workplan.", + "approval_classes": ["live-e2"], + "routes": [], + "identities": { + "count": 2, + "role": "ordinary tenant-scoped caller; none currently exposed", + "ttl_seconds": 900, + "broker_audience": "whitehat-e2/tenant-engine" + }, + "abort_telemetry": [ + "p95_latency_ms", + "probe_attributable_5xx", + "unexpected_or_non_fixture_data", + "missing_attribution" + ] +} diff --git a/tests/test_cli.py b/tests/test_cli.py index e12c876..c6f4241 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,3 +12,43 @@ def test_validate_engagement_reports_clean_denial(tmp_path, capsys): main(["validate-engagement", str(path)]) assert stopped.value.code == 2 assert capsys.readouterr().err.startswith("not authorized:") + + +def test_validate_targets_accepts_catalog(capsys): + main(["validate-targets", "targets"]) + assert capsys.readouterr().out.startswith("validated 4 target registrations") + + +def test_kill_switch_is_clear_by_default(capsys): + main(["kill-switch"]) + assert capsys.readouterr().out.strip() == "clear" + + +def test_admit_plane_refuses_cancelled_engagement(capsys): + with pytest.raises(SystemExit) as stopped: + main([ + "admit-plane", + "engagements/2026-08-21-audit-core-e2.json", + "targets/audit-core-e2.json", + ]) + assert stopped.value.code == 2 + assert "not authorized:" in capsys.readouterr().err + + +def test_deliver_refuses_fixture_calibration(tmp_path, capsys): + report = tmp_path / "fixture.json" + report.write_text(json.dumps({ + "schema_version": "whitehat-run/v1", "run_id": "run-1", + "evidence_class": "fixture", "engagement_id": "eng", + "authorization_id": "auth", "target": "fixture-e2", + "target_revision": "local", "posture_claim": "E2", + "attacker_model": "E2", "started_at": "2026-08-22T00:00:00Z", + "ended_at": "2026-08-22T00:01:00Z", "outcome": "pass", + "attempted_operations": 1, "cleanup": "n/a", + "credential_revocation": "n/a", "probes": [], "limitations": [], + "assurance_statement": "not proof", + }), encoding="utf-8") + with pytest.raises(SystemExit) as stopped: + main(["deliver", str(report), "--outbox", str(tmp_path / "outbox")]) + assert stopped.value.code == 2 + assert "fixture evidence" in capsys.readouterr().err diff --git a/tests/test_plane.py b/tests/test_plane.py new file mode 100644 index 0000000..03a8a4d --- /dev/null +++ b/tests/test_plane.py @@ -0,0 +1,234 @@ +import json +from datetime import UTC, datetime + +import pytest + +from whitehat_security.audit_fixtures import AuditFixture, audit_probe_suite +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.plane import ( + KillSwitch, LocalBroker, RateWatcher, UnconnectedCustodyBroker, + admit, cleanup, retired_ids, +) +from whitehat_security.reporting import queue_risk_nexus +from whitehat_security.targets import load_catalog, load_registration + + +NOW = datetime(2026, 8, 22, 12, tzinfo=UTC) + + +def fixture_record(**overrides): + data = { + "engagement_id": "WH-ENG-FIXTURE-1", "authorization_id": "auth-1", + "authorizer": "operator", "approved_at": "2026-08-22T11:00:00Z", + "expires_at": "2026-08-22T18:00:00Z", "target": "in-process", + "target_id": "fixture-e2", "target_owner": "whitehat-security", + "environment": "fixture", "source": "in-process", + "approval_class": "fixture-e2", + "routes": ["GET /objects/{id}"], "fixture_ids": ["object-a", "object-b"], + "credential_lane": "local-broker", "credential_role": "runtime", + "credential_max_ttl_seconds": 900, "techniques": ["e2-differential"], + "prohibited_techniques": ["saturation"], "rate_limit_per_minute": 10, + "max_concurrency": 1, "maximum_requests": 8, + "window_start": "2026-08-22T11:00:00Z", + "window_end": "2026-08-22T18: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", + } + data.update(overrides) + return data + + +def load_engagement(tmp_path, data): + path = tmp_path / "engagement.json" + path.write_text(json.dumps(data), encoding="utf-8") + return Engagement.load(path, now=NOW) + + +def fixture_registration(): + return load_registration("targets/fixture-e2.json") + + +def test_catalog_loads_honest_applicability(): + catalog = load_catalog("targets") + assert catalog["fixture-e2"]["applicability"] == "applicable" + assert catalog["audit-core"]["applicability"] == "applicable" + assert catalog["tenant-engine"]["applicability"] == "not_applicable" + assert catalog["flex-auth"]["applicability"] == "pending" + + +def test_retired_ids_include_cancelled_records(): + ids = retired_ids() + assert "WH-ENG-20260821-AUDIT-E2" in ids + assert "WH-ENG-20260821-TENANT-E2" in ids + + +def test_fixture_plane_admits_and_projects_handles_without_secrets(tmp_path): + engagement = load_engagement(tmp_path, fixture_record()) + broker = LocalBroker() + lease = admit( + engagement=engagement, registration=fixture_registration(), + broker=broker, kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, retired=retired_ids(), + ) + roles = {handle.role for handle in lease.identities} + assert roles == {"owner", "attacker"} + assert not hasattr(lease.identities[0], "secret") + assert not hasattr(lease.identities[1], "secret") + rendered = repr(lease) + repr(lease.identities) + for value in broker._secrets.values(): + assert value.hex() not in rendered + assert value not in rendered.encode() + report = cleanup(lease, broker) + assert report["credential_revocation"] == "revoked" + with pytest.raises(AuthorizationError, match="unknown"): + broker.revoke(lease.lease_id) + + +def test_not_applicable_target_is_refused(tmp_path): + engagement = load_engagement(tmp_path, fixture_record(target_id="tenant-engine")) + registration = load_registration("targets/tenant-engine-e2.json") + with pytest.raises(AuthorizationError, match="not_applicable"): + admit( + engagement=engagement, registration=registration, + broker=LocalBroker(), kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, retired=set(), + ) + + +def test_retired_id_cannot_be_reused_even_if_fields_are_complete(tmp_path): + engagement = load_engagement( + tmp_path, fixture_record(engagement_id="WH-ENG-20260821-AUDIT-E2") + ) + with pytest.raises(AuthorizationError, match="retired"): + admit( + engagement=engagement, registration=fixture_registration(), + broker=LocalBroker(), kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, retired=retired_ids(), + ) + + +def test_kill_switch_fails_closed(tmp_path): + path = tmp_path / "KILL" + path.write_text("", encoding="utf-8") + engagement = load_engagement(tmp_path, fixture_record()) + with pytest.raises(AuthorizationError, match="kill switch"): + admit( + engagement=engagement, registration=fixture_registration(), + broker=LocalBroker(), kill_switch=KillSwitch(path), + now=NOW, retired=set(), + ) + + +def test_live_e2_without_plane_namespace_fails(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="user-engine", + runner_image_digest="sha256:abc", + )) + registration = load_registration("targets/audit-core-e2.json") + with pytest.raises(AuthorizationError, match="whitehat plane namespace"): + admit( + engagement=engagement, registration=registration, + broker=LocalBroker(), kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, retired=set(), + ) + + +def test_unconnected_broker_requests_no_credential(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", + )) + registration = load_registration("targets/audit-core-e2.json") + with pytest.raises(AuthorizationError, match="no credential was requested"): + admit( + engagement=engagement, registration=registration, + broker=UnconnectedCustodyBroker(), + 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"], + )) + with pytest.raises(AuthorizationError): + admit( + engagement=engagement, registration=fixture_registration(), + broker=LocalBroker(), kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, retired=set(), + ) + + +def test_rate_and_concurrency_ceilings(tmp_path): + watcher = RateWatcher(per_minute=10, max_concurrency=1, max_requests=1) + watcher.acquire() + with pytest.raises(AuthorizationError, match="concurrency"): + watcher.acquire() + watcher.release() + with pytest.raises(AuthorizationError, match="request ceiling"): + watcher.acquire() + + +def test_execute_authorized_requires_plane_lease(tmp_path): + engagement = load_engagement(tmp_path, fixture_record()) + lease = admit( + engagement=engagement, registration=fixture_registration(), + broker=LocalBroker(), kill_switch=KillSwitch(tmp_path / "KILL"), + now=NOW, retired=set(), + ) + probe = probe_suite(FixtureService(True))[0] + result = execute_authorized( + probe, lease=lease, route="GET /objects/{id}", salt=b"test", now=NOW + ) + assert result.outcome == "pass" + + +def test_fixture_report_is_not_delivered_as_target_assurance(tmp_path): + report = RunReport( + schema_version="whitehat-run/v1", run_id="run-1", evidence_class="fixture", + engagement_id="eng-1", authorization_id="auth-1", target="fixture-e2", + target_revision="local", posture_claim="E2", attacker_model="E2", + started_at="2026-08-22T00:00:00Z", ended_at="2026-08-22T00:01:00Z", + outcome="pass", attempted_operations=1, cleanup="complete", + credential_revocation="complete", + ) + with pytest.raises(AuthorizationError, match="fixture evidence"): + queue_risk_nexus(report, tmp_path) + + +def test_target_report_is_queued_without_severity(tmp_path): + report = RunReport( + schema_version="whitehat-run/v1", run_id="run-2", evidence_class="target", + engagement_id="eng-1", authorization_id="auth-1", target="audit-core", + target_revision="abc", posture_claim="E2", attacker_model="E2", + started_at="2026-08-22T00:00:00Z", ended_at="2026-08-22T00:01:00Z", + outcome="pass", attempted_operations=1, cleanup="complete", + credential_revocation="complete", + ) + path = queue_risk_nexus(report, tmp_path) + text = path.read_text(encoding="utf-8") + assert "**pass**" in text + assert "Severity" not in text + assert "not proof" in text + + +def test_audit_shaped_probes_calibrate(): + good = [execute(probe, salt=b"test") for probe in audit_probe_suite(AuditFixture(True))] + bad = [execute(probe, salt=b"test") for probe in audit_probe_suite(AuditFixture(False))] + assert {item.probe_id for item in good} == { + "audit-event-by-id", "audit-correlation-slice", "audit-append-as-b", + } + assert {item.outcome for item in good} == {"pass"} + assert {item.outcome for item in bad} == {"finding"} diff --git a/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md b/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md index 2966df5..8c677d8 100644 --- a/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md +++ b/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md @@ -89,8 +89,10 @@ Drafted in `docs/rules-of-engagement.md` on 2026-08-18 with authorization classes, per-run records, initial target envelope, hard prohibitions, credential/attribution rules, rate defaults, abort/cleanup and evidence schema. The operator accepted v0.1 on 2026-08-21 with the scope recorded in -§10. The acceptance approves the operating rules and offline fixture work; it -does not pre-authorize any live target. +what is now §11. v0.2 (2026-08-22) adds §10, the governed test plane, as a +stricter admission control. It does not expand authorization. The acceptance +approves the operating rules and offline fixture work; it does not +pre-authorize any live target. ### T02 — The attacker model per axis @@ -141,30 +143,31 @@ The core technique: run the same request as two tenants and compare. produced ordinary-looking responses, a 403 and a 404, and nothing alerted. - Capture evidence as a count and a schema shape, never as tenant data (T01). -**Acceptance:** run against `tenant-engine` and `audit-core`, both of which -currently claim `E2`. The artifact is the run record, not a green tick. +**Acceptance:** a dated run record against every *applicable* E2 target. The +artifact is the run record, not a green tick. `tenant-engine` is registered +`not_applicable` for E2; that record is the artifact for that target. +`audit-core` remains the live E2 target and still needs a new admitted +engagement. -Implementation progress, 2026-08-21: `src/whitehat_security/differential.py` -now performs owner/attacker/absent comparisons, detects fixture disclosure and -write-side state change, and persists only sanitized observations. Concrete -route inventories live in `probe-packs/tenant-engine-e2.json` and -`probe-packs/audit-core-e2.json`. The two target runs remain gated on complete -engagement records, owner acknowledgements and executable identity adapters. +Implementation progress, 2026-08-22: `src/whitehat_security/differential.py` +performs owner/attacker/absent comparisons, detects fixture disclosure and +write-side state change, and persists only sanitized observations. Live entry +is `execute_authorized`, which requires a plane lease. Concrete route +inventories live in `probe-packs/`. Target honesty lives in `targets/`: +`audit-core` is applicable; `tenant-engine` is `not_applicable` (that is the +artifact, not a deferral); `flex-auth` is pending. -Production engagements were operator-approved on 2026-08-21 and committed in -`engagements/`. Audit-core accepted the route/identity shape and reviewed the -adapter; its first review caught a missing idempotency header before traffic, -which is corrected and regression-tested. `docs/target-applicability.md` -records a separate issue: tenant-engine currently exposes no authenticated -tenant-A identity with no tenant-B authority, so its E2 applicability is -pending owner confirmation rather than being faked with its all-tenant service -operator or an unauthenticated actor. +The 2026-08-21 production engagement IDs were cancelled without sending a +target request and are retired. A live audit-core run still needs a **new** +engagement, target-owner acknowledgement, provisioned `whitehat` namespace, +and a connected custody broker. Whitehat will not relabel tenant-engine to +finish this task. ### T04 — Prove the probes fail ```task id: WHITEHAT-WP-0001-T04 -status: progress +status: done priority: high state_hub_task_id: "a2afc0f5-3ece-410c-864c-829a8aac30fc" ``` @@ -179,12 +182,13 @@ the same discipline applies here and is not optional. **Acceptance:** every probe in T03 demonstrated failing before any of them is trusted passing. -Implementation progress, 2026-08-21: five generic read/list/create/update/ -delete probes pass the enforcing in-process fixture and all five produce -findings when its tenant predicate is removed. The sanitized artifact is -`evidence/offline-calibration.json`; fourteen tests guard the calibration and -fail-closed authorization behavior. Target-specific probes are not trusted -passing until they receive the same calibration. +Completed 2026-08-22. Five generic read/list/create/update/delete probes and +the three audit-core shaped probes pass the enforcing in-process fixture and +all produce findings when the tenant predicate is removed. Tenant-engine has +no applicable E2 identity, so its pack is not calibrated as if it were E2. +`make fixture-evidence` refreshes `evidence/offline-calibration.json`. +Target probes are still not trusted passing against a live service until a +new admitted engagement runs. ### T05 — RLS conformance under attack (the E3 artifact) @@ -263,28 +267,56 @@ A **passing** run is also reported. "The attacks we thought of did not work" is the honest claim, and recording it dated is what lets anyone see how stale the assurance has become. -Implementation progress, 2026-08-21: `schemas/run-report.schema.json` defines -the minimized evidence contract and `whitehat risk-message` renders both pass -and finding deliveries without severity. Offline calibration is deliberately -not routed as target assurance. Completion awaits the first authorized target -report being delivered to `risk-nexus`. +Implementation progress, 2026-08-22: `schemas/run-report.schema.json` defines +the minimized evidence contract, `whitehat risk-message` renders both pass +and finding deliveries without severity, and `whitehat deliver` queues a +target report to `outbox/`. Fixture calibration is refused as target +assurance. Completion awaits the first authorized target report being +delivered to `risk-nexus`. + +### T08 — Governed test plane + +The 2026-08-22 cutoff's missing infrastructure, encoded here so live work can +resume later without assembling authority during the run. + +```task +id: WHITEHAT-WP-0001-T08 +status: done +priority: high +``` + +Completed 2026-08-22 as a repository contract, not a cluster provision: + +- Target registration schema and catalog, including an honest + `not_applicable` state. +- Fail-closed admission: retired IDs, kill switch, approval class, namespace, + pinned digest, known-bad calibration, two identity handles. +- Credential broker interface that never returns secret values. The live + broker is unconnected and raises before any custody call. +- 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. ## Sequencing -T01 gates all. T02 shapes T03/T05/T06. T04 gates trusting any of them. T07 can -follow T03. +T01 gates all. T02 shapes T03/T05/T06. T04 gates trusting any of them. T08 +gates live T03. T07 can follow T03. ## Session cutoff — 2026-08-22 -This session ends with the workplan deliberately **active**, not cosmetically -complete. T01 and T02 are done. T03 through T07 remain in progress. The two +The coordinating session ended with the workplan deliberately **active**. T01, +T02, T04 and T08 are done. T03, T05, T06 and T07 remain in progress. The two dated production engagement records were cancelled without sending any target request; cancelled engagement IDs are terminal and must never be reused. -The offline instrument is implemented and calibrated, but the estate does not -yet provide the governed test plane needed to exercise it routinely and safely. -The exact completed scope, unresolved scope, and recommended restart boundary -are recorded in `docs/session-cutoff-2026-08-22.md`. +The offline instrument is implemented, calibrated, and plane-gated. The +estate still has to provision the cluster plane and a value-safe credential +broker before any live evidence exists. Resume only with new engagement IDs. +The exact completed scope is recorded in `docs/session-cutoff-2026-08-22.md` +and `docs/test-plane.md`. ## Risks