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
This commit is contained in:
tegwick 2026-08-22 00:44:21 +02:00
parent 0aab0cb4c6
commit 95129d7a35
35 changed files with 1599 additions and 103 deletions

View file

@ -2,6 +2,8 @@
check: test check: test
PYTHONPATH=src python3 -m whitehat_security.cli validate-packs probe-packs 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: test:
PYTHONPATH=src python3 -m pytest -q PYTHONPATH=src python3 -m pytest -q

View file

@ -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) - Operator runbook: [`docs/operator-runbook.md`](docs/operator-runbook.md)
- Session cutoff and redesign handoff: - Session cutoff and redesign handoff:
[`docs/session-cutoff-2026-08-22.md`](docs/session-cutoff-2026-08-22.md) [`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/) - Target packs: [`probe-packs/`](probe-packs/)
## Offline calibration ## Offline calibration
@ -37,8 +39,9 @@ make check
make fixture-evidence make fixture-evidence
``` ```
This proves that every current differential probe passes an enforcing fixture This proves that every current differential probe, including the audit-core
and detects the corresponding missing-tenant-predicate fixture. It sends no shaped pack, passes an enforcing fixture and detects the corresponding
network traffic and is not evidence about a target service. Live execution missing-tenant-predicate fixture. It sends no network traffic and is not
requires a current target-specific record based on evidence about a target service. Live execution requires a current
[`engagements/template.json`](engagements/template.json). target-specific record, a registered applicable target, and plane admission.
See [`docs/test-plane.md`](docs/test-plane.md).

View file

@ -69,17 +69,20 @@ boundary always holds.
- `T01` is complete: the rules of engagement were accepted on 2026-08-21. - `T01` is complete: the rules of engagement were accepted on 2026-08-21.
- `T02` is complete: the per-axis attacker model is recorded in - `T02` is complete: the per-axis attacker model is recorded in
`docs/attacker-model.md`. `docs/attacker-model.md`.
- `T03` is in progress: the differential core and audit-core/tenant-engine - `T03` is in progress: the differential core, audit-core adapter and test
target packs exist, but target run records are still gated. plane admission exist. `tenant-engine` is registered `not_applicable` for
- `T04` is in progress: all generic read/write operations detect the E2. Live audit-core still needs a new engagement and a provisioned plane.
known-bad fixture; target-specific calibration follows executable adapters. - `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. - `T05` is in progress with a 24-hour E3 cadence and offline evaluator.
- `T06` is in progress with a bounded characterization evaluator; no shared - `T06` is in progress with a bounded characterization evaluator; no shared
substrate window is approved. substrate window is approved.
- `T07` is in progress with a report schema and risk-nexus message formatter; - `T07` is in progress: reports can be queued to `outbox/`; the first target
the first target report has not yet been produced. 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 - No live probe traffic is authorized; each target still requires its own
engagement record and approvals. engagement record, plane admission and approvals.
## Relationships ## Relationships

View file

@ -12,7 +12,8 @@
| task | WHITEHAT-WP-0001-T01 | done | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | | 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-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-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-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-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-T07 | progress | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md |
| task | WHITEHAT-WP-0001-T08 | done | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md |

View file

@ -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 `evidence/offline-calibration.json` is calibration evidence, not target
assurance. 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 ## Prepare a target run
1. Select the target's probe pack and review every route with its owner. 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. 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 5. Obtain the ordinary modeled credential through its custody lane. Do not put
its value in the record or shell history. 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 ```sh
PYTHONPATH=src python3 -m whitehat_security.cli validate-engagement engagements/<record>.json PYTHONPATH=src python3 -m whitehat_security.cli validate-engagement engagements/<record>.json
PYTHONPATH=src python3 -m whitehat_security.cli admit-plane engagements/<record>.json targets/<target>.json
``` ```
7. Confirm target identity/revision and probe attribution before the first 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`. proven; otherwise `pass`.
- Validate the record against `schemas/run-report.schema.json`. - Validate the record against `schemas/run-report.schema.json`.
- Route both passing and finding target reports to `risk-nexus`; never assign - Route both passing and finding target reports to `risk-nexus`; never assign
severity. The message formatter is `whitehat risk-message <report.json>`. severity. Queue the delivery with `whitehat deliver <report.json>` (fixture
calibration is refused). The message formatter is
`whitehat risk-message <report.json>`.
- Schedule the next run from the target's declared cadence. - Schedule the next run from the target's declared cadence.
No target run is complete until its dated report and risk-nexus delivery are No target run is complete until its dated report and risk-nexus delivery are

View file

@ -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, whether an observation is a finding or note, and owns severity, disclosure,
review cadence and escalation. review cadence and escalation.
Offline calibration stays in this repository and is plainly labeled Queue a target report without assigning severity:
`evidence_class: fixture`; it is not sent as if it were a target result.
```sh
PYTHONPATH=src python3 -m whitehat_security.cli deliver evidence/<run>.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.

View file

@ -2,7 +2,7 @@
Status: **accepted** Status: **accepted**
Version: 0.1 Version: 0.2
Date: 2026-08-18 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 probe must first fail against its known-bad fixture and pass against the
known-good fixture in the same revision. 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 Approved by: **repository operator**, by explicit approval in the coordinating
session 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. 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 Every live run still requires the engagement record, target-owner
acknowledgement and any technique-specific operator approval required by §1. 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.

View file

@ -76,3 +76,10 @@ each run:
Resume only with new engagement IDs after that plane can issue the correct test 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 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. 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.

View file

@ -1,10 +1,12 @@
# Target applicability for WHITEHAT-WP-0001 # 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 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; 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 ## audit-core — applicable

48
docs/test-plane.md Normal file
View file

@ -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/<record>.json targets/<target>.json
PYTHONPATH=src python3 -m whitehat_security.cli kill-switch
PYTHONPATH=src python3 -m whitehat_security.cli deliver evidence/<run>.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.

View file

@ -9,3 +9,8 @@ or route.
An engagement record contains authorization metadata only. Never put a token, An engagement record contains authorization metadata only. Never put a token,
password, database URL, secret path value, or real tenant identifier here. 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.

View file

@ -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.

View file

@ -5,10 +5,14 @@
"approved_at": "YYYY-MM-DDTHH:MM:SSZ", "approved_at": "YYYY-MM-DDTHH:MM:SSZ",
"expires_at": "YYYY-MM-DDTHH:MM:SSZ", "expires_at": "YYYY-MM-DDTHH:MM:SSZ",
"target": "https://target.example.invalid", "target": "https://target.example.invalid",
"target_id": "registered-target-id",
"target_owner": "responsible repo or party", "target_owner": "responsible repo or party",
"target_owner_acknowledged_at": "YYYY-MM-DDTHH:MM:SSZ", "target_owner_acknowledged_at": "YYYY-MM-DDTHH:MM:SSZ",
"environment": "build", "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", "source": "probe source and network position",
"routes": ["GET /exact/in-scope/route"], "routes": ["GET /exact/in-scope/route"],
"fixture_ids": ["synthetic-tenant-a", "synthetic-tenant-b"], "fixture_ids": ["synthetic-tenant-a", "synthetic-tenant-b"],
@ -19,6 +23,7 @@
"prohibited_techniques": ["discovery", "saturation", "real-data collection"], "prohibited_techniques": ["discovery", "saturation", "real-data collection"],
"rate_limit_per_minute": 10, "rate_limit_per_minute": 10,
"max_concurrency": 1, "max_concurrency": 1,
"maximum_requests": 30,
"window_start": "YYYY-MM-DDTHH:MM:SSZ", "window_start": "YYYY-MM-DDTHH:MM:SSZ",
"window_end": "YYYY-MM-DDTHH:MM:SSZ", "window_end": "YYYY-MM-DDTHH:MM:SSZ",
"operator_contact": "operator contact", "operator_contact": "operator contact",

View file

@ -1,5 +1,5 @@
{ {
"ended_at": "2026-08-21T21:51:31.697322Z", "ended_at": "2026-08-21T22:43:03.740339Z",
"evidence_class": "fixture", "evidence_class": "fixture",
"expected": { "expected": {
"known_bad": "finding", "known_bad": "finding",
@ -12,7 +12,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "eaad30a4dc5b105af70231b5a4e72b9fc61e64a73a24074f919efd6fb5f9e9ae", "run_digest": "2804e68bca6d4652529b13014b55efa296eb81101726ff8d6634dff4b97c5f32",
"schema": [ "schema": [
"$", "$",
"$.error:str" "$.error:str"
@ -23,7 +23,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 3, "fixture_match_count": 3,
"run_digest": "5b9c2a46c8b84818280ce239e6bb01157bbcdf93165b8bfa97dd2c8c9121280f", "run_digest": "6095953dfd3d463aa844f1c51d20745bb5c0c5a79ce223c7445e57b6c323cd31",
"schema": [ "schema": [
"$", "$",
"$.id:str", "$.id:str",
@ -36,7 +36,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 3, "fixture_match_count": 3,
"run_digest": "5b9c2a46c8b84818280ce239e6bb01157bbcdf93165b8bfa97dd2c8c9121280f", "run_digest": "6095953dfd3d463aa844f1c51d20745bb5c0c5a79ce223c7445e57b6c323cd31",
"schema": [ "schema": [
"$", "$",
"$.id:str", "$.id:str",
@ -60,7 +60,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 0, "count": 0,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "de0385fce9a44845bc70a21d8561a6b3211538b2c32d9b35789042dfe594f223", "run_digest": "4a8cefe3ea0fa3fb888d8c578dd227c6a12216868da197f82b1e4de23aedd0f0",
"schema": [ "schema": [
"$", "$",
"$.items[]" "$.items[]"
@ -71,7 +71,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 3, "fixture_match_count": 3,
"run_digest": "3985d8408fbce3de8f6cd6dbf9b2d70257bbaee903cc5bc5a3da61610f6689c0", "run_digest": "29e86fd84a1c6aa038d8d1278d52c4470135bcc81db39b800940132e2761bc7b",
"schema": [ "schema": [
"$", "$",
"$.items[]", "$.items[]",
@ -86,7 +86,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 3, "fixture_match_count": 3,
"run_digest": "3985d8408fbce3de8f6cd6dbf9b2d70257bbaee903cc5bc5a3da61610f6689c0", "run_digest": "29e86fd84a1c6aa038d8d1278d52c4470135bcc81db39b800940132e2761bc7b",
"schema": [ "schema": [
"$", "$",
"$.items[]", "$.items[]",
@ -111,7 +111,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "857d9822bdfb939e4f126efa577be8d4a9e64ab023f5d611ca581498a963d048", "run_digest": "4a2f56562162e6bb6745ca18b8e7325c9565235506d3870bb907ba588ac4fadc",
"schema": [ "schema": [
"$", "$",
"$.error:str" "$.error:str"
@ -122,7 +122,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 2, "fixture_match_count": 2,
"run_digest": "06e7ab37f09fcbcb142cf1d09927885685ef9f9d273f2ec49186c40a16c04c18", "run_digest": "5e5ea8b63082ebc7bcce23fd5b66098a2e4ee092dba8a1c4c2a562e70e7c9550",
"schema": [ "schema": [
"$", "$",
"$.id:str", "$.id:str",
@ -134,7 +134,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "bf9bfc96947e5da52b13b9e027ad6f5c115da06c05dc7c79b82ea6b3a6514965", "run_digest": "3a686f08bad5214b56d3e0aa05c141998519809e5cb16499c3f3784a0becbe4a",
"schema": [ "schema": [
"$", "$",
"$.id:str" "$.id:str"
@ -156,7 +156,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "65bb2998672433c35d92bdb9b15965b2eb72776280b5cecc95c9d94e9149af12", "run_digest": "44a1c9a896f575f82c40a744d41519a1f84469cfe22fc889f6f68756da57d77f",
"schema": [ "schema": [
"$", "$",
"$.error:str" "$.error:str"
@ -167,7 +167,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 3, "fixture_match_count": 3,
"run_digest": "7e3e05697523dde79079b1a5cf6ed50e12177962742b8b7ddcf62d025daad6ad", "run_digest": "f48030bb08aa0d07380f0cec2ff0f919c5312fa780e3388984e8d4873440189f",
"schema": [ "schema": [
"$", "$",
"$.id:str", "$.id:str",
@ -180,7 +180,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 3, "fixture_match_count": 3,
"run_digest": "49d1b1ff789c3cf3709f0e7fe0041dc6cdbbfc7106ee736bb9d8acfcbb13568b", "run_digest": "5baf8016840cabb74202e381f5f163a463c1279382d8f74332f3b602f1c9704e",
"schema": [ "schema": [
"$", "$",
"$.id:str", "$.id:str",
@ -205,7 +205,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "15604312df72ed94d0b08f047fbe1e185e3bd6f49e41cb6cb6b779ba0c06be0a", "run_digest": "6d9c1f7d5d5c20afb8d8337ba4214e04be05e1b2336edc6263720c2f349a04cc",
"schema": [ "schema": [
"$", "$",
"$.error:str" "$.error:str"
@ -216,7 +216,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 1, "fixture_match_count": 1,
"run_digest": "ec66dc06c3b3202bc3e2f233c0eed22848106a5dbb2d39ca90233bfbb853e852", "run_digest": "32d054c129bb639b1beb7b8ca83a9556b88558facc84132e9fec63950cb4e66d",
"schema": [ "schema": [
"$", "$",
"$.deleted:str" "$.deleted:str"
@ -227,7 +227,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 3, "fixture_match_count": 3,
"run_digest": "961561666050ffa3e74a3ccf98a27859fd657a962767ff7a507cb6cca7a1cd5b", "run_digest": "3853f8bf9f473716ce4fced2d44f3a58b411c078afc6f131b4a47eda4929f798",
"schema": [ "schema": [
"$", "$",
"$.id:str", "$.id:str",
@ -245,6 +245,154 @@
"tenant-B fixture state changed", "tenant-B fixture state changed",
"attacker response differs from the absent-object reference" "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": [ "known_good": [
@ -254,7 +402,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "eddeeb2191de71535b4d2c06a88807ba797dff4b2c13481f06c522e0315bb7ea", "run_digest": "056e35eda0bacdda3e2a862a5e325f5fe090b8090f83037b5172200ff7b0e6dc",
"schema": [ "schema": [
"$", "$",
"$.error:str" "$.error:str"
@ -265,7 +413,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "eddeeb2191de71535b4d2c06a88807ba797dff4b2c13481f06c522e0315bb7ea", "run_digest": "056e35eda0bacdda3e2a862a5e325f5fe090b8090f83037b5172200ff7b0e6dc",
"schema": [ "schema": [
"$", "$",
"$.error:str" "$.error:str"
@ -276,7 +424,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 3, "fixture_match_count": 3,
"run_digest": "13c9649399717d67dec6441a3e6ee046a389a84549fc39538548bb7e2496df37", "run_digest": "49ea6432de7c9a88d2b32a4601d4fa811436909127084edaed522c28bc627235",
"schema": [ "schema": [
"$", "$",
"$.id:str", "$.id:str",
@ -297,7 +445,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 0, "count": 0,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "db750d043a8e87c08f86b35735cbde33060236fbf673934edd16a2ef8077b314", "run_digest": "61f8492a8388851bdfe14592a200635255cbe24d068f524ae6de9d6b68260c3d",
"schema": [ "schema": [
"$", "$",
"$.items[]" "$.items[]"
@ -308,7 +456,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "a1cbcf47d0c2f17a97abc59e2a559729fc027337f4360be44287764a6b061e7e", "run_digest": "3add37393c96826ead72b129dbf8ac0efd07c254231a7a5a40640d953e82320e",
"schema": [ "schema": [
"$", "$",
"$.items[]", "$.items[]",
@ -323,7 +471,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 3, "fixture_match_count": 3,
"run_digest": "1ce9addbaf53fb9dd8ced38fd0bbd237e7d338ac3ad31a2d8e06e168620d91c6", "run_digest": "479d2a3ab215dd0a629ca719163e1454439b782193e5202bf34e65c0c3a7ccf9",
"schema": [ "schema": [
"$", "$",
"$.items[]", "$.items[]",
@ -346,7 +494,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "2375ef32a41c0e05c19b2e362c0b2d1e490b0ecf863b7db9a3fb2c7824650f88", "run_digest": "e33ae1117bfbf3ea0f2beee3ada7a4b2e7105e3f8ff9ea0f32e1a458e97c8277",
"schema": [ "schema": [
"$", "$",
"$.error:str" "$.error:str"
@ -357,7 +505,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "2375ef32a41c0e05c19b2e362c0b2d1e490b0ecf863b7db9a3fb2c7824650f88", "run_digest": "e33ae1117bfbf3ea0f2beee3ada7a4b2e7105e3f8ff9ea0f32e1a458e97c8277",
"schema": [ "schema": [
"$", "$",
"$.error:str" "$.error:str"
@ -368,7 +516,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "93a604c1def0003e5ba2ac6bb71607adfaa5ad27e6f314c9e135d22ed91f4d50", "run_digest": "11a2bc634aa1f2cda1bb7f137339bc47f69c21f025b47fa6fd5795c0840b4adf",
"schema": [ "schema": [
"$", "$",
"$.id:str" "$.id:str"
@ -387,7 +535,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "128ec07ad319e1bd03582118fcb672edee8bdb90cf04259fd1c70565a49dfb24", "run_digest": "31c30991423c8f2d3dd4757e0fdd188b37f6470a6babad1d9700aef0f451da32",
"schema": [ "schema": [
"$", "$",
"$.error:str" "$.error:str"
@ -398,7 +546,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "128ec07ad319e1bd03582118fcb672edee8bdb90cf04259fd1c70565a49dfb24", "run_digest": "31c30991423c8f2d3dd4757e0fdd188b37f6470a6babad1d9700aef0f451da32",
"schema": [ "schema": [
"$", "$",
"$.error:str" "$.error:str"
@ -409,7 +557,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 3, "fixture_match_count": 3,
"run_digest": "39041e5495f98db586db32437188e52a4d1afe349f9b29f1fcbb8c2ed163f750", "run_digest": "fb069aca7ea87059083bee9517d11d411a68e8b1ba88a1bf59394f11c6ab83c2",
"schema": [ "schema": [
"$", "$",
"$.id:str", "$.id:str",
@ -430,7 +578,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "d494c9a631fb3af8ad9a36c369d6633d5baa727b078f1740c1e08fc811834959", "run_digest": "9205aee8ba243fc42f8ccf89fad98523c85c39adaa2311e9263512b2cc6faa9e",
"schema": [ "schema": [
"$", "$",
"$.error:str" "$.error:str"
@ -441,7 +589,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 0, "fixture_match_count": 0,
"run_digest": "d494c9a631fb3af8ad9a36c369d6633d5baa727b078f1740c1e08fc811834959", "run_digest": "9205aee8ba243fc42f8ccf89fad98523c85c39adaa2311e9263512b2cc6faa9e",
"schema": [ "schema": [
"$", "$",
"$.error:str" "$.error:str"
@ -452,7 +600,7 @@
"content_type": "application/json", "content_type": "application/json",
"count": 1, "count": 1,
"fixture_match_count": 3, "fixture_match_count": 3,
"run_digest": "a5ea7cb1c5b72a7b06f989181b4ff39debe887134fcc69c0053602dbc82b98da", "run_digest": "681e463a7d41ce5ff05da5bc75241a0a176e8a6c1fdb19e1e88cc8f9490e023b",
"schema": [ "schema": [
"$", "$",
"$.id:str", "$.id:str",
@ -466,6 +614,142 @@
"outcome": "pass", "outcome": "pass",
"probe_id": "delete-b", "probe_id": "delete-b",
"reasons": [] "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": [ "limitations": [
@ -473,7 +757,7 @@
"No network request, database connection, or live credential was used." "No network request, database connection, or live credential was used."
], ],
"outcome": "pass", "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", "schema_version": "whitehat-calibration/v1",
"started_at": "2026-08-21T21:51:31.696239Z" "started_at": "2026-08-21T22:43:03.737244Z"
} }

5
outbox/README.md Normal file
View file

@ -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`.

27
plane/README.md Normal file
View file

@ -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.

9
plane/namespace.yaml Normal file
View file

@ -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

40
plane/network-policy.yaml Normal file
View file

@ -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

53
plane/runner-pod.yaml Normal file
View file

@ -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

View file

@ -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

View file

@ -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}
}
}
}
}

View file

@ -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,
),
]

View file

@ -6,19 +6,26 @@ import sys
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path from pathlib import Path
from .audit_fixtures import AuditFixture, audit_probe_suite
from .capacity import CapacitySample, characterize from .capacity import CapacitySample, characterize
from .differential import execute from .differential import execute
from .e3 import CADENCE, PROBES from .e3 import CADENCE, PROBES
from .engagement import AuthorizationError, Engagement from .engagement import AuthorizationError, Engagement
from .fixtures import FixtureService, probe_suite from .fixtures import FixtureService, probe_suite
from .model import RunReport, utc_now 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: def fixture_calibration() -> dict:
started = utc_now() started = utc_now()
good = [execute(probe) for probe in probe_suite(FixtureService(enforce_tenant=True))] good_generic = probe_suite(FixtureService(enforce_tenant=True))
bad = [execute(probe) for probe in probe_suite(FixtureService(enforce_tenant=False))] 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) detected = all(result.outcome == "finding" for result in bad)
rejected = all(result.outcome == "pass" for result in good) rejected = all(result.outcome == "pass" for result in good)
return { return {
@ -78,6 +85,15 @@ def main(argv: list[str] | None = None) -> None:
engagement.add_argument("path") engagement.add_argument("path")
packs = commands.add_parser("validate-packs") packs = commands.add_parser("validate-packs")
packs.add_argument("path") 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("e3-plan")
commands.add_parser("capacity-fixture") commands.add_parser("capacity-fixture")
message = commands.add_parser("risk-message") message = commands.add_parser("risk-message")
@ -108,6 +124,42 @@ def main(argv: list[str] | None = None) -> None:
validate_pack(path) validate_pack(path)
print(f"validated {len(paths)} probe packs") print(f"validated {len(paths)} probe packs")
return 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": if args.command == "e3-plan":
print(json.dumps({"cadence": CADENCE, "probes": [asdict(probe) for probe in PROBES]}, print(json.dumps({"cadence": CADENCE, "probes": [asdict(probe) for probe in PROBES]},
indent=2, sort_keys=True)) indent=2, sort_keys=True))

View file

@ -2,10 +2,11 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from typing import Callable from typing import Callable
from .engagement import Engagement
from .model import Observation, ProbeResult, Response, observe from .model import Observation, ProbeResult, Response, observe
from .plane import PlaneLease
Request = Callable[[], Response] Request = Callable[[], Response]
State = Callable[[], object] State = Callable[[], object]
@ -49,12 +50,17 @@ def execute(probe: DifferentialProbe, *, salt: bytes | None = None) -> ProbeResu
) )
def execute_authorized(probe: DifferentialProbe, *, engagement: Engagement, def execute_authorized(probe: DifferentialProbe, *, lease: PlaneLease,
route: str, salt: bytes | None = None) -> ProbeResult: route: str, salt: bytes | None = None,
"""Live entry point: authorization is rechecked immediately before calls.""" now: datetime | None = None) -> ProbeResult:
engagement.validate() """Live entry point: plane admission is rechecked immediately before calls."""
engagement.permits(technique="e2-differential", route=route) lease.engagement.validate(now=now)
lease.permits(technique="e2-differential", route=route)
lease.watcher.acquire()
try:
return execute(probe, salt=salt) return execute(probe, salt=salt)
finally:
lease.watcher.release()
def _equivalent_denial(left: Observation, right: Observation) -> bool: def _equivalent_denial(left: Observation, right: Observation) -> bool:

View file

@ -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,
}

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from .engagement import AuthorizationError
from .model import RunReport 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."]) lines.extend(["", "Reporter supplies facts only; risk-nexus owns severity and disclosure."])
return "\n".join(lines) + "\n" 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

View file

@ -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

14
targets/README.md Normal file
View file

@ -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/`.

View file

@ -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"
]
}

37
targets/fixture-e2.json Normal file
View file

@ -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"
]
}

22
targets/flex-auth-e2.json Normal file
View file

@ -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"
]
}

View file

@ -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"
]
}

View file

@ -12,3 +12,43 @@ def test_validate_engagement_reports_clean_denial(tmp_path, capsys):
main(["validate-engagement", str(path)]) main(["validate-engagement", str(path)])
assert stopped.value.code == 2 assert stopped.value.code == 2
assert capsys.readouterr().err.startswith("not authorized:") 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

234
tests/test_plane.py Normal file
View file

@ -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"}

View file

@ -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, classes, per-run records, initial target envelope, hard prohibitions,
credential/attribution rules, rate defaults, abort/cleanup and evidence credential/attribution rules, rate defaults, abort/cleanup and evidence
schema. The operator accepted v0.1 on 2026-08-21 with the scope recorded in 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 what is now §11. v0.2 (2026-08-22) adds §10, the governed test plane, as a
does not pre-authorize any live target. 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 ### 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. 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). - 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 **Acceptance:** a dated run record against every *applicable* E2 target. The
currently claim `E2`. The artifact is the run record, not a green tick. 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` Implementation progress, 2026-08-22: `src/whitehat_security/differential.py`
now performs owner/attacker/absent comparisons, detects fixture disclosure and performs owner/attacker/absent comparisons, detects fixture disclosure and
write-side state change, and persists only sanitized observations. Concrete write-side state change, and persists only sanitized observations. Live entry
route inventories live in `probe-packs/tenant-engine-e2.json` and is `execute_authorized`, which requires a plane lease. Concrete route
`probe-packs/audit-core-e2.json`. The two target runs remain gated on complete inventories live in `probe-packs/`. Target honesty lives in `targets/`:
engagement records, owner acknowledgements and executable identity adapters. `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 The 2026-08-21 production engagement IDs were cancelled without sending a
`engagements/`. Audit-core accepted the route/identity shape and reviewed the target request and are retired. A live audit-core run still needs a **new**
adapter; its first review caught a missing idempotency header before traffic, engagement, target-owner acknowledgement, provisioned `whitehat` namespace,
which is corrected and regression-tested. `docs/target-applicability.md` and a connected custody broker. Whitehat will not relabel tenant-engine to
records a separate issue: tenant-engine currently exposes no authenticated finish this task.
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.
### T04 — Prove the probes fail ### T04 — Prove the probes fail
```task ```task
id: WHITEHAT-WP-0001-T04 id: WHITEHAT-WP-0001-T04
status: progress status: done
priority: high priority: high
state_hub_task_id: "a2afc0f5-3ece-410c-864c-829a8aac30fc" 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 **Acceptance:** every probe in T03 demonstrated failing before any of them is
trusted passing. trusted passing.
Implementation progress, 2026-08-21: five generic read/list/create/update/ Completed 2026-08-22. Five generic read/list/create/update/delete probes and
delete probes pass the enforcing in-process fixture and all five produce the three audit-core shaped probes pass the enforcing in-process fixture and
findings when its tenant predicate is removed. The sanitized artifact is all produce findings when the tenant predicate is removed. Tenant-engine has
`evidence/offline-calibration.json`; fourteen tests guard the calibration and no applicable E2 identity, so its pack is not calibrated as if it were E2.
fail-closed authorization behavior. Target-specific probes are not trusted `make fixture-evidence` refreshes `evidence/offline-calibration.json`.
passing until they receive the same calibration. 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) ### 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 the honest claim, and recording it dated is what lets anyone see how stale the
assurance has become. assurance has become.
Implementation progress, 2026-08-21: `schemas/run-report.schema.json` defines Implementation progress, 2026-08-22: `schemas/run-report.schema.json` defines
the minimized evidence contract and `whitehat risk-message` renders both pass the minimized evidence contract, `whitehat risk-message` renders both pass
and finding deliveries without severity. Offline calibration is deliberately and finding deliveries without severity, and `whitehat deliver` queues a
not routed as target assurance. Completion awaits the first authorized target target report to `outbox/`. Fixture calibration is refused as target
report being delivered to `risk-nexus`. 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 ## Sequencing
T01 gates all. T02 shapes T03/T05/T06. T04 gates trusting any of them. T07 can T01 gates all. T02 shapes T03/T05/T06. T04 gates trusting any of them. T08
follow T03. gates live T03. T07 can follow T03.
## Session cutoff — 2026-08-22 ## Session cutoff — 2026-08-22
This session ends with the workplan deliberately **active**, not cosmetically The coordinating session ended with the workplan deliberately **active**. T01,
complete. T01 and T02 are done. T03 through T07 remain in progress. The two 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 dated production engagement records were cancelled without sending any target
request; cancelled engagement IDs are terminal and must never be reused. request; cancelled engagement IDs are terminal and must never be reused.
The offline instrument is implemented and calibrated, but the estate does not The offline instrument is implemented, calibrated, and plane-gated. The
yet provide the governed test plane needed to exercise it routinely and safely. estate still has to provision the cluster plane and a value-safe credential
The exact completed scope, unresolved scope, and recommended restart boundary broker before any live evidence exists. Resume only with new engagement IDs.
are recorded in `docs/session-cutoff-2026-08-22.md`. The exact completed scope is recorded in `docs/session-cutoff-2026-08-22.md`
and `docs/test-plane.md`.
## Risks ## Risks