Build authorization-gated tenancy evidence harness

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0260c-4067-7052-9647-ad000d576e38
This commit is contained in:
tegwick 2026-08-21 23:53:27 +02:00
parent 2c8e1d41ad
commit beab2a04d1
32 changed files with 1816 additions and 11 deletions

4
.gitignore vendored
View file

@ -1 +1,5 @@
.repo-manager/ .repo-manager/
__pycache__/
*.py[cod]
.pytest_cache/
.venv/

10
Makefile Normal file
View file

@ -0,0 +1,10 @@
.PHONY: check test fixture-evidence
check: test
PYTHONPATH=src python3 -m whitehat_security.cli validate-packs probe-packs
test:
PYTHONPATH=src python3 -m pytest -q
fixture-evidence:
PYTHONPATH=src python3 -m whitehat_security.cli fixtures --output evidence/offline-calibration.json

View file

@ -25,3 +25,18 @@ probes most worth having are the ones an author would not think to write.
- Attacker model: [`docs/attacker-model.md`](docs/attacker-model.md) - Attacker model: [`docs/attacker-model.md`](docs/attacker-model.md)
- Rules of engagement (accepted; live targets still require engagement approval): - Rules of engagement (accepted; live targets still require engagement approval):
[`docs/rules-of-engagement.md`](docs/rules-of-engagement.md) [`docs/rules-of-engagement.md`](docs/rules-of-engagement.md)
- Operator runbook: [`docs/operator-runbook.md`](docs/operator-runbook.md)
- Target packs: [`probe-packs/`](probe-packs/)
## Offline calibration
```sh
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).

View file

@ -69,7 +69,15 @@ 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 ready to begin with offline harness and fixture design. - `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.
- `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.
- 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 and approvals.

View file

@ -11,8 +11,8 @@
| workplan | WHITEHAT-WP-0001 | active | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | | workplan | WHITEHAT-WP-0001 | active | — | 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-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 | todo | — | 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 | wait | — | 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-T05 | wait | — | 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 | wait | — | 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 | wait | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | | task | WHITEHAT-WP-0001-T07 | progress | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md |

28
docs/e3-cadence.md Normal file
View file

@ -0,0 +1,28 @@
# E3 conformance cadence
The E3 detection interval is **24 hours**, so the declared maximum exposure
window is **24 hours plus run and reporting latency**. A run is also required
before deployment promotion following any schema migration, RLS policy change,
role/grant change, security-definer function change, or posture-mechanism
change. The event-triggered run does not reset the daily schedule.
This is a detection control, not prevention. A migration can introduce an
unprotected table between runs; the interval states how long that condition
can remain unnoticed under normal scheduling.
The executable plan is available with:
```sh
PYTHONPATH=src python3 -m whitehat_security.cli e3-plan
```
The run checks the conformance view, unset-GUC reads, wrong-tenant reads and
writes, `BYPASSRLS` on the runtime identity, and unsafe `SECURITY DEFINER`
paths. The SQL-compromise reset probe is recorded as `inconclusive` under E3:
changing the GUC after gaining SQL execution demonstrates E3's documented
limit and must not be mislabeled a failed E3-A control.
Every database run requires its own approved target/database/window. It uses
one ordinary runtime connection, sets `application_name` to the engagement id,
and never uses an owner, superuser or `BYPASSRLS` role for convenience.

View file

@ -0,0 +1,27 @@
# Noisy-neighbour characterization
There is no standing authorization or default ceiling for this technique. A
run requires a separate operator-approved target, namespace, time window,
aggressor allowance, concurrency/resource ceilings, service classes, headroom
threshold, and abort thresholds.
For each co-resident consumer the artifact records a quiet baseline and a
loaded sample: latency, error rate, throughput and the declared service class.
The aggressor may reach but never exceed its own allowance. The artifact also
records whether the governor bound, peak versus ceiling, aggregate headroom,
and each neighbour's measured degradation.
Zero degradation is not the expected assertion on shared infrastructure.
Whether measured degradation is acceptable belongs to the responsible owner;
whitehat records the observation. A governor that does not bind, an exceeded
probe ceiling, missing neighbour samples, or an abort threshold produces a
finding/abort rather than a stronger retry.
The evaluator is calibrated offline with:
```sh
PYTHONPATH=src python3 -m whitehat_security.cli capacity-fixture
```
That command generates no load and is not P1/P2 target evidence.

69
docs/operator-runbook.md Normal file
View file

@ -0,0 +1,69 @@
# Operator runbook
This runbook does not authorize a target. Read and follow
[`rules-of-engagement.md`](rules-of-engagement.md) first.
## Offline calibration
The calibration is safe to run without target authorization. It opens no
socket and uses only synthetic in-process services created by this repository.
```sh
make check
make fixture-evidence
```
Success requires every read/write probe to pass against the enforcing fixture
and to produce a finding against the fixture with its tenant predicate removed.
`evidence/offline-calibration.json` is calibration evidence, not target
assurance.
## Prepare a target run
1. Select the target's probe pack and review every route with its owner.
2. Copy `engagements/template.json` to a dated record. Resolve every field;
placeholders are invalid.
3. Record operator approval and a target-owner acknowledgement after approval.
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:
```sh
PYTHONPATH=src python3 -m whitehat_security.cli validate-engagement engagements/<record>.json
```
7. Confirm target identity/revision and probe attribution before the first
hostile operation. If either is uncertain, abort.
## Execute
Execution adapters are target-specific because identity binding is part of
the property under test. An adapter must implement the `DifferentialProbe`
contract: owner request, attacker request, absent-object reference, fixture
markers, and (for writes) a tenant-B state oracle. It must call
`Engagement.permits()` immediately before each route and obey the recorded
rate/concurrency limit.
Run owner and absent controls before the attacker request. Keep response bodies
only in memory. Persist the observation produced by `execute()`: status,
content type, count, schema paths, run-local digest and fixture-match count.
Stop immediately on any abort condition in the rules. Do not investigate an
unexpected response by broadening routes, identities, discovery, or privilege.
## Close and report
- Delete only recorded fixtures and verify their absence.
- Revoke the lease or record its bounded expiry.
- Set the overall outcome to `finding` if any probe finds exposure or mutation;
`aborted` on an abort; `inconclusive` when controls or cleanup cannot be
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 <report.json>`.
- Schedule the next run from the target's declared cadence.
No target run is complete until its dated report and risk-nexus delivery are
both recorded.

23
docs/reporting.md Normal file
View file

@ -0,0 +1,23 @@
# Reporting contract
Every target run—pass, finding, inconclusive or abort—uses
`schemas/run-report.schema.json`. Reports include authorization and engagement
ids, target revision, posture/model, timestamps, sanitized observations,
cleanup and credential disposition. They never include response bodies,
credentials or severity.
A finding is routed to `risk-nexus` with supported facts and provenance. A
passing report is routed too because its date and target revision define the
freshness of the limited assurance. Generate the message body with:
```sh
PYTHONPATH=src python3 -m whitehat_security.cli risk-message evidence/<run>.json
```
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.

View file

@ -0,0 +1,60 @@
# Proposed E2 engagements — operator approval package
Status: **proposed; sends no traffic**
These proposals turn the two remaining E2 acceptance runs into bounded
operator decisions. They are not active until the operator approves them and
the target owner acknowledges the identity/fixture design.
## WH-ENG-20260821-AUDIT-E2
| Field | Proposed value |
| --- | --- |
| Target | railiance01 production deployment; `audit-core` namespace; `audit-core.audit-core.svc.cluster.local:8080` |
| Owner | `audit-core` |
| Source | dedicated attributed probe pod in an owner-approved ingress namespace |
| Routes | `POST /v1/events`; `GET /v1/events/{id}`; `GET /v1/events?correlation_id=` |
| Fixtures | two new synthetic tenants, two scoped reader/sender identities, fixture events only |
| Credential | temporary ordinary sender tokens; tenant scoped; at most 15-minute validity; no operator/database credential |
| Technique | E2 owner/attacker/absent differential; read and append/state checks |
| Ceiling | concurrency 1; 10 HTTP requests/minute; at most 30 total requests |
| Window | 15 minutes, scheduled after owner acknowledgement; no discovery or saturation |
| Abort | unexpected/non-fixture data; absent attribution; wrong target/revision; readiness failure; operator/owner stop; p95 over 500 ms or any 5xx attributable to probe |
| Cleanup | remove only named fixture events where target contract permits; otherwise retain them as explicitly named audit fixtures; revoke both temporary sender tokens |
| Reporting | all outcomes to `risk-nexus`, without severity |
Owner question sent as State Hub message
`93c3ca5e-34a2-43af-9843-19eef1ef350b`. The current deployment's only ordinary
sender is wildcard-write/no-read, so the owner must approve temporary scoped
read identities or a semantically equivalent setup before this can test E2.
## WH-ENG-20260821-TENANT-E2
| Field | Proposed value |
| --- | --- |
| Target | railiance01 production deployment; `tenant-engine` namespace; `tenant-engine.tenant-engine.svc.cluster.local:8090` |
| Owner | `tenant-engine` |
| Source | dedicated attributed probe pod admitted through the owner-approved `user-engine` ingress class |
| Routes | declared tenant read, roles/cache, roles/live, guardrails, role grant, plan, lifecycle, grouping and guardrail routes in `probe-packs/tenant-engine-e2.json` |
| Fixtures | two new synthetic tenant records and only their grants/plans/guardrails |
| Credential | ordinary caller identity approved by the owner; no database, owner, superuser or `BYPASSRLS` credential |
| Technique | E2 owner/attacker/absent differential with before/after state checks |
| Ceiling | concurrency 1; 10 HTTP requests/minute; at most 60 total requests |
| Window | 15 minutes, scheduled after owner acknowledgement; no discovery or saturation |
| Abort | unexpected/non-fixture data; absent attribution; wrong target/revision; readiness failure; operator/owner stop; p95 over 500 ms or any 5xx attributable to probe |
| Cleanup | delete/retire only the named fixture tenants using the owner-approved lifecycle; if deletion is not a target contract, retain/retire and record that limitation |
| Reporting | all outcomes to `risk-nexus`, without severity |
Owner question sent as State Hub message
`1d0ca44e-e2d7-4c66-8ee2-aa4af3c80375`. The request `actor` is evaluated by
flex-auth while network identity is the admitted workload. The owner must name
the legitimate tenant-A fixture identity/policy; an allow-all authorizer or an
invented tenant claim is not acceptable evidence.
## Explicitly excluded
These E2 approvals do not cover direct PostgreSQL access, RLS probes, schema or
role changes, connection/load saturation, recovery, production, adjacent
services, real tenant identifiers, or collection of response bodies. E3 and
P1/P2 each receive a separate approval package after E2 establishes the safe
execution path.

11
engagements/README.md Normal file
View file

@ -0,0 +1,11 @@
# Engagement records
Copy `template.json` to a dated, target-specific record and have both the
operator and target owner approve it before a live run. The runner validates
the record at execution time and fails closed when it is incomplete, expired,
outside its window, too permissive, or mismatched to the requested technique
or route.
An engagement record contains authorization metadata only. Never put a token,
password, database URL, secret path value, or real tenant identifier here.

31
engagements/template.json Normal file
View file

@ -0,0 +1,31 @@
{
"engagement_id": "WH-ENG-YYYYMMDD-NNN",
"authorization_id": "approval-record-reference",
"authorizer": "operator identity",
"approved_at": "YYYY-MM-DDTHH:MM:SSZ",
"expires_at": "YYYY-MM-DDTHH:MM:SSZ",
"target": "https://target.example.invalid",
"target_owner": "responsible repo or party",
"target_owner_acknowledged_at": "YYYY-MM-DDTHH:MM:SSZ",
"environment": "build",
"namespace": "namespace if applicable",
"source": "probe source and network position",
"routes": ["GET /exact/in-scope/route"],
"fixture_ids": ["synthetic-tenant-a", "synthetic-tenant-b"],
"credential_lane": "sanctioned custody lane reference",
"credential_role": "ordinary modeled role",
"credential_max_ttl_seconds": 900,
"techniques": ["e2-differential"],
"prohibited_techniques": ["discovery", "saturation", "real-data collection"],
"rate_limit_per_minute": 10,
"max_concurrency": 1,
"window_start": "YYYY-MM-DDTHH:MM:SSZ",
"window_end": "YYYY-MM-DDTHH:MM:SSZ",
"operator_contact": "operator contact",
"abort_contact": "target owner contact",
"abort_thresholds": {"latency_ms": 0, "error_rate": 0},
"posture_claim": "E2",
"attacker_model": "E2-authenticated-tenant-a",
"finding_destination": "risk-nexus"
}

11
evidence/README.md Normal file
View file

@ -0,0 +1,11 @@
# Evidence
This directory stores sanitized run artifacts. `offline-calibration.json` is
generated from repository-created fixtures and proves only that the harness
distinguishes known-good from known-bad behavior.
Before committing target evidence, verify that it contains no response body,
credential, database URL, real tenant identifier, or real tenant value. A
run-local digest is allowed; it must not be reusable across runs as a data
oracle.

View file

@ -0,0 +1,479 @@
{
"ended_at": "2026-08-21T21:51:31.697322Z",
"evidence_class": "fixture",
"expected": {
"known_bad": "finding",
"known_good": "pass"
},
"known_bad": [
{
"observations": {
"absent": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "eaad30a4dc5b105af70231b5a4e72b9fc61e64a73a24074f919efd6fb5f9e9ae",
"schema": [
"$",
"$.error:str"
],
"status": 404
},
"attacker": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 3,
"run_digest": "5b9c2a46c8b84818280ce239e6bb01157bbcdf93165b8bfa97dd2c8c9121280f",
"schema": [
"$",
"$.id:str",
"$.label:str",
"$.tenant:str"
],
"status": 200
},
"owner": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 3,
"run_digest": "5b9c2a46c8b84818280ce239e6bb01157bbcdf93165b8bfa97dd2c8c9121280f",
"schema": [
"$",
"$.id:str",
"$.label:str",
"$.tenant:str"
],
"status": 200
}
},
"operation": "read",
"outcome": "finding",
"probe_id": "read-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": "de0385fce9a44845bc70a21d8561a6b3211538b2c32d9b35789042dfe594f223",
"schema": [
"$",
"$.items[]"
],
"status": 200
},
"attacker": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 3,
"run_digest": "3985d8408fbce3de8f6cd6dbf9b2d70257bbaee903cc5bc5a3da61610f6689c0",
"schema": [
"$",
"$.items[]",
"$.items[]",
"$.items[].id:str",
"$.items[].label:str",
"$.items[].tenant:str"
],
"status": 200
},
"owner": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 3,
"run_digest": "3985d8408fbce3de8f6cd6dbf9b2d70257bbaee903cc5bc5a3da61610f6689c0",
"schema": [
"$",
"$.items[]",
"$.items[]",
"$.items[].id:str",
"$.items[].label:str",
"$.items[].tenant:str"
],
"status": 200
}
},
"operation": "read",
"outcome": "finding",
"probe_id": "list-filter",
"reasons": [
"attacker response contains a tenant-B fixture marker"
]
},
{
"observations": {
"absent": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "857d9822bdfb939e4f126efa577be8d4a9e64ab023f5d611ca581498a963d048",
"schema": [
"$",
"$.error:str"
],
"status": 403
},
"attacker": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 2,
"run_digest": "06e7ab37f09fcbcb142cf1d09927885685ef9f9d273f2ec49186c40a16c04c18",
"schema": [
"$",
"$.id:str",
"$.tenant:str"
],
"status": 201
},
"owner": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "bf9bfc96947e5da52b13b9e027ad6f5c115da06c05dc7c79b82ea6b3a6514965",
"schema": [
"$",
"$.id:str"
],
"status": 201
}
},
"operation": "create",
"outcome": "finding",
"probe_id": "create-attributed-to-b",
"reasons": [
"attacker response contains a tenant-B fixture marker",
"tenant-B fixture state changed"
]
},
{
"observations": {
"absent": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "65bb2998672433c35d92bdb9b15965b2eb72776280b5cecc95c9d94e9149af12",
"schema": [
"$",
"$.error:str"
],
"status": 404
},
"attacker": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 3,
"run_digest": "7e3e05697523dde79079b1a5cf6ed50e12177962742b8b7ddcf62d025daad6ad",
"schema": [
"$",
"$.id:str",
"$.label:str",
"$.tenant:str"
],
"status": 200
},
"owner": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 3,
"run_digest": "49d1b1ff789c3cf3709f0e7fe0041dc6cdbbfc7106ee736bb9d8acfcbb13568b",
"schema": [
"$",
"$.id:str",
"$.label:str",
"$.tenant:str"
],
"status": 200
}
},
"operation": "update",
"outcome": "finding",
"probe_id": "update-b",
"reasons": [
"attacker response contains a tenant-B fixture marker",
"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": "15604312df72ed94d0b08f047fbe1e185e3bd6f49e41cb6cb6b779ba0c06be0a",
"schema": [
"$",
"$.error:str"
],
"status": 404
},
"attacker": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 1,
"run_digest": "ec66dc06c3b3202bc3e2f233c0eed22848106a5dbb2d39ca90233bfbb853e852",
"schema": [
"$",
"$.deleted:str"
],
"status": 200
},
"owner": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 3,
"run_digest": "961561666050ffa3e74a3ccf98a27859fd657a962767ff7a507cb6cca7a1cd5b",
"schema": [
"$",
"$.id:str",
"$.label:str",
"$.tenant:str"
],
"status": 200
}
},
"operation": "delete",
"outcome": "finding",
"probe_id": "delete-b",
"reasons": [
"attacker response contains a tenant-B fixture marker",
"tenant-B fixture state changed",
"attacker response differs from the absent-object reference"
]
}
],
"known_good": [
{
"observations": {
"absent": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "eddeeb2191de71535b4d2c06a88807ba797dff4b2c13481f06c522e0315bb7ea",
"schema": [
"$",
"$.error:str"
],
"status": 404
},
"attacker": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "eddeeb2191de71535b4d2c06a88807ba797dff4b2c13481f06c522e0315bb7ea",
"schema": [
"$",
"$.error:str"
],
"status": 404
},
"owner": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 3,
"run_digest": "13c9649399717d67dec6441a3e6ee046a389a84549fc39538548bb7e2496df37",
"schema": [
"$",
"$.id:str",
"$.label:str",
"$.tenant:str"
],
"status": 200
}
},
"operation": "read",
"outcome": "pass",
"probe_id": "read-by-id",
"reasons": []
},
{
"observations": {
"absent": {
"content_type": "application/json",
"count": 0,
"fixture_match_count": 0,
"run_digest": "db750d043a8e87c08f86b35735cbde33060236fbf673934edd16a2ef8077b314",
"schema": [
"$",
"$.items[]"
],
"status": 200
},
"attacker": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "a1cbcf47d0c2f17a97abc59e2a559729fc027337f4360be44287764a6b061e7e",
"schema": [
"$",
"$.items[]",
"$.items[]",
"$.items[].id:str",
"$.items[].label:str",
"$.items[].tenant:str"
],
"status": 200
},
"owner": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 3,
"run_digest": "1ce9addbaf53fb9dd8ced38fd0bbd237e7d338ac3ad31a2d8e06e168620d91c6",
"schema": [
"$",
"$.items[]",
"$.items[]",
"$.items[].id:str",
"$.items[].label:str",
"$.items[].tenant:str"
],
"status": 200
}
},
"operation": "read",
"outcome": "pass",
"probe_id": "list-filter",
"reasons": []
},
{
"observations": {
"absent": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "2375ef32a41c0e05c19b2e362c0b2d1e490b0ecf863b7db9a3fb2c7824650f88",
"schema": [
"$",
"$.error:str"
],
"status": 403
},
"attacker": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "2375ef32a41c0e05c19b2e362c0b2d1e490b0ecf863b7db9a3fb2c7824650f88",
"schema": [
"$",
"$.error:str"
],
"status": 403
},
"owner": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "93a604c1def0003e5ba2ac6bb71607adfaa5ad27e6f314c9e135d22ed91f4d50",
"schema": [
"$",
"$.id:str"
],
"status": 201
}
},
"operation": "create",
"outcome": "pass",
"probe_id": "create-attributed-to-b",
"reasons": []
},
{
"observations": {
"absent": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "128ec07ad319e1bd03582118fcb672edee8bdb90cf04259fd1c70565a49dfb24",
"schema": [
"$",
"$.error:str"
],
"status": 404
},
"attacker": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "128ec07ad319e1bd03582118fcb672edee8bdb90cf04259fd1c70565a49dfb24",
"schema": [
"$",
"$.error:str"
],
"status": 404
},
"owner": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 3,
"run_digest": "39041e5495f98db586db32437188e52a4d1afe349f9b29f1fcbb8c2ed163f750",
"schema": [
"$",
"$.id:str",
"$.label:str",
"$.tenant:str"
],
"status": 200
}
},
"operation": "update",
"outcome": "pass",
"probe_id": "update-b",
"reasons": []
},
{
"observations": {
"absent": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "d494c9a631fb3af8ad9a36c369d6633d5baa727b078f1740c1e08fc811834959",
"schema": [
"$",
"$.error:str"
],
"status": 404
},
"attacker": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 0,
"run_digest": "d494c9a631fb3af8ad9a36c369d6633d5baa727b078f1740c1e08fc811834959",
"schema": [
"$",
"$.error:str"
],
"status": 404
},
"owner": {
"content_type": "application/json",
"count": 1,
"fixture_match_count": 3,
"run_digest": "a5ea7cb1c5b72a7b06f989181b4ff39debe887134fcc69c0053602dbc82b98da",
"schema": [
"$",
"$.id:str",
"$.label:str",
"$.tenant:str"
],
"status": 200
}
},
"operation": "delete",
"outcome": "pass",
"probe_id": "delete-b",
"reasons": []
}
],
"limitations": [
"Offline fixture evidence calibrates the harness; it is not target assurance.",
"No network request, database connection, or live credential was used."
],
"outcome": "pass",
"run_id": "fixture-calibration-2026-08-21T21:51:31.696239Z",
"schema_version": "whitehat-calibration/v1",
"started_at": "2026-08-21T21:51:31.696239Z"
}

View file

@ -0,0 +1,41 @@
{
"schema_version": "whitehat-probe-pack/v1",
"target": "audit-core",
"posture_claim": "E2",
"attacker_model": "docs/attacker-model.md#3-e2--authenticated-tenant-a-at-the-service-boundary",
"notes": [
"Use two read-enabled scoped sender identities, each restricted to its fixture tenant.",
"A correlation id deliberately spans services; the expected result is the caller's readable tenant slice.",
"Instance-wide stats, integrity, dead-letter and secret-finding routes are excluded because their contract is operator-only, not tenant-scoped."
],
"probes": [
{
"id": "audit-event-by-id",
"operation": "read",
"route": "GET /v1/events/{tenant_b_event_id}",
"owner": "tenant B sender reads tenant B fixture event",
"attacker": "tenant A sender substitutes tenant B fixture event id",
"absent": "tenant A sender reads a reserved absent event id",
"finding": "tenant B fixture marker is returned or existence differs from absent"
},
{
"id": "audit-correlation-slice",
"operation": "read",
"route": "GET /v1/events?correlation_id={shared_correlation_id}",
"owner": "tenant B sender queries correlation shared by A and B fixture events",
"attacker": "tenant A sender queries the same correlation",
"absent": "tenant A sender queries a reserved correlation with no events",
"finding": "tenant B fixture marker appears in tenant A's readable slice"
},
{
"id": "audit-append-as-b",
"operation": "create",
"route": "POST /v1/events",
"owner": "tenant B sender appends a tenant B control event",
"attacker": "tenant A sender submits a new event claiming tenant B",
"absent": "tenant A sender submits an otherwise equivalent disallowed-tenant control",
"finding": "append succeeds or subsequent tenant B state contains the attacker event"
}
]
}

View file

@ -0,0 +1,77 @@
{
"schema_version": "whitehat-probe-pack/v1",
"target": "tenant-engine",
"posture_claim": "E2",
"attacker_model": "docs/attacker-model.md#3-e2--authenticated-tenant-a-at-the-service-boundary",
"notes": [
"Actor identities must be real flex-auth-bound fixture identities; an allow-all authorizer is not target evidence.",
"TenantStore.events() is excluded because tenant-engine exposes no HTTP event-read route.",
"Writes require state-oracle reads before and after; response status alone is insufficient."
],
"probes": [
{
"id": "tenant-record-by-id",
"operation": "read",
"route": "GET /tenants/{tenant_b_id}",
"owner": "tenant B actor reads tenant B fixture",
"attacker": "tenant A actor substitutes tenant B id",
"absent": "tenant A actor reads a reserved absent tenant id",
"finding": "tenant B marker is returned or existence differs from absent"
},
{
"id": "tenant-roles-cache",
"operation": "read",
"route": "GET /tenants/{tenant_b_id}/roles",
"owner": "tenant B cache-reader reads tenant B roles",
"attacker": "tenant A cache-reader substitutes tenant B id",
"absent": "tenant A cache-reader reads a reserved absent tenant id",
"finding": "tenant B role state is returned or existence differs from absent"
},
{
"id": "tenant-roles-live",
"operation": "read",
"route": "GET /tenants/{tenant_b_id}/roles/live",
"owner": "tenant B live-reader reads tenant B roles",
"attacker": "tenant A live-reader substitutes tenant B id",
"absent": "tenant A live-reader reads a reserved absent tenant id",
"finding": "tenant B role state is returned or existence differs from absent"
},
{
"id": "tenant-guardrails",
"operation": "read",
"route": "GET /tenants/{tenant_b_id}/guardrails",
"owner": "tenant B actor reads tenant B guardrails",
"attacker": "tenant A actor substitutes tenant B id",
"absent": "tenant A actor reads a reserved absent tenant id",
"finding": "tenant B limit state is returned or existence differs from absent"
},
{
"id": "tenant-role-grant",
"operation": "create",
"route": "POST /tenants/{tenant_b_id}/roles/grant",
"owner": "tenant B operator creates a disposable control grant",
"attacker": "tenant A operator substitutes tenant B id",
"absent": "tenant A operator targets a reserved absent tenant id",
"finding": "grant succeeds or tenant B role state changes"
},
{
"id": "tenant-plan-assign",
"operation": "update",
"route": "POST /tenants/{tenant_b_id}/plan",
"owner": "tenant B operator assigns a disposable control plan",
"attacker": "tenant A operator substitutes tenant B id",
"absent": "tenant A operator targets a reserved absent tenant id",
"finding": "assignment succeeds or tenant B plan state changes"
},
{
"id": "tenant-lifecycle-and-guardrail-writes",
"operation": "update",
"route": "PATCH/POST/PUT/DELETE /tenants/{tenant_b_id}/...",
"owner": "tenant B operator exercises each authorized fixture mutation",
"attacker": "tenant A operator substitutes tenant B id in update, retire, reactivate, grouping and guardrail routes",
"absent": "tenant A operator targets a reserved absent tenant id",
"finding": "any mutation succeeds or tenant B ETag/state changes"
}
]
}

19
pyproject.toml Normal file
View file

@ -0,0 +1,19 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "whitehat-security"
version = "0.1.0"
description = "Authorization-gated adversarial tenancy evidence harness"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Proprietary" }
[project.scripts]
whitehat = "whitehat_security.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

View file

@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "whitehat-engagement/v1",
"type": "object",
"additionalProperties": true,
"required": [
"engagement_id", "authorization_id", "authorizer", "approved_at", "expires_at",
"target", "target_owner", "environment", "source", "routes", "fixture_ids",
"credential_lane", "credential_role", "credential_max_ttl_seconds", "techniques",
"prohibited_techniques", "rate_limit_per_minute", "max_concurrency", "window_start",
"window_end", "operator_contact", "abort_contact", "posture_claim", "attacker_model",
"finding_destination", "target_owner_acknowledged_at"
],
"properties": {
"engagement_id": {"type": "string", "minLength": 1},
"authorization_id": {"type": "string", "minLength": 1},
"approved_at": {"type": "string", "format": "date-time"},
"expires_at": {"type": "string", "format": "date-time"},
"window_start": {"type": "string", "format": "date-time"},
"window_end": {"type": "string", "format": "date-time"},
"target_owner_acknowledged_at": {"type": "string", "format": "date-time"},
"environment": {"enum": ["fixture", "build", "staging", "production"]},
"routes": {"type": "array", "minItems": 1, "items": {"type": "string"}},
"fixture_ids": {"type": "array", "minItems": 1, "items": {"type": "string"}},
"techniques": {"type": "array", "minItems": 1, "items": {"type": "string"}},
"max_concurrency": {"const": 1},
"rate_limit_per_minute": {"type": "integer", "minimum": 1, "maximum": 60},
"finding_destination": {"const": "risk-nexus"}
}
}

View file

@ -0,0 +1,22 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "whitehat-run/v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version", "run_id", "evidence_class", "engagement_id", "authorization_id",
"target", "target_revision", "posture_claim", "attacker_model", "started_at", "ended_at",
"outcome", "attempted_operations", "cleanup", "credential_revocation", "probes",
"limitations", "assurance_statement"
],
"properties": {
"schema_version": {"const": "whitehat-run/v1"},
"evidence_class": {"enum": ["fixture", "target"]},
"outcome": {"enum": ["pass", "finding", "inconclusive", "aborted"]},
"attempted_operations": {"type": "integer", "minimum": 0},
"probes": {"type": "array"},
"limitations": {"type": "array", "items": {"type": "string"}},
"assurance_statement": {"type": "string", "minLength": 1}
}
}

View file

@ -0,0 +1,4 @@
"""Adversarial evidence tools for the NetKingdom tenancy posture."""
__version__ = "0.1.0"

View file

@ -0,0 +1,73 @@
from __future__ import annotations
from dataclasses import dataclass
from .model import Outcome
@dataclass(frozen=True)
class CapacitySample:
consumer: str
latency_ms: float
error_rate: float
throughput_per_second: float
@dataclass(frozen=True)
class CapacityResult:
outcome: Outcome
governor_bound: bool
aggressor_peak: float
aggressor_ceiling: float
neighbour_degradation: dict[str, dict[str, float]]
reasons: list[str]
def characterize(*, baseline: list[CapacitySample], loaded: list[CapacitySample],
governor_bound: bool, aggressor_peak: float,
aggressor_ceiling: float) -> CapacityResult:
before = {sample.consumer: sample for sample in baseline}
after = {sample.consumer: sample for sample in loaded}
reasons: list[str] = []
if not governor_bound:
reasons.append("declared governor did not bind")
if aggressor_peak > aggressor_ceiling:
reasons.append("probe exceeded its authorized allowance")
missing = sorted(before.keys() - after.keys())
if missing:
reasons.append("loaded samples missing consumers: " + ", ".join(missing))
degradation: dict[str, dict[str, float]] = {}
for consumer in sorted(before.keys() & after.keys()):
old, new = before[consumer], after[consumer]
degradation[consumer] = {
"latency_increase_percent": _change(old.latency_ms, new.latency_ms),
"error_rate_increase_points": new.error_rate - old.error_rate,
"throughput_decrease_percent": _decrease(old.throughput_per_second,
new.throughput_per_second),
}
outcome: Outcome = "pass"
if aggressor_peak > aggressor_ceiling:
outcome = "aborted"
elif reasons:
outcome = "finding"
return CapacityResult(
outcome=outcome,
governor_bound=governor_bound,
aggressor_peak=aggressor_peak,
aggressor_ceiling=aggressor_ceiling,
neighbour_degradation=degradation,
reasons=reasons,
)
def _change(before: float, after: float) -> float:
if before == 0:
return 0.0 if after == 0 else float("inf")
return round((after - before) / before * 100, 3)
def _decrease(before: float, after: float) -> float:
if before == 0:
return 0.0
return round((before - after) / before * 100, 3)

View file

@ -0,0 +1,123 @@
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import asdict
from pathlib import Path
from .capacity import CapacitySample, characterize
from .differential import execute
from .e3 import CADENCE, PROBES
from .engagement import Engagement
from .fixtures import FixtureService, probe_suite
from .model import RunReport, utc_now
from .reporting import risk_nexus_message
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))]
detected = all(result.outcome == "finding" for result in bad)
rejected = all(result.outcome == "pass" for result in good)
return {
"schema_version": "whitehat-calibration/v1",
"evidence_class": "fixture",
"run_id": f"fixture-calibration-{started}",
"started_at": started,
"ended_at": utc_now(),
"outcome": "pass" if detected and rejected else "finding",
"expected": {"known_good": "pass", "known_bad": "finding"},
"known_good": [asdict(result) for result in good],
"known_bad": [asdict(result) for result in bad],
"limitations": [
"Offline fixture evidence calibrates the harness; it is not target assurance.",
"No network request, database connection, or live credential was used.",
],
}
def capacity_fixture() -> dict:
baseline = [
CapacitySample("aggressor", 10, 0, 100),
CapacitySample("neighbour", 12, 0, 80),
]
loaded = [
CapacitySample("aggressor", 25, 0.01, 120),
CapacitySample("neighbour", 18, 0.02, 60),
]
return asdict(characterize(
baseline=baseline, loaded=loaded, governor_bound=True,
aggressor_peak=10, aggressor_ceiling=10,
))
def validate_pack(path: Path) -> None:
data = json.loads(path.read_text(encoding="utf-8"))
required = {"schema_version", "target", "posture_claim", "attacker_model", "probes"}
missing = sorted(required - data.keys())
if missing:
raise ValueError(f"{path}: missing {', '.join(missing)}")
ids: set[str] = set()
for probe in data["probes"]:
for key in ("id", "operation", "route", "owner", "attacker", "absent"):
if key not in probe:
raise ValueError(f"{path}: probe missing {key}")
if probe["id"] in ids:
raise ValueError(f"{path}: duplicate probe id {probe['id']}")
ids.add(probe["id"])
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(prog="whitehat")
commands = parser.add_subparsers(dest="command", required=True)
fixtures = commands.add_parser("fixtures", help="calibrate E2 probes offline")
fixtures.add_argument("--output")
engagement = commands.add_parser("validate-engagement")
engagement.add_argument("path")
packs = commands.add_parser("validate-packs")
packs.add_argument("path")
commands.add_parser("e3-plan")
commands.add_parser("capacity-fixture")
message = commands.add_parser("risk-message")
message.add_argument("report")
args = parser.parse_args(argv)
if args.command == "fixtures":
result = fixture_calibration()
rendered = json.dumps(result, indent=2, sort_keys=True) + "\n"
if args.output:
Path(args.output).write_text(rendered, encoding="utf-8")
else:
print(rendered, end="")
raise SystemExit(0 if result["outcome"] == "pass" else 1)
if args.command == "validate-engagement":
record = Engagement.load(args.path)
print(f"authorized: {record.raw['engagement_id']}")
return
if args.command == "validate-packs":
paths = sorted(Path(args.path).glob("*.json"))
if not paths:
raise SystemExit("no probe packs found")
for path in paths:
validate_pack(path)
print(f"validated {len(paths)} probe packs")
return
if args.command == "e3-plan":
print(json.dumps({"cadence": CADENCE, "probes": [asdict(probe) for probe in PROBES]},
indent=2, sort_keys=True))
return
if args.command == "capacity-fixture":
print(json.dumps(capacity_fixture(), indent=2, sort_keys=True))
return
if args.command == "risk-message":
report = RunReport(**json.loads(Path(args.report).read_text(encoding="utf-8")))
print(risk_nexus_message(report), end="")
return
raise SystemExit(2)
if __name__ == "__main__":
main(sys.argv[1:])

View file

@ -0,0 +1,67 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Callable
from .engagement import Engagement
from .model import Observation, ProbeResult, Response, observe
Request = Callable[[], Response]
State = Callable[[], object]
@dataclass(frozen=True)
class DifferentialProbe:
probe_id: str
operation: str
owner_request: Request
attacker_request: Request
absent_request: Request
fixture_markers: tuple[str, ...]
state: State | None = None
hide_existence: bool = True
def execute(probe: DifferentialProbe, *, salt: bytes | None = None) -> ProbeResult:
"""Run one cold probe, retaining response bodies only in this stack frame."""
run_salt = salt or os.urandom(32)
before = probe.state() if probe.state else None
owner = observe(probe.owner_request(), salt=run_salt, fixture_markers=probe.fixture_markers)
absent = observe(probe.absent_request(), salt=run_salt, fixture_markers=probe.fixture_markers)
attacker = observe(probe.attacker_request(), salt=run_salt, fixture_markers=probe.fixture_markers)
after = probe.state() if probe.state else None
reasons: list[str] = []
if attacker.fixture_match_count:
reasons.append("attacker response contains a tenant-B fixture marker")
if before != after:
reasons.append("tenant-B fixture state changed")
if probe.hide_existence and not _equivalent_denial(attacker, absent):
reasons.append("attacker response differs from the absent-object reference")
return ProbeResult(
probe_id=probe.probe_id,
operation=probe.operation,
outcome="finding" if reasons else "pass",
reasons=reasons,
observations={"owner": owner, "attacker": attacker, "absent": absent},
)
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 _equivalent_denial(left: Observation, right: Observation) -> bool:
return (
left.status == right.status
and left.content_type == right.content_type
and left.count == right.count
and left.schema == right.schema
and left.run_digest == right.run_digest
)

View file

@ -0,0 +1,61 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Literal
from .model import Outcome
Expectation = Literal["zero_rows", "statement_rejected", "false", "documented_limit"]
Query = Callable[[str], object]
@dataclass(frozen=True)
class E3Probe:
probe_id: str
sql_key: str
expectation: Expectation
@dataclass(frozen=True)
class E3Result:
probe_id: str
outcome: Outcome
reason: str
PROBES = (
E3Probe("conformance-view-empty", "conformance", "zero_rows"),
E3Probe("unset-guc-reads-none", "unset_guc", "zero_rows"),
E3Probe("tenant-a-cannot-read-b", "wrong_tenant_read", "zero_rows"),
E3Probe("tenant-a-cannot-insert-b", "wrong_tenant_insert", "statement_rejected"),
E3Probe("runtime-lacks-bypassrls", "runtime_bypassrls", "false"),
E3Probe("unsafe-definer-inventory-empty", "unsafe_definer", "zero_rows"),
E3Probe("sql-compromise-reset", "reset_to_b", "documented_limit"),
)
def evaluate(probe: E3Probe, *, rows: int = 0, rejected: bool = False,
boolean: bool | None = None) -> E3Result:
if probe.expectation == "documented_limit":
return E3Result(probe.probe_id, "inconclusive",
"E3-B observation records the documented SQL-compromise limit")
passed = {
"zero_rows": rows == 0,
"statement_rejected": rejected,
"false": boolean is False,
}[probe.expectation]
if passed:
return E3Result(probe.probe_id, "pass", f"expectation met: {probe.expectation}")
return E3Result(probe.probe_id, "finding", f"expectation failed: {probe.expectation}")
CADENCE = {
"interval": "24h",
"maximum_detection_window": "24h plus run and reporting latency",
"reset_triggers": [
"schema migration", "role or grant change", "RLS policy change",
"security-definer function change", "posture mechanism change",
],
"triggered_run_deadline": "before deployment promotion",
}

View file

@ -0,0 +1,83 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
class AuthorizationError(ValueError):
pass
REQUIRED = {
"engagement_id", "authorization_id", "authorizer", "approved_at", "expires_at",
"target", "target_owner", "environment", "source", "routes", "fixture_ids",
"credential_lane", "credential_role", "credential_max_ttl_seconds", "techniques",
"prohibited_techniques", "rate_limit_per_minute", "max_concurrency", "window_start",
"window_end", "operator_contact", "abort_contact", "posture_claim", "attacker_model",
"finding_destination", "target_owner_acknowledged_at",
}
@dataclass(frozen=True)
class Engagement:
raw: dict[str, Any]
@classmethod
def load(cls, path: str | Path, *, now: datetime | None = None) -> "Engagement":
data = json.loads(Path(path).read_text(encoding="utf-8"))
missing = sorted(REQUIRED - data.keys())
if missing:
raise AuthorizationError(f"incomplete engagement; missing: {', '.join(missing)}")
engagement = cls(data)
engagement.validate(now=now)
return engagement
def validate(self, *, now: datetime | None = None) -> None:
current = now or datetime.now(UTC)
start = _timestamp(self.raw["window_start"])
end = _timestamp(self.raw["window_end"])
expiry = _timestamp(self.raw["expires_at"])
approved = _timestamp(self.raw["approved_at"])
acknowledged = _timestamp(self.raw["target_owner_acknowledged_at"])
if not approved <= current <= min(end, expiry):
raise AuthorizationError("engagement is outside its approved time/expiry window")
if current < start:
raise AuthorizationError("engagement window has not started")
if acknowledged < approved:
raise AuthorizationError("target-owner acknowledgement predates approval")
if acknowledged > current:
raise AuthorizationError("target-owner acknowledgement is in the future")
if start < approved:
raise AuthorizationError("engagement window starts before approval")
if self.raw["max_concurrency"] != 1:
raise AuthorizationError("v0.1 permits exactly one in-flight API/database operation")
if not 1 <= self.raw["rate_limit_per_minute"] <= 60:
raise AuthorizationError("API rate ceiling must be between 1 and 60 per minute")
if self.raw["environment"] == "production" and not self.raw.get("production_approval"):
raise AuthorizationError("production requires production-specific approval")
if self.raw["finding_destination"] != "risk-nexus":
raise AuthorizationError("finding destination must be risk-nexus")
parsed = urlparse(self.raw["target"])
if parsed.scheme and parsed.scheme not in {"http", "https", "postgresql"}:
raise AuthorizationError("unsupported target scheme")
if not self.raw["fixture_ids"]:
raise AuthorizationError("at least one disposable fixture id is required")
if not 1 <= self.raw["credential_max_ttl_seconds"] <= 3600:
raise AuthorizationError("credential TTL must be between 1 and 3600 seconds")
def permits(self, *, technique: str, route: str) -> None:
if technique not in self.raw["techniques"]:
raise AuthorizationError(f"technique not authorized: {technique}")
if route not in self.raw["routes"]:
raise AuthorizationError(f"route not authorized: {route}")
def _timestamp(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise AuthorizationError("engagement timestamps must include a timezone")
return parsed.astimezone(UTC)

View file

@ -0,0 +1,91 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from .differential import DifferentialProbe
from .model import Response
@dataclass
class FixtureService:
enforce_tenant: bool
def __post_init__(self) -> None:
self.objects = {
"object-a": {"id": "object-a", "tenant": "tenant-a", "label": "fixture-a"},
"object-b": {"id": "object-b", "tenant": "tenant-b", "label": "fixture-b"},
}
def snapshot_b(self) -> object:
return deepcopy(self.objects.get("object-b"))
def read(self, actor_tenant: str, object_id: str) -> Response:
item = self.objects.get(object_id)
if item is None or (self.enforce_tenant and item["tenant"] != actor_tenant):
return Response(404, {"error": "not_found"})
return Response(200, deepcopy(item))
def list_for(self, actor_tenant: str, requested_tenant: str) -> Response:
tenant = actor_tenant if self.enforce_tenant else requested_tenant
items = [deepcopy(item) for item in self.objects.values() if item["tenant"] == tenant]
return Response(200, {"items": items})
def create_for(self, actor_tenant: str, requested_tenant: str, object_id: str) -> Response:
if self.enforce_tenant and actor_tenant != requested_tenant:
return Response(403, {"error": "forbidden"})
self.objects[object_id] = {
"id": object_id,
"tenant": requested_tenant,
"label": "fixture-created",
}
return Response(201, {"id": object_id, "tenant": requested_tenant})
def update(self, actor_tenant: str, object_id: str) -> Response:
item = self.objects.get(object_id)
if item is None or (self.enforce_tenant and item["tenant"] != actor_tenant):
return Response(404, {"error": "not_found"})
item["label"] = "fixture-mutated"
return Response(200, deepcopy(item))
def delete(self, actor_tenant: str, object_id: str) -> Response:
item = self.objects.get(object_id)
if item is None or (self.enforce_tenant and item["tenant"] != actor_tenant):
return Response(404, {"error": "not_found"})
del self.objects[object_id]
return Response(200, {"deleted": object_id})
def probe_suite(service: FixtureService) -> list[DifferentialProbe]:
markers = ("object-b", "tenant-b", "fixture-b", "fixture-mutated")
return [
DifferentialProbe(
"read-by-id", "read", lambda: service.read("tenant-b", "object-b"),
lambda: service.read("tenant-a", "object-b"),
lambda: service.read("tenant-a", "absent"), markers,
),
DifferentialProbe(
"list-filter", "read", lambda: service.list_for("tenant-b", "tenant-b"),
lambda: service.list_for("tenant-a", "tenant-b"),
lambda: Response(200, {"items": []}), markers, hide_existence=False,
),
DifferentialProbe(
"create-attributed-to-b", "create",
lambda: Response(201, {"id": "owner-control"}),
lambda: service.create_for("tenant-a", "tenant-b", "object-created"),
lambda: Response(403, {"error": "forbidden"}), markers + ("object-created",),
state=lambda: deepcopy(service.objects), hide_existence=False,
),
DifferentialProbe(
"update-b", "update", lambda: Response(200, service.snapshot_b()),
lambda: service.update("tenant-a", "object-b"),
lambda: service.read("tenant-a", "absent"), markers,
state=service.snapshot_b,
),
DifferentialProbe(
"delete-b", "delete", lambda: Response(200, service.snapshot_b()),
lambda: service.delete("tenant-a", "object-b"),
lambda: service.read("tenant-a", "absent"), markers,
state=service.snapshot_b,
),
]

View file

@ -0,0 +1,123 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from typing import Any, Literal
Outcome = Literal["pass", "finding", "inconclusive", "aborted"]
PASS_LIMIT = (
"Pass means only that the attacks attempted in this run did not work; "
"it is not proof that the tenant boundary always holds."
)
def utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def canonical(value: Any) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()
def shape(value: Any, prefix: str = "$") -> list[str]:
"""Return schema paths only; never return scalar values."""
if isinstance(value, dict):
paths = [prefix]
for key in sorted(value):
paths.extend(shape(value[key], f"{prefix}.{key}"))
return paths
if isinstance(value, list):
paths = [f"{prefix}[]"]
for item in value[:1]:
paths.extend(shape(item, f"{prefix}[]"))
return paths
return [f"{prefix}:{type(value).__name__}"]
def scalar_values(value: Any) -> list[str]:
if isinstance(value, dict):
return [item for child in value.values() for item in scalar_values(child)]
if isinstance(value, list):
return [item for child in value for item in scalar_values(child)]
if value is None:
return []
return [str(value)]
def item_count(value: Any) -> int:
if isinstance(value, list):
return len(value)
if isinstance(value, dict):
for key in ("items", "events", "results", "rows"):
if isinstance(value.get(key), list):
return len(value[key])
return 1 if value else 0
return 1 if value is not None else 0
@dataclass(frozen=True)
class Response:
status: int
body: Any
content_type: str = "application/json"
@dataclass(frozen=True)
class Observation:
status: int
content_type: str
count: int
schema: list[str]
run_digest: str
fixture_match_count: int
def observe(response: Response, *, salt: bytes, fixture_markers: tuple[str, ...]) -> Observation:
values = set(scalar_values(response.body))
return Observation(
status=response.status,
content_type=response.content_type.split(";", 1)[0].strip().lower(),
count=item_count(response.body),
schema=shape(response.body),
run_digest=hashlib.sha256(salt + canonical(response.body)).hexdigest(),
fixture_match_count=sum(
any(marker in value for value in values) for marker in fixture_markers
),
)
@dataclass
class ProbeResult:
probe_id: str
operation: str
outcome: Outcome
reasons: list[str]
observations: dict[str, Observation]
@dataclass
class RunReport:
schema_version: str
run_id: str
evidence_class: Literal["fixture", "target"]
engagement_id: str
authorization_id: str
target: str
target_revision: str
posture_claim: str
attacker_model: str
started_at: str
ended_at: str
outcome: Outcome
attempted_operations: int
cleanup: str
credential_revocation: str
probes: list[ProbeResult] = field(default_factory=list)
limitations: list[str] = field(default_factory=list)
assurance_statement: str = PASS_LIMIT
def as_dict(self) -> dict[str, Any]:
return asdict(self)

View file

@ -0,0 +1,31 @@
from __future__ import annotations
import json
from pathlib import Path
from .model import RunReport
def write_report(report: RunReport, output: str | Path) -> None:
path = Path(output)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(report.as_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8")
def risk_nexus_message(report: RunReport) -> str:
lines = [
f"Whitehat run `{report.run_id}`: **{report.outcome}**",
"",
f"- evidence class: `{report.evidence_class}`",
f"- target: `{report.target}` at `{report.target_revision}`",
f"- posture/model: `{report.posture_claim}` / `{report.attacker_model}`",
f"- engagement/authorization: `{report.engagement_id}` / `{report.authorization_id}`",
f"- attempted operations: {report.attempted_operations}",
f"- interval: {report.started_at} to {report.ended_at}",
"",
report.assurance_statement,
]
if report.outcome == "finding":
lines.extend(["", "Reporter supplies facts only; risk-nexus owns severity and disclosure."])
return "\n".join(lines) + "\n"

View file

@ -0,0 +1,37 @@
from whitehat_security.cli import fixture_calibration
from whitehat_security.differential import execute
from whitehat_security.fixtures import FixtureService, probe_suite
def test_every_probe_passes_known_good_fixture():
results = [execute(probe, salt=b"test") for probe in probe_suite(FixtureService(True))]
assert results
assert {result.outcome for result in results} == {"pass"}
def test_every_probe_detects_known_bad_fixture():
results = [execute(probe, salt=b"test") for probe in probe_suite(FixtureService(False))]
assert results
assert {result.outcome for result in results} == {"finding"}
def test_evidence_never_contains_fixture_body_values():
result = execute(probe_suite(FixtureService(False))[0], salt=b"test")
rendered = repr(result)
assert "fixture-b" not in rendered
assert "tenant-b" not in rendered
assert result.observations["attacker"].fixture_match_count > 0
def test_fixture_marker_is_found_when_embedded_in_a_value():
service = FixtureService(False)
service.objects["object-b"]["label"] = "prefix-fixture-b-suffix"
result = execute(probe_suite(service)[0], salt=b"test")
assert result.outcome == "finding"
def test_calibration_distinguishes_good_and_bad():
report = fixture_calibration()
assert report["outcome"] == "pass"
assert all(item["outcome"] == "pass" for item in report["known_good"])
assert all(item["outcome"] == "finding" for item in report["known_bad"])

View file

@ -0,0 +1,50 @@
from whitehat_security.capacity import CapacitySample, characterize
from whitehat_security.e3 import PROBES, evaluate
from whitehat_security.model import RunReport
from whitehat_security.reporting import risk_nexus_message
def test_e3_expected_failures_are_findings_and_limit_is_not():
conformance = next(probe for probe in PROBES if probe.probe_id == "conformance-view-empty")
boundary = next(probe for probe in PROBES if probe.probe_id == "sql-compromise-reset")
assert evaluate(conformance, rows=1).outcome == "finding"
assert evaluate(conformance, rows=0).outcome == "pass"
assert evaluate(boundary, rows=1).outcome == "inconclusive"
def test_capacity_records_neighbour_degradation():
result = characterize(
baseline=[CapacitySample("n", 10, 0, 100)],
loaded=[CapacitySample("n", 15, .01, 75)],
governor_bound=True, aggressor_peak=5, aggressor_ceiling=5,
)
assert result.outcome == "pass"
assert result.neighbour_degradation["n"] == {
"latency_increase_percent": 50.0,
"error_rate_increase_points": .01,
"throughput_decrease_percent": 25.0,
}
def test_capacity_unbound_governor_is_finding():
result = characterize(
baseline=[], loaded=[], governor_bound=False,
aggressor_peak=7, aggressor_ceiling=5,
)
assert result.outcome == "aborted"
assert len(result.reasons) == 2
def test_risk_message_contains_pass_and_no_severity():
report = RunReport(
schema_version="whitehat-run/v1", run_id="run-1", evidence_class="target",
engagement_id="eng-1", authorization_id="auth-1", target="service",
target_revision="abc", posture_claim="E2", attacker_model="E2",
started_at="2026-08-21T00:00:00Z", ended_at="2026-08-21T00:01:00Z",
outcome="pass", attempted_operations=1, cleanup="complete",
credential_revocation="complete",
)
message = risk_nexus_message(report)
assert "**pass**" in message
assert "Severity" not in message
assert "not proof" in message

63
tests/test_engagement.py Normal file
View file

@ -0,0 +1,63 @@
import json
from datetime import UTC, datetime
import pytest
from whitehat_security.engagement import AuthorizationError, Engagement
def record():
return {
"engagement_id": "WH-ENG-1", "authorization_id": "auth-1",
"authorizer": "operator", "approved_at": "2026-08-21T08:00:00Z",
"expires_at": "2026-08-21T12:00:00Z", "target": "https://fixture.invalid",
"target_owner": "target-repo", "environment": "build", "source": "runner",
"routes": ["GET /objects/{id}"], "fixture_ids": ["object-a", "object-b"],
"credential_lane": "openbao", "credential_role": "runtime",
"credential_max_ttl_seconds": 900, "techniques": ["e2-differential"],
"prohibited_techniques": ["saturation"], "rate_limit_per_minute": 10,
"max_concurrency": 1, "window_start": "2026-08-21T08:00:00Z",
"window_end": "2026-08-21T10:00:00Z", "operator_contact": "operator",
"abort_contact": "target-owner", "posture_claim": "E2",
"attacker_model": "E2-authenticated-tenant-a", "finding_destination": "risk-nexus",
"target_owner_acknowledged_at": "2026-08-21T08:01:00Z",
}
def load(tmp_path, data):
path = tmp_path / "engagement.json"
path.write_text(json.dumps(data), encoding="utf-8")
return Engagement.load(path, now=datetime(2026, 8, 21, 9, tzinfo=UTC))
def test_complete_current_record_is_accepted(tmp_path):
engagement = load(tmp_path, record())
engagement.permits(technique="e2-differential", route="GET /objects/{id}")
@pytest.mark.parametrize("field", ["target_owner_acknowledged_at", "abort_contact", "fixture_ids"])
def test_incomplete_record_fails_closed(tmp_path, field):
data = record()
del data[field]
with pytest.raises(AuthorizationError):
load(tmp_path, data)
def test_expired_record_fails_closed(tmp_path):
with pytest.raises(AuthorizationError, match="outside"):
Engagement.load(
_write(tmp_path, record()), now=datetime(2026, 8, 21, 13, tzinfo=UTC)
)
def test_unauthorized_route_fails_closed(tmp_path):
engagement = load(tmp_path, record())
with pytest.raises(AuthorizationError, match="route"):
engagement.permits(technique="e2-differential", route="DELETE /objects/{id}")
def _write(tmp_path, data):
path = tmp_path / "engagement.json"
path.write_text(json.dumps(data), encoding="utf-8")
return path

View file

@ -125,7 +125,7 @@ failure.
```task ```task
id: WHITEHAT-WP-0001-T03 id: WHITEHAT-WP-0001-T03
status: todo status: progress
priority: high priority: high
state_hub_task_id: "742561f1-97b6-4fcc-8e06-d18508ddecae" state_hub_task_id: "742561f1-97b6-4fcc-8e06-d18508ddecae"
``` ```
@ -144,11 +144,18 @@ The core technique: run the same request as two tenants and compare.
**Acceptance:** run against `tenant-engine` and `audit-core`, both of which **Acceptance:** run against `tenant-engine` and `audit-core`, both of which
currently claim `E2`. The artifact is the run record, not a green tick. currently claim `E2`. The artifact is the run record, not a green tick.
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.
### T04 — Prove the probes fail ### T04 — Prove the probes fail
```task ```task
id: WHITEHAT-WP-0001-T04 id: WHITEHAT-WP-0001-T04
status: wait status: progress
priority: high priority: high
state_hub_task_id: "a2afc0f5-3ece-410c-864c-829a8aac30fc" state_hub_task_id: "a2afc0f5-3ece-410c-864c-829a8aac30fc"
``` ```
@ -163,11 +170,18 @@ 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/
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.
### T05 — RLS conformance under attack (the E3 artifact) ### T05 — RLS conformance under attack (the E3 artifact)
```task ```task
id: WHITEHAT-WP-0001-T05 id: WHITEHAT-WP-0001-T05
status: wait status: progress
priority: medium priority: medium
state_hub_task_id: "6df676e7-bed5-4291-a128-eb6e9844edf2" state_hub_task_id: "6df676e7-bed5-4291-a128-eb6e9844edf2"
``` ```
@ -188,11 +202,18 @@ notices. This repo is that something.
control the interval between runs *is* the exposure window, and ADR-0003 leaves control the interval between runs *is* the exposure window, and ADR-0003 leaves
the number to this repo. Set it, and state the resulting window in the record. the number to this repo. Set it, and state the resulting window in the record.
Implementation progress, 2026-08-21: the cadence is fixed at 24 hours plus run
and reporting latency, with event-triggered pre-promotion runs after schema,
role, RLS or security-definer changes. `src/whitehat_security/e3.py` encodes the
seven expected outcomes and keeps the SQL-compromise GUC reset labeled as E3's
documented limit. A live database run still requires its separate authorized
target and window.
### T06 — Noisy-neighbour characterisation (the P1/P2 artifact) ### T06 — Noisy-neighbour characterisation (the P1/P2 artifact)
```task ```task
id: WHITEHAT-WP-0001-T06 id: WHITEHAT-WP-0001-T06
status: wait status: progress
priority: medium priority: medium
state_hub_task_id: "99254ab5-f08e-44c7-8399-be706dc03019" state_hub_task_id: "99254ab5-f08e-44c7-8399-be706dc03019"
``` ```
@ -210,11 +231,17 @@ acceptable.
Runs inside a declared window per T01 — on a single-node rail with a six- Runs inside a declared window per T01 — on a single-node rail with a six-
consumer connection ceiling, a saturation probe is an outage if run carelessly. consumer connection ceiling, a saturation probe is an outage if run carelessly.
Implementation progress, 2026-08-21: `src/whitehat_security/capacity.py`
records baseline/loaded latency, errors and throughput per consumer, governor
binding, aggressor peak/ceiling and neighbour degradation. No live load has
been generated; the required operator-approved substrate window and ceilings
do not yet exist.
### T07 — Reporting into risk-nexus ### T07 — Reporting into risk-nexus
```task ```task
id: WHITEHAT-WP-0001-T07 id: WHITEHAT-WP-0001-T07
status: wait status: progress
priority: medium priority: medium
state_hub_task_id: "54f33288-5361-4b20-8e1f-168866d64644" state_hub_task_id: "54f33288-5361-4b20-8e1f-168866d64644"
``` ```
@ -227,6 +254,12 @@ 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
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`.
## 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. T07 can