From beab2a04d1835b8ad9836a3db72a6743da6653db Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 21 Aug 2026 23:53:27 +0200 Subject: [PATCH] Build authorization-gated tenancy evidence harness Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0260c-4067-7052-9647-ad000d576e38 --- .gitignore | 4 + Makefile | 10 + README.md | 15 + SCOPE.md | 10 +- WORK-RECORDS.md | 10 +- docs/e3-cadence.md | 28 + docs/noisy-neighbour-runbook.md | 27 + docs/operator-runbook.md | 69 +++ docs/reporting.md | 23 + engagements/2026-08-21-e2-proposals.md | 60 +++ engagements/README.md | 11 + engagements/template.json | 31 ++ evidence/README.md | 11 + evidence/offline-calibration.json | 479 ++++++++++++++++++ probe-packs/audit-core-e2.json | 41 ++ probe-packs/tenant-engine-e2.json | 77 +++ pyproject.toml | 19 + schemas/engagement.schema.json | 31 ++ schemas/run-report.schema.json | 22 + src/whitehat_security/__init__.py | 4 + src/whitehat_security/capacity.py | 73 +++ src/whitehat_security/cli.py | 123 +++++ src/whitehat_security/differential.py | 67 +++ src/whitehat_security/e3.py | 61 +++ src/whitehat_security/engagement.py | 83 +++ src/whitehat_security/fixtures.py | 91 ++++ src/whitehat_security/model.py | 123 +++++ src/whitehat_security/reporting.py | 31 ++ tests/test_differential.py | 37 ++ tests/test_e3_capacity_reporting.py | 50 ++ tests/test_engagement.py | 63 +++ .../WHITEHAT-WP-0001-cross-tenant-evidence.md | 43 +- 32 files changed, 1816 insertions(+), 11 deletions(-) create mode 100644 Makefile create mode 100644 docs/e3-cadence.md create mode 100644 docs/noisy-neighbour-runbook.md create mode 100644 docs/operator-runbook.md create mode 100644 docs/reporting.md create mode 100644 engagements/2026-08-21-e2-proposals.md create mode 100644 engagements/README.md create mode 100644 engagements/template.json create mode 100644 evidence/README.md create mode 100644 evidence/offline-calibration.json create mode 100644 probe-packs/audit-core-e2.json create mode 100644 probe-packs/tenant-engine-e2.json create mode 100644 pyproject.toml create mode 100644 schemas/engagement.schema.json create mode 100644 schemas/run-report.schema.json create mode 100644 src/whitehat_security/__init__.py create mode 100644 src/whitehat_security/capacity.py create mode 100644 src/whitehat_security/cli.py create mode 100644 src/whitehat_security/differential.py create mode 100644 src/whitehat_security/e3.py create mode 100644 src/whitehat_security/engagement.py create mode 100644 src/whitehat_security/fixtures.py create mode 100644 src/whitehat_security/model.py create mode 100644 src/whitehat_security/reporting.py create mode 100644 tests/test_differential.py create mode 100644 tests/test_e3_capacity_reporting.py create mode 100644 tests/test_engagement.py diff --git a/.gitignore b/.gitignore index bf80165..6db8fce 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ .repo-manager/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..913ebc3 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 10512f9..d2c3f3e 100644 --- a/README.md +++ b/README.md @@ -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) - Rules of engagement (accepted; live targets still require engagement approval): [`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). diff --git a/SCOPE.md b/SCOPE.md index 7724778..152d44c 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -69,7 +69,15 @@ boundary always holds. - `T01` is complete: the rules of engagement were accepted on 2026-08-21. - `T02` is complete: the per-axis attacker model is recorded in `docs/attacker-model.md`. -- `T03` is 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 engagement record and approvals. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 7a823e2..b300557 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -11,8 +11,8 @@ | 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-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-T04 | wait | — | 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-T06 | wait | — | 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-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-T05 | progress | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | +| task | WHITEHAT-WP-0001-T06 | progress | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | +| task | WHITEHAT-WP-0001-T07 | progress | — | workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md | diff --git a/docs/e3-cadence.md b/docs/e3-cadence.md new file mode 100644 index 0000000..5420314 --- /dev/null +++ b/docs/e3-cadence.md @@ -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. + diff --git a/docs/noisy-neighbour-runbook.md b/docs/noisy-neighbour-runbook.md new file mode 100644 index 0000000..4d4da78 --- /dev/null +++ b/docs/noisy-neighbour-runbook.md @@ -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. + diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md new file mode 100644 index 0000000..9d7c4d6 --- /dev/null +++ b/docs/operator-runbook.md @@ -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/.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 `. +- 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. + diff --git a/docs/reporting.md b/docs/reporting.md new file mode 100644 index 0000000..55ec0c8 --- /dev/null +++ b/docs/reporting.md @@ -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/.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. + diff --git a/engagements/2026-08-21-e2-proposals.md b/engagements/2026-08-21-e2-proposals.md new file mode 100644 index 0000000..84bca41 --- /dev/null +++ b/engagements/2026-08-21-e2-proposals.md @@ -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. diff --git a/engagements/README.md b/engagements/README.md new file mode 100644 index 0000000..ed4ac7f --- /dev/null +++ b/engagements/README.md @@ -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. + diff --git a/engagements/template.json b/engagements/template.json new file mode 100644 index 0000000..ba20b8c --- /dev/null +++ b/engagements/template.json @@ -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" +} + diff --git a/evidence/README.md b/evidence/README.md new file mode 100644 index 0000000..eb38226 --- /dev/null +++ b/evidence/README.md @@ -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. + diff --git a/evidence/offline-calibration.json b/evidence/offline-calibration.json new file mode 100644 index 0000000..5aeb137 --- /dev/null +++ b/evidence/offline-calibration.json @@ -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" +} diff --git a/probe-packs/audit-core-e2.json b/probe-packs/audit-core-e2.json new file mode 100644 index 0000000..37d31f8 --- /dev/null +++ b/probe-packs/audit-core-e2.json @@ -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" + } + ] +} + diff --git a/probe-packs/tenant-engine-e2.json b/probe-packs/tenant-engine-e2.json new file mode 100644 index 0000000..74ccd28 --- /dev/null +++ b/probe-packs/tenant-engine-e2.json @@ -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" + } + ] +} + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..01f0770 --- /dev/null +++ b/pyproject.toml @@ -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"] + diff --git a/schemas/engagement.schema.json b/schemas/engagement.schema.json new file mode 100644 index 0000000..0a996fd --- /dev/null +++ b/schemas/engagement.schema.json @@ -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"} + } +} + diff --git a/schemas/run-report.schema.json b/schemas/run-report.schema.json new file mode 100644 index 0000000..3102a43 --- /dev/null +++ b/schemas/run-report.schema.json @@ -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} + } +} + diff --git a/src/whitehat_security/__init__.py b/src/whitehat_security/__init__.py new file mode 100644 index 0000000..beef565 --- /dev/null +++ b/src/whitehat_security/__init__.py @@ -0,0 +1,4 @@ +"""Adversarial evidence tools for the NetKingdom tenancy posture.""" + +__version__ = "0.1.0" + diff --git a/src/whitehat_security/capacity.py b/src/whitehat_security/capacity.py new file mode 100644 index 0000000..76580c5 --- /dev/null +++ b/src/whitehat_security/capacity.py @@ -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) diff --git a/src/whitehat_security/cli.py b/src/whitehat_security/cli.py new file mode 100644 index 0000000..ddf7a37 --- /dev/null +++ b/src/whitehat_security/cli.py @@ -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:]) + diff --git a/src/whitehat_security/differential.py b/src/whitehat_security/differential.py new file mode 100644 index 0000000..4587593 --- /dev/null +++ b/src/whitehat_security/differential.py @@ -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 + ) diff --git a/src/whitehat_security/e3.py b/src/whitehat_security/e3.py new file mode 100644 index 0000000..b906bad --- /dev/null +++ b/src/whitehat_security/e3.py @@ -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", +} + diff --git a/src/whitehat_security/engagement.py b/src/whitehat_security/engagement.py new file mode 100644 index 0000000..31667c4 --- /dev/null +++ b/src/whitehat_security/engagement.py @@ -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) diff --git a/src/whitehat_security/fixtures.py b/src/whitehat_security/fixtures.py new file mode 100644 index 0000000..fefd88e --- /dev/null +++ b/src/whitehat_security/fixtures.py @@ -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, + ), + ] diff --git a/src/whitehat_security/model.py b/src/whitehat_security/model.py new file mode 100644 index 0000000..9e6d403 --- /dev/null +++ b/src/whitehat_security/model.py @@ -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) diff --git a/src/whitehat_security/reporting.py b/src/whitehat_security/reporting.py new file mode 100644 index 0000000..b8bbf68 --- /dev/null +++ b/src/whitehat_security/reporting.py @@ -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" + diff --git a/tests/test_differential.py b/tests/test_differential.py new file mode 100644 index 0000000..2029d6f --- /dev/null +++ b/tests/test_differential.py @@ -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"]) diff --git a/tests/test_e3_capacity_reporting.py b/tests/test_e3_capacity_reporting.py new file mode 100644 index 0000000..6d52647 --- /dev/null +++ b/tests/test_e3_capacity_reporting.py @@ -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 diff --git a/tests/test_engagement.py b/tests/test_engagement.py new file mode 100644 index 0000000..16d67b5 --- /dev/null +++ b/tests/test_engagement.py @@ -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 + diff --git a/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md b/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md index 838b4c2..f9f68e7 100644 --- a/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md +++ b/workplans/WHITEHAT-WP-0001-cross-tenant-evidence.md @@ -125,7 +125,7 @@ failure. ```task id: WHITEHAT-WP-0001-T03 -status: todo +status: progress priority: high 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 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 ```task id: WHITEHAT-WP-0001-T04 -status: wait +status: progress priority: high 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 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) ```task id: WHITEHAT-WP-0001-T05 -status: wait +status: progress priority: medium 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 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) ```task id: WHITEHAT-WP-0001-T06 -status: wait +status: progress priority: medium 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- 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 ```task id: WHITEHAT-WP-0001-T07 -status: wait +status: progress priority: medium 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 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 T01 gates all. T02 shapes T03/T05/T06. T04 gates trusting any of them. T07 can