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:
parent
2c8e1d41ad
commit
beab2a04d1
32 changed files with 1816 additions and 11 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1 +1,5 @@
|
|||
.repo-manager/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
|
|
|
|||
10
Makefile
Normal file
10
Makefile
Normal 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
|
||||
15
README.md
15
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).
|
||||
|
|
|
|||
10
SCOPE.md
10
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
28
docs/e3-cadence.md
Normal file
28
docs/e3-cadence.md
Normal 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.
|
||||
|
||||
27
docs/noisy-neighbour-runbook.md
Normal file
27
docs/noisy-neighbour-runbook.md
Normal 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
69
docs/operator-runbook.md
Normal 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
23
docs/reporting.md
Normal 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.
|
||||
|
||||
60
engagements/2026-08-21-e2-proposals.md
Normal file
60
engagements/2026-08-21-e2-proposals.md
Normal 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
11
engagements/README.md
Normal 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
31
engagements/template.json
Normal 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
11
evidence/README.md
Normal 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.
|
||||
|
||||
479
evidence/offline-calibration.json
Normal file
479
evidence/offline-calibration.json
Normal 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"
|
||||
}
|
||||
41
probe-packs/audit-core-e2.json
Normal file
41
probe-packs/audit-core-e2.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
77
probe-packs/tenant-engine-e2.json
Normal file
77
probe-packs/tenant-engine-e2.json
Normal 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
19
pyproject.toml
Normal 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"]
|
||||
|
||||
31
schemas/engagement.schema.json
Normal file
31
schemas/engagement.schema.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
|
||||
22
schemas/run-report.schema.json
Normal file
22
schemas/run-report.schema.json
Normal 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}
|
||||
}
|
||||
}
|
||||
|
||||
4
src/whitehat_security/__init__.py
Normal file
4
src/whitehat_security/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Adversarial evidence tools for the NetKingdom tenancy posture."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
73
src/whitehat_security/capacity.py
Normal file
73
src/whitehat_security/capacity.py
Normal 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)
|
||||
123
src/whitehat_security/cli.py
Normal file
123
src/whitehat_security/cli.py
Normal 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:])
|
||||
|
||||
67
src/whitehat_security/differential.py
Normal file
67
src/whitehat_security/differential.py
Normal 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
|
||||
)
|
||||
61
src/whitehat_security/e3.py
Normal file
61
src/whitehat_security/e3.py
Normal 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",
|
||||
}
|
||||
|
||||
83
src/whitehat_security/engagement.py
Normal file
83
src/whitehat_security/engagement.py
Normal 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)
|
||||
91
src/whitehat_security/fixtures.py
Normal file
91
src/whitehat_security/fixtures.py
Normal 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,
|
||||
),
|
||||
]
|
||||
123
src/whitehat_security/model.py
Normal file
123
src/whitehat_security/model.py
Normal 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)
|
||||
31
src/whitehat_security/reporting.py
Normal file
31
src/whitehat_security/reporting.py
Normal 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"
|
||||
|
||||
37
tests/test_differential.py
Normal file
37
tests/test_differential.py
Normal 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"])
|
||||
50
tests/test_e3_capacity_reporting.py
Normal file
50
tests/test_e3_capacity_reporting.py
Normal 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
63
tests/test_engagement.py
Normal 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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue