Admit E3 and capacity on the test plane
Register in-process E3 and capacity fixtures, keep live database and substrate targets pending, and ask ops-mason for namespace-only provision. No packets, no credentials, no cancelled engagement IDs. Assistant: grok Assistant-Session: 01a02670-3345-76f2-a014-70fde8e2a2bb
This commit is contained in:
parent
4882c2d47a
commit
7e83a66573
22 changed files with 501 additions and 74 deletions
1
Makefile
1
Makefile
|
|
@ -10,3 +10,4 @@ test:
|
|||
|
||||
fixture-evidence:
|
||||
PYTHONPATH=src python3 -m whitehat_security.cli fixtures --output evidence/offline-calibration.json
|
||||
PYTHONPATH=src python3 -m whitehat_security.cli e3-fixtures --output evidence/offline-e3-calibration.json
|
||||
|
|
|
|||
7
SCOPE.md
7
SCOPE.md
|
|
@ -74,9 +74,10 @@ boundary always holds.
|
|||
E2. Live audit-core still needs a new engagement and a provisioned plane.
|
||||
- `T04` is complete for every applicable E2 probe: generic and audit-core
|
||||
shaped fixtures fail known-bad and pass known-good in-process.
|
||||
- `T05` is in progress with a 24-hour E3 cadence and offline evaluator.
|
||||
- `T06` is in progress with a bounded characterization evaluator; no shared
|
||||
substrate window is approved.
|
||||
- `T05` is in progress: 24-hour cadence, offline evaluator and in-process
|
||||
calibration exist; `platform-pg` is pending a live window.
|
||||
- `T06` is in progress: the characterization evaluator is calibrated
|
||||
in-process; `shared-substrate` is pending a live window and ceiling.
|
||||
- `T07` is in progress: reports can be queued to `outbox/`; the first target
|
||||
report has not been produced.
|
||||
- `T08` is the governed test plane contract. Cluster provisioning is outside
|
||||
|
|
|
|||
|
|
@ -14,8 +14,13 @@ The executable plan is available with:
|
|||
|
||||
```sh
|
||||
PYTHONPATH=src python3 -m whitehat_security.cli e3-plan
|
||||
PYTHONPATH=src python3 -m whitehat_security.cli e3-fixtures
|
||||
```
|
||||
|
||||
`e3-fixtures` calibrates the seven expected outcomes in-process, including
|
||||
keeping the SQL-compromise reset as `inconclusive`. It opens no database
|
||||
connection and is not target assurance.
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ 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.
|
||||
`make fixture-evidence` also writes `evidence/offline-e3-calibration.json`.
|
||||
Both files are calibration evidence, not target assurance.
|
||||
|
||||
## Test plane
|
||||
|
||||
|
|
|
|||
|
|
@ -144,11 +144,13 @@ Live runs are admitted only through the test plane documented in
|
|||
projected short-lived identities, registered target, kill switch, rate watcher
|
||||
and lease cleanup. The plane is a control, not authorization.
|
||||
|
||||
`fixture-e2` is the in-process class already permitted by §1. `live-e2` still
|
||||
requires the dated engagement record, target-owner acknowledgement and
|
||||
technique-specific operator approval required by §1, plus plane admission.
|
||||
`e3`, recovery, saturation and destructive techniques keep separate explicit
|
||||
approval classes and are not admitted by the E2 plane.
|
||||
`fixture-e2`, `fixture-e3` and `fixture-capacity` are in-process classes
|
||||
already permitted by §1. `live-e2` still requires the dated engagement
|
||||
record, target-owner acknowledgement and technique-specific operator approval
|
||||
required by §1, plus plane admission. Live `e3` additionally names the
|
||||
database and uses one ordinary runtime role with no `BYPASSRLS`. Live
|
||||
`capacity` additionally names an aggressor ceiling and a substrate window.
|
||||
Recovery and destructive techniques remain outside this plane.
|
||||
|
||||
Cancelled engagement identifiers are terminal and must not be reused.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,11 +11,15 @@ A live E2 run is admitted only when every item below is true:
|
|||
2. Known-bad calibration for that target has already failed in-process.
|
||||
3. The engagement is complete, in window, owner-acknowledged, and not a
|
||||
retired ID.
|
||||
4. `approval_class` is `live-e2` (or `fixture-e2` for in-process only).
|
||||
4. `approval_class` matches the registration (`fixture-e2` / `live-e2` /
|
||||
`fixture-e3` / `e3` / `fixture-capacity` / `capacity`).
|
||||
5. The runner namespace is `whitehat`, the image digest is pinned, and
|
||||
engagement labels are present.
|
||||
6. The custody broker projected exactly two identity *handles* (owner and
|
||||
attacker). Credential values are not returned to the operator or agent.
|
||||
engagement labels are present. Live E3 also names a `database`. Live
|
||||
capacity also names an `aggressor_ceiling`.
|
||||
6. The custody broker projected the registered number of identity *handles*
|
||||
(two for E2, one runtime for E3, none for capacity fixtures). Credential
|
||||
values are not returned to the operator or agent. Live classes fail closed
|
||||
while the custody broker is unconnected.
|
||||
7. The kill switch is clear.
|
||||
8. The rate watcher is attached. Cleanup revokes the lease.
|
||||
|
||||
|
|
|
|||
31
engagements/2026-08-22-plane-provision.md
Normal file
31
engagements/2026-08-22-plane-provision.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Plane provision request — not an engagement
|
||||
|
||||
Status: **coordination only; apply nothing from this repository**
|
||||
|
||||
`WHITEHAT-WP-0001-T08` encoded the governed test plane as a contract. Live E2
|
||||
still needs the cluster objects. This note asks `ops-mason` to provision the
|
||||
**namespace, default-deny network policy, and runner service account** from
|
||||
`plane/`. It does **not** authorize:
|
||||
|
||||
- applying the runner pod
|
||||
- projecting credentials
|
||||
- sending packets
|
||||
- reusing `WH-ENG-20260821-AUDIT-E2` or `WH-ENG-20260821-TENANT-E2`
|
||||
|
||||
## Requested objects
|
||||
|
||||
| Object | File | Notes |
|
||||
| --- | --- | --- |
|
||||
| Namespace `whitehat` | `plane/namespace.yaml` | restricted PSS |
|
||||
| Default-deny NetworkPolicy | `plane/network-policy.yaml` | audit-core egress is documented, not a standing allow for other targets |
|
||||
| ServiceAccount `whitehat-runner` | `plane/service-account.yaml` | `automountServiceAccountToken: false` |
|
||||
|
||||
Do not create a credential secret. The live custody broker is still
|
||||
unconnected; whitehat will fail closed until a later engagement ID exists.
|
||||
|
||||
## Next engagement (not this request)
|
||||
|
||||
After the namespace exists, a **new** audit-core E2 ID can be drafted. It will
|
||||
need two ordinary tenant-scoped `may_read`/`may_write` fixture senders, TTL
|
||||
≤ 900s, projected into the runner mount without exposing values to the agent.
|
||||
That is a separate request and uses a new ID.
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
# 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.
|
||||
This directory stores sanitized run artifacts. `offline-calibration.json` and
|
||||
`offline-e3-calibration.json` are generated from repository-created fixtures
|
||||
and prove only that the harness distinguishes known-good from known-bad
|
||||
behavior. They are not target assurance.
|
||||
|
||||
Before committing target evidence, verify that it contains no response body,
|
||||
credential, database URL, real tenant identifier, or real tenant value. A
|
||||
|
|
|
|||
99
evidence/offline-e3-calibration.json
Normal file
99
evidence/offline-e3-calibration.json
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
{
|
||||
"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"
|
||||
},
|
||||
"ended_at": "2026-08-22T07:39:54.791280Z",
|
||||
"evidence_class": "fixture",
|
||||
"known_bad": [
|
||||
{
|
||||
"outcome": "finding",
|
||||
"probe_id": "conformance-view-empty",
|
||||
"reason": "expectation failed: zero_rows"
|
||||
},
|
||||
{
|
||||
"outcome": "finding",
|
||||
"probe_id": "unset-guc-reads-none",
|
||||
"reason": "expectation failed: zero_rows"
|
||||
},
|
||||
{
|
||||
"outcome": "finding",
|
||||
"probe_id": "tenant-a-cannot-read-b",
|
||||
"reason": "expectation failed: zero_rows"
|
||||
},
|
||||
{
|
||||
"outcome": "finding",
|
||||
"probe_id": "tenant-a-cannot-insert-b",
|
||||
"reason": "expectation failed: statement_rejected"
|
||||
},
|
||||
{
|
||||
"outcome": "finding",
|
||||
"probe_id": "runtime-lacks-bypassrls",
|
||||
"reason": "expectation failed: false"
|
||||
},
|
||||
{
|
||||
"outcome": "finding",
|
||||
"probe_id": "unsafe-definer-inventory-empty",
|
||||
"reason": "expectation failed: zero_rows"
|
||||
},
|
||||
{
|
||||
"outcome": "inconclusive",
|
||||
"probe_id": "sql-compromise-reset",
|
||||
"reason": "E3-B observation records the documented SQL-compromise limit"
|
||||
}
|
||||
],
|
||||
"known_good": [
|
||||
{
|
||||
"outcome": "pass",
|
||||
"probe_id": "conformance-view-empty",
|
||||
"reason": "expectation met: zero_rows"
|
||||
},
|
||||
{
|
||||
"outcome": "pass",
|
||||
"probe_id": "unset-guc-reads-none",
|
||||
"reason": "expectation met: zero_rows"
|
||||
},
|
||||
{
|
||||
"outcome": "pass",
|
||||
"probe_id": "tenant-a-cannot-read-b",
|
||||
"reason": "expectation met: zero_rows"
|
||||
},
|
||||
{
|
||||
"outcome": "pass",
|
||||
"probe_id": "tenant-a-cannot-insert-b",
|
||||
"reason": "expectation met: statement_rejected"
|
||||
},
|
||||
{
|
||||
"outcome": "pass",
|
||||
"probe_id": "runtime-lacks-bypassrls",
|
||||
"reason": "expectation met: false"
|
||||
},
|
||||
{
|
||||
"outcome": "pass",
|
||||
"probe_id": "unsafe-definer-inventory-empty",
|
||||
"reason": "expectation met: zero_rows"
|
||||
},
|
||||
{
|
||||
"outcome": "inconclusive",
|
||||
"probe_id": "sql-compromise-reset",
|
||||
"reason": "E3-B observation records the documented SQL-compromise limit"
|
||||
}
|
||||
],
|
||||
"limitations": [
|
||||
"Offline E3 calibration evaluates the harness; it is not target assurance.",
|
||||
"No database connection or live credential was used.",
|
||||
"sql-compromise-reset is E3's documented limit and stays inconclusive."
|
||||
],
|
||||
"outcome": "pass",
|
||||
"run_id": "e3-calibration-2026-08-22T07:39:54.791131Z",
|
||||
"schema_version": "whitehat-e3-calibration/v1",
|
||||
"started_at": "2026-08-22T07:39:54.791131Z"
|
||||
}
|
||||
|
|
@ -14,13 +14,13 @@
|
|||
"approval_classes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"enum": ["fixture-e2", "live-e2", "e3", "capacity"]}
|
||||
"items": {"enum": ["fixture-e2", "live-e2", "fixture-e3", "e3", "fixture-capacity", "capacity"]}
|
||||
},
|
||||
"routes": {"type": "array", "items": {"type": "string"}},
|
||||
"identities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {"const": 2},
|
||||
"count": {"type": "integer", "minimum": 0, "maximum": 2},
|
||||
"ttl_seconds": {"type": "integer", "minimum": 1, "maximum": 900}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from pathlib import Path
|
|||
from .audit_fixtures import AuditFixture, audit_probe_suite
|
||||
from .capacity import CapacitySample, characterize
|
||||
from .differential import execute
|
||||
from .e3 import CADENCE, PROBES
|
||||
from .e3 import CADENCE, PROBES, e3_calibration
|
||||
from .engagement import AuthorizationError, Engagement
|
||||
from .fixtures import FixtureService, probe_suite
|
||||
from .model import RunReport, utc_now
|
||||
|
|
@ -95,6 +95,8 @@ def main(argv: list[str] | None = None) -> None:
|
|||
deliver.add_argument("report")
|
||||
deliver.add_argument("--outbox", default="outbox")
|
||||
commands.add_parser("e3-plan")
|
||||
e3_fix = commands.add_parser("e3-fixtures", help="calibrate E3 probes offline")
|
||||
e3_fix.add_argument("--output")
|
||||
commands.add_parser("capacity-fixture")
|
||||
message = commands.add_parser("risk-message")
|
||||
message.add_argument("report")
|
||||
|
|
@ -164,6 +166,14 @@ def main(argv: list[str] | None = None) -> None:
|
|||
print(json.dumps({"cadence": CADENCE, "probes": [asdict(probe) for probe in PROBES]},
|
||||
indent=2, sort_keys=True))
|
||||
return
|
||||
if args.command == "e3-fixtures":
|
||||
result = e3_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 == "capacity-fixture":
|
||||
print(json.dumps(capacity_fixture(), indent=2, sort_keys=True))
|
||||
return
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
from dataclasses import dataclass
|
||||
from typing import Callable, Literal
|
||||
|
||||
from .model import Outcome
|
||||
from .model import Outcome, utc_now
|
||||
|
||||
Expectation = Literal["zero_rows", "statement_rejected", "false", "documented_limit"]
|
||||
Query = Callable[[str], object]
|
||||
|
|
@ -49,6 +49,52 @@ def evaluate(probe: E3Probe, *, rows: int = 0, rejected: bool = False,
|
|||
return E3Result(probe.probe_id, "finding", f"expectation failed: {probe.expectation}")
|
||||
|
||||
|
||||
def fixture_results(*, enforce: bool) -> list[E3Result]:
|
||||
"""In-process known-good/known-bad outcomes. No database connection."""
|
||||
results: list[E3Result] = []
|
||||
for probe in PROBES:
|
||||
if probe.expectation == "documented_limit":
|
||||
results.append(evaluate(probe))
|
||||
continue
|
||||
if enforce:
|
||||
results.append(evaluate(probe, rows=0, rejected=True, boolean=False))
|
||||
else:
|
||||
results.append(evaluate(probe, rows=1, rejected=False, boolean=True))
|
||||
return results
|
||||
|
||||
|
||||
def e3_calibration() -> dict:
|
||||
started = utc_now()
|
||||
good = fixture_results(enforce=True)
|
||||
bad = fixture_results(enforce=False)
|
||||
|
||||
def expected(result: E3Result, *, enforce: bool) -> bool:
|
||||
probe = next(item for item in PROBES if item.probe_id == result.probe_id)
|
||||
if probe.expectation == "documented_limit":
|
||||
return result.outcome == "inconclusive"
|
||||
return result.outcome == ("pass" if enforce else "finding")
|
||||
|
||||
ok = all(expected(item, enforce=True) for item in good) and all(
|
||||
expected(item, enforce=False) for item in bad
|
||||
)
|
||||
return {
|
||||
"schema_version": "whitehat-e3-calibration/v1",
|
||||
"evidence_class": "fixture",
|
||||
"run_id": f"e3-calibration-{started}",
|
||||
"started_at": started,
|
||||
"ended_at": utc_now(),
|
||||
"outcome": "pass" if ok else "finding",
|
||||
"cadence": CADENCE,
|
||||
"known_good": [result.__dict__ for result in good],
|
||||
"known_bad": [result.__dict__ for result in bad],
|
||||
"limitations": [
|
||||
"Offline E3 calibration evaluates the harness; it is not target assurance.",
|
||||
"No database connection or live credential was used.",
|
||||
"sql-compromise-reset is E3's documented limit and stays inconclusive.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
CADENCE = {
|
||||
"interval": "24h",
|
||||
"maximum_detection_window": "24h plus run and reporting latency",
|
||||
|
|
|
|||
|
|
@ -19,9 +19,12 @@ TERMINAL_IDS = frozenset({
|
|||
APPROVAL_CLASSES = {
|
||||
"fixture-e2": frozenset({"e2-differential"}),
|
||||
"live-e2": frozenset({"e2-differential"}),
|
||||
"fixture-e3": frozenset({"e3-rls"}),
|
||||
"e3": frozenset({"e3-rls"}),
|
||||
"fixture-capacity": frozenset({"p1-noisy-neighbour", "p2-noisy-neighbour"}),
|
||||
"capacity": frozenset({"p1-noisy-neighbour", "p2-noisy-neighbour"}),
|
||||
}
|
||||
FIXTURE_CLASSES = frozenset({"fixture-e2", "fixture-e3", "fixture-capacity"})
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
|
|
@ -102,7 +105,7 @@ class IdentityHandle:
|
|||
|
||||
class CredentialBroker(Protocol):
|
||||
def project(self, engagement: Engagement, registration: dict[str, Any]
|
||||
) -> tuple[IdentityHandle, IdentityHandle]:
|
||||
) -> tuple[IdentityHandle, ...]:
|
||||
...
|
||||
|
||||
def revoke(self, lease_id: str) -> None:
|
||||
|
|
@ -113,7 +116,7 @@ class UnconnectedCustodyBroker:
|
|||
"""Fail-closed live broker. It never requests or returns credential values."""
|
||||
|
||||
def project(self, engagement: Engagement, registration: dict[str, Any]
|
||||
) -> tuple[IdentityHandle, IdentityHandle]:
|
||||
) -> tuple[IdentityHandle, ...]:
|
||||
raise AuthorizationError(
|
||||
"live custody broker is not connected; no credential was requested"
|
||||
)
|
||||
|
|
@ -132,20 +135,25 @@ class LocalBroker:
|
|||
self._leases: dict[str, tuple[str, str]] = {}
|
||||
|
||||
def project(self, engagement: Engagement, registration: dict[str, Any]
|
||||
) -> tuple[IdentityHandle, IdentityHandle]:
|
||||
) -> tuple[IdentityHandle, ...]:
|
||||
if registration.get("applicability") != "applicable":
|
||||
raise AuthorizationError("broker will not project identities for a non-applicable target")
|
||||
count = int(registration["identities"]["count"])
|
||||
lease_id = os.urandom(8).hex()
|
||||
ttl = min(int(engagement.raw["credential_max_ttl_seconds"]), STANDING_E2_TTL)
|
||||
expires_at = (datetime.now(UTC) + timedelta(seconds=ttl)).isoformat().replace("+00:00", "Z")
|
||||
owner = IdentityHandle("owner", f"/var/run/secrets/whitehat/{lease_id}/token-b",
|
||||
lease_id, expires_at)
|
||||
attacker = IdentityHandle("attacker", f"/var/run/secrets/whitehat/{lease_id}/token-a",
|
||||
lease_id, expires_at)
|
||||
self._secrets[owner.mount_path] = os.urandom(32)
|
||||
self._secrets[attacker.mount_path] = os.urandom(32)
|
||||
self._leases[lease_id] = (owner.mount_path, attacker.mount_path)
|
||||
return owner, attacker
|
||||
handles: list[IdentityHandle] = []
|
||||
if count >= 1:
|
||||
role = "owner" if count == 2 else "runtime"
|
||||
path = f"/var/run/secrets/whitehat/{lease_id}/{'token-b' if count == 2 else 'runtime'}"
|
||||
handles.append(IdentityHandle(role, path, lease_id, expires_at))
|
||||
self._secrets[path] = os.urandom(32)
|
||||
if count == 2:
|
||||
path = f"/var/run/secrets/whitehat/{lease_id}/token-a"
|
||||
handles.append(IdentityHandle("attacker", path, lease_id, expires_at))
|
||||
self._secrets[path] = os.urandom(32)
|
||||
self._leases[lease_id] = tuple(handle.mount_path for handle in handles)
|
||||
return tuple(handles)
|
||||
|
||||
def revoke(self, lease_id: str) -> None:
|
||||
paths = self._leases.pop(lease_id, None)
|
||||
|
|
@ -159,7 +167,7 @@ class LocalBroker:
|
|||
class PlaneLease:
|
||||
engagement: Engagement
|
||||
registration: dict[str, Any]
|
||||
identities: tuple[IdentityHandle, IdentityHandle]
|
||||
identities: tuple[IdentityHandle, ...]
|
||||
watcher: RateWatcher
|
||||
lease_id: str
|
||||
broker_name: str
|
||||
|
|
@ -172,7 +180,7 @@ class PlaneLease:
|
|||
|
||||
def default_broker(engagement: Engagement) -> CredentialBroker:
|
||||
if (engagement.raw.get("environment") == "fixture"
|
||||
and engagement.raw.get("approval_class") == "fixture-e2"):
|
||||
and engagement.raw.get("approval_class") in FIXTURE_CLASSES):
|
||||
return LocalBroker()
|
||||
return UnconnectedCustodyBroker()
|
||||
|
||||
|
|
@ -212,32 +220,26 @@ def admit(*, engagement: Engagement, registration: dict[str, Any],
|
|||
for route in engagement.raw["routes"]:
|
||||
if route not in registration["routes"]:
|
||||
raise AuthorizationError(f"route not registered: {route}")
|
||||
if approval_class == "live-e2":
|
||||
if engagement.raw.get("plane_namespace") != PLANE_NAMESPACE:
|
||||
raise AuthorizationError("live E2 requires the whitehat plane namespace")
|
||||
if not engagement.raw.get("runner_image_digest"):
|
||||
raise AuthorizationError("live E2 requires a pinned runner image digest")
|
||||
if engagement.raw["credential_max_ttl_seconds"] > STANDING_E2_TTL:
|
||||
raise AuthorizationError("E2 credential TTL must be at most 900 seconds")
|
||||
if engagement.raw["environment"] == "fixture":
|
||||
raise AuthorizationError("live E2 cannot use the fixture environment")
|
||||
elif approval_class == "fixture-e2":
|
||||
if engagement.raw["environment"] != "fixture":
|
||||
raise AuthorizationError("fixture-e2 requires environment=fixture")
|
||||
elif approval_class in {"e3", "capacity"}:
|
||||
raise AuthorizationError(
|
||||
f"{approval_class} requires a separate explicit operator window; "
|
||||
"the E2 plane will not admit it"
|
||||
)
|
||||
_enforce_class_envelope(approval_class, engagement)
|
||||
selected = broker or default_broker(engagement)
|
||||
identities = selected.project(engagement, registration)
|
||||
if len(identities) != 2:
|
||||
raise AuthorizationError("broker must project exactly two identities")
|
||||
roles = {handle.role for handle in identities}
|
||||
if roles != {"owner", "attacker"}:
|
||||
raise AuthorizationError("broker must project owner and attacker identities")
|
||||
if any(hasattr(handle, "secret") and getattr(handle, "secret") for handle in identities):
|
||||
raise AuthorizationError("broker exposed a credential value")
|
||||
count = int(registration["identities"]["count"])
|
||||
if count:
|
||||
identities = selected.project(engagement, registration)
|
||||
if len(identities) != count:
|
||||
raise AuthorizationError(f"broker must project exactly {count} identities")
|
||||
expected = {2: {"owner", "attacker"}, 1: {"runtime"}}[count]
|
||||
if {handle.role for handle in identities} != expected:
|
||||
raise AuthorizationError(f"broker must project {sorted(expected)} identities")
|
||||
if any(hasattr(handle, "secret") and getattr(handle, "secret") for handle in identities):
|
||||
raise AuthorizationError("broker exposed a credential value")
|
||||
lease_id = identities[0].lease_id
|
||||
else:
|
||||
if approval_class not in FIXTURE_CLASSES:
|
||||
selected.project(engagement, registration)
|
||||
identities = ()
|
||||
lease_id = os.urandom(8).hex()
|
||||
if hasattr(selected, "_leases"):
|
||||
selected._leases[lease_id] = ()
|
||||
watcher = RateWatcher(
|
||||
per_minute=engagement.raw["rate_limit_per_minute"],
|
||||
max_concurrency=engagement.raw["max_concurrency"],
|
||||
|
|
@ -249,11 +251,31 @@ def admit(*, engagement: Engagement, registration: dict[str, Any],
|
|||
registration=registration,
|
||||
identities=identities,
|
||||
watcher=watcher,
|
||||
lease_id=identities[0].lease_id,
|
||||
lease_id=lease_id,
|
||||
broker_name=type(selected).__name__,
|
||||
)
|
||||
|
||||
|
||||
def _enforce_class_envelope(approval_class: str, engagement: Engagement) -> None:
|
||||
environment = engagement.raw["environment"]
|
||||
if approval_class in FIXTURE_CLASSES:
|
||||
if environment != "fixture":
|
||||
raise AuthorizationError(f"{approval_class} requires environment=fixture")
|
||||
return
|
||||
if environment == "fixture":
|
||||
raise AuthorizationError(f"{approval_class} cannot use the fixture environment")
|
||||
if engagement.raw.get("plane_namespace") != PLANE_NAMESPACE:
|
||||
raise AuthorizationError(f"{approval_class} requires the whitehat plane namespace")
|
||||
if not engagement.raw.get("runner_image_digest"):
|
||||
raise AuthorizationError(f"{approval_class} requires a pinned runner image digest")
|
||||
if engagement.raw["credential_max_ttl_seconds"] > STANDING_E2_TTL:
|
||||
raise AuthorizationError("credential TTL must be at most 900 seconds")
|
||||
if approval_class == "e3" and not engagement.raw.get("database"):
|
||||
raise AuthorizationError("live E3 requires a named database")
|
||||
if approval_class == "capacity" and engagement.raw.get("aggressor_ceiling") is None:
|
||||
raise AuthorizationError("live capacity requires an aggressor_ceiling")
|
||||
|
||||
|
||||
def cleanup(lease: PlaneLease, broker: CredentialBroker) -> dict[str, str]:
|
||||
broker.revoke(lease.lease_id)
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -37,9 +37,15 @@ def load_registration(path: str | Path) -> dict[str, Any]:
|
|||
if not data["routes"]:
|
||||
raise AuthorizationError(f"{path}: applicable target must register routes")
|
||||
identities = data["identities"]
|
||||
if identities.get("count") != 2:
|
||||
count = identities.get("count")
|
||||
classes = set(data["approval_classes"])
|
||||
if classes & {"fixture-e2", "live-e2"} and count != 2:
|
||||
raise AuthorizationError(f"{path}: E2 registration must project two identities")
|
||||
if identities.get("ttl_seconds", 0) > 900:
|
||||
if classes & {"fixture-e3", "e3"} and count != 1:
|
||||
raise AuthorizationError(f"{path}: E3 registration must project one runtime identity")
|
||||
if classes & {"fixture-capacity", "capacity"} and count not in {0, 1}:
|
||||
raise AuthorizationError(f"{path}: capacity registration projects at most one aggressor identity")
|
||||
if count and identities.get("ttl_seconds", 0) > 900:
|
||||
raise AuthorizationError(f"{path}: identity TTL must be at most 900 seconds")
|
||||
if data.get("known_bad_calibration") in {None, "", "pending"}:
|
||||
raise AuthorizationError(f"{path}: applicable target needs completed known-bad calibration")
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ honest applicability record the test plane admits against.
|
|||
| `audit-core` | applicable | Two ordinary tenant-scoped senders instantiate E2. Live run still needs a new engagement and plane lease. |
|
||||
| `tenant-engine` | not_applicable | No tenant-A identity without tenant-B authority. |
|
||||
| `flex-auth` | pending | Initial envelope; owner has not confirmed the attacker identity. |
|
||||
| `fixture-e3` | applicable | In-process E3 evaluator. One runtime identity, no `BYPASSRLS`. |
|
||||
| `platform-pg` | pending | Live E3 needs a named database, ordinary runtime role, and dated window. |
|
||||
| `fixture-capacity` | applicable | In-process P1/P2 evaluator. Generates no load. |
|
||||
| `shared-substrate` | pending | Live capacity needs an operator window and aggressor ceiling. |
|
||||
|
||||
`not_applicable` is a completed artifact, not a deferral. Do not relabel it to
|
||||
close a workplan. Do not reuse cancelled engagement IDs from `engagements/`.
|
||||
|
|
|
|||
29
targets/fixture-capacity.json
Normal file
29
targets/fixture-capacity.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"schema_version": "whitehat-target/v1",
|
||||
"target_id": "fixture-capacity",
|
||||
"posture_claim": "P1/P2",
|
||||
"attacker_model": "bounded co-resident saturating its own allowance",
|
||||
"applicability": "applicable",
|
||||
"applicability_reason": "In-process evaluator created by this repository. It generates no load and records governor binding plus neighbour degradation.",
|
||||
"approval_classes": ["fixture-capacity"],
|
||||
"adapter": "src/whitehat_security/capacity.py",
|
||||
"probe_pack": "src/whitehat_security/capacity.py",
|
||||
"known_bad_calibration": "src/whitehat_security/capacity.py",
|
||||
"fixture_lifecycle": {
|
||||
"create": "in-process samples",
|
||||
"delete": "drop process state",
|
||||
"ids_are_synthetic": true
|
||||
},
|
||||
"egress": "in-process",
|
||||
"routes": ["baseline", "loaded"],
|
||||
"identities": {
|
||||
"count": 0,
|
||||
"role": "none; fixture generates no network load",
|
||||
"broker_audience": "whitehat-capacity/fixture"
|
||||
},
|
||||
"abort_telemetry": [
|
||||
"governor_unbound",
|
||||
"aggressor_exceeded_ceiling",
|
||||
"missing_neighbour_sample"
|
||||
]
|
||||
}
|
||||
39
targets/fixture-e3.json
Normal file
39
targets/fixture-e3.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"schema_version": "whitehat-target/v1",
|
||||
"target_id": "fixture-e3",
|
||||
"posture_claim": "E3",
|
||||
"attacker_model": "E3-A omitted-predicate accident; E3-B documented SQL-compromise limit",
|
||||
"applicability": "applicable",
|
||||
"applicability_reason": "In-process evaluator created by this repository. It uses one ordinary runtime identity and never grants BYPASSRLS or superuser.",
|
||||
"approval_classes": ["fixture-e3"],
|
||||
"adapter": "src/whitehat_security/e3.py",
|
||||
"probe_pack": "src/whitehat_security/e3.py",
|
||||
"known_bad_calibration": "src/whitehat_security/e3.py",
|
||||
"fixture_lifecycle": {
|
||||
"create": "in-process constructor",
|
||||
"delete": "drop process state",
|
||||
"ids_are_synthetic": true
|
||||
},
|
||||
"egress": "in-process",
|
||||
"routes": [
|
||||
"conformance",
|
||||
"unset_guc",
|
||||
"wrong_tenant_read",
|
||||
"wrong_tenant_insert",
|
||||
"runtime_bypassrls",
|
||||
"unsafe_definer",
|
||||
"reset_to_b"
|
||||
],
|
||||
"identities": {
|
||||
"count": 1,
|
||||
"role": "ordinary runtime database role without BYPASSRLS",
|
||||
"ttl_seconds": 900,
|
||||
"broker_audience": "whitehat-e3/fixture"
|
||||
},
|
||||
"abort_telemetry": [
|
||||
"statement_timeout",
|
||||
"unexpected_or_non_fixture_data",
|
||||
"missing_attribution",
|
||||
"privilege_escalation"
|
||||
]
|
||||
}
|
||||
22
targets/platform-pg-e3.json
Normal file
22
targets/platform-pg-e3.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"schema_version": "whitehat-target/v1",
|
||||
"target_id": "platform-pg",
|
||||
"posture_claim": "E3",
|
||||
"attacker_model": "E3-A omitted-predicate accident; E3-B documented SQL-compromise limit",
|
||||
"applicability": "pending",
|
||||
"applicability_reason": "Named in the rules of engagement as outside the initial API-probe envelope. A live run needs the database owner to confirm an ordinary runtime role without BYPASSRLS, the conformance view, and a dated window. This registration does not authorize a connection.",
|
||||
"approval_classes": ["e3"],
|
||||
"routes": [],
|
||||
"identities": {
|
||||
"count": 1,
|
||||
"role": "ordinary runtime database role without BYPASSRLS or superuser",
|
||||
"ttl_seconds": 900,
|
||||
"broker_audience": "whitehat-e3/platform-pg"
|
||||
},
|
||||
"abort_telemetry": [
|
||||
"statement_timeout",
|
||||
"unexpected_or_non_fixture_data",
|
||||
"missing_attribution",
|
||||
"privilege_escalation"
|
||||
]
|
||||
}
|
||||
21
targets/shared-substrate-capacity.json
Normal file
21
targets/shared-substrate-capacity.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"schema_version": "whitehat-target/v1",
|
||||
"target_id": "shared-substrate",
|
||||
"posture_claim": "P1/P2",
|
||||
"attacker_model": "bounded co-resident saturating its own allowance",
|
||||
"applicability": "pending",
|
||||
"applicability_reason": "No operator-approved substrate window or aggressor ceiling exists. Saturation against a shared rail is an outage if run without those bounds. This registration does not authorize load.",
|
||||
"approval_classes": ["capacity"],
|
||||
"routes": [],
|
||||
"identities": {
|
||||
"count": 0,
|
||||
"role": "none until a window names the aggressor consumer",
|
||||
"broker_audience": "whitehat-capacity/shared-substrate"
|
||||
},
|
||||
"abort_telemetry": [
|
||||
"governor_unbound",
|
||||
"aggressor_exceeded_ceiling",
|
||||
"missing_neighbour_sample",
|
||||
"shared_substrate_abort_threshold"
|
||||
]
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ def test_validate_engagement_reports_clean_denial(tmp_path, capsys):
|
|||
|
||||
def test_validate_targets_accepts_catalog(capsys):
|
||||
main(["validate-targets", "targets"])
|
||||
assert capsys.readouterr().out.startswith("validated 4 target registrations")
|
||||
assert capsys.readouterr().out.startswith("validated 8 target registrations")
|
||||
|
||||
|
||||
def test_kill_switch_is_clear_by_default(capsys):
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from whitehat_security.differential import execute, execute_authorized
|
|||
from whitehat_security.engagement import AuthorizationError, Engagement
|
||||
from whitehat_security.fixtures import FixtureService, probe_suite
|
||||
from whitehat_security.model import RunReport
|
||||
from whitehat_security.e3 import e3_calibration
|
||||
from whitehat_security.plane import (
|
||||
KillSwitch, LocalBroker, RateWatcher, UnconnectedCustodyBroker,
|
||||
admit, cleanup, retired_ids,
|
||||
|
|
@ -59,6 +60,10 @@ def test_catalog_loads_honest_applicability():
|
|||
assert catalog["audit-core"]["applicability"] == "applicable"
|
||||
assert catalog["tenant-engine"]["applicability"] == "not_applicable"
|
||||
assert catalog["flex-auth"]["applicability"] == "pending"
|
||||
assert catalog["fixture-e3"]["applicability"] == "applicable"
|
||||
assert catalog["fixture-capacity"]["applicability"] == "applicable"
|
||||
assert catalog["platform-pg"]["applicability"] == "pending"
|
||||
assert catalog["shared-substrate"]["applicability"] == "pending"
|
||||
|
||||
|
||||
def test_retired_ids_include_cancelled_records():
|
||||
|
|
@ -171,6 +176,82 @@ def test_e3_is_not_admitted_by_the_e2_plane(tmp_path):
|
|||
)
|
||||
|
||||
|
||||
def test_fixture_e3_projects_one_runtime_identity(tmp_path):
|
||||
broker = LocalBroker()
|
||||
engagement = load_engagement(tmp_path, fixture_record(
|
||||
target_id="fixture-e3", approval_class="fixture-e3",
|
||||
techniques=["e3-rls"], routes=["conformance"],
|
||||
))
|
||||
lease = admit(
|
||||
engagement=engagement, registration=load_registration("targets/fixture-e3.json"),
|
||||
broker=broker, kill_switch=KillSwitch(tmp_path / "KILL"),
|
||||
now=NOW, retired=set(),
|
||||
)
|
||||
assert len(lease.identities) == 1
|
||||
assert lease.identities[0].role == "runtime"
|
||||
assert cleanup(lease, broker)["credential_revocation"] == "revoked"
|
||||
|
||||
|
||||
def test_fixture_capacity_projects_no_identities(tmp_path):
|
||||
broker = LocalBroker()
|
||||
engagement = load_engagement(tmp_path, fixture_record(
|
||||
target_id="fixture-capacity", approval_class="fixture-capacity",
|
||||
techniques=["p1-noisy-neighbour"], routes=["baseline"],
|
||||
))
|
||||
lease = admit(
|
||||
engagement=engagement, registration=load_registration("targets/fixture-capacity.json"),
|
||||
broker=broker, kill_switch=KillSwitch(tmp_path / "KILL"),
|
||||
now=NOW, retired=set(),
|
||||
)
|
||||
assert lease.identities == ()
|
||||
assert cleanup(lease, broker)["credential_revocation"] == "revoked"
|
||||
|
||||
|
||||
def test_pending_e3_target_is_refused(tmp_path):
|
||||
engagement = load_engagement(tmp_path, fixture_record(
|
||||
target_id="platform-pg", approval_class="e3",
|
||||
techniques=["e3-rls"], environment="build",
|
||||
plane_namespace="whitehat", runner_image_digest="sha256:abc",
|
||||
database="platform-pg", routes=["conformance"],
|
||||
))
|
||||
with pytest.raises(AuthorizationError, match="pending"):
|
||||
admit(
|
||||
engagement=engagement,
|
||||
registration=load_registration("targets/platform-pg-e3.json"),
|
||||
broker=LocalBroker(), kill_switch=KillSwitch(tmp_path / "KILL"),
|
||||
now=NOW, retired=set(),
|
||||
)
|
||||
|
||||
|
||||
def test_live_e3_without_database_fails_closed(tmp_path):
|
||||
registration = load_registration("targets/fixture-e3.json")
|
||||
registration["target_id"] = "fixture-e3-live"
|
||||
registration["approval_classes"] = ["e3"]
|
||||
engagement = load_engagement(tmp_path, fixture_record(
|
||||
target_id="fixture-e3-live", approval_class="e3",
|
||||
techniques=["e3-rls"], environment="build",
|
||||
plane_namespace="whitehat", runner_image_digest="sha256:abc",
|
||||
routes=["conformance"],
|
||||
))
|
||||
with pytest.raises(AuthorizationError, match="named database"):
|
||||
admit(
|
||||
engagement=engagement, registration=registration,
|
||||
broker=LocalBroker(), kill_switch=KillSwitch(tmp_path / "KILL"),
|
||||
now=NOW, retired=set(),
|
||||
)
|
||||
|
||||
|
||||
def test_e3_calibration_keeps_documented_limit_inconclusive():
|
||||
report = e3_calibration()
|
||||
assert report["outcome"] == "pass"
|
||||
by_id = {item["probe_id"]: item for item in report["known_good"]}
|
||||
assert by_id["sql-compromise-reset"]["outcome"] == "inconclusive"
|
||||
assert by_id["conformance-view-empty"]["outcome"] == "pass"
|
||||
bad = {item["probe_id"]: item for item in report["known_bad"]}
|
||||
assert bad["conformance-view-empty"]["outcome"] == "finding"
|
||||
assert bad["sql-compromise-reset"]["outcome"] == "inconclusive"
|
||||
|
||||
|
||||
def test_rate_and_concurrency_ceilings(tmp_path):
|
||||
watcher = RateWatcher(per_minute=10, max_concurrency=1, max_requests=1)
|
||||
watcher.acquire()
|
||||
|
|
|
|||
|
|
@ -215,12 +215,13 @@ 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
|
||||
Implementation progress, 2026-08-22: 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.
|
||||
seven expected outcomes, keeps the SQL-compromise GUC reset labeled as E3's
|
||||
documented limit, and calibrates known-good/known-bad in-process. `platform-pg`
|
||||
is registered `pending`. A live database run still requires its separate
|
||||
authorized target, named database, ordinary runtime role, and window.
|
||||
|
||||
### T06 — Noisy-neighbour characterisation (the P1/P2 artifact)
|
||||
|
||||
|
|
@ -244,11 +245,12 @@ 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`
|
||||
Implementation progress, 2026-08-22: `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.
|
||||
binding, aggressor peak/ceiling and neighbour degradation. The in-process
|
||||
fixture is registered as `fixture-capacity`. `shared-substrate` is `pending`.
|
||||
No live load has been generated; the required operator-approved substrate
|
||||
window and aggressor ceiling do not yet exist.
|
||||
|
||||
### T07 — Reporting into risk-nexus
|
||||
|
||||
|
|
@ -297,9 +299,10 @@ Completed 2026-08-22 as a repository contract, not a cluster provision:
|
|||
- Rate watcher, lease cleanup, default-deny plane manifests, runner identity.
|
||||
- Automatic outbox delivery of target reports only.
|
||||
|
||||
`ops-mason` still has to provision namespace, network policy and a real
|
||||
custody projection after a new engagement ID exists. That provision is not
|
||||
this task, and this task does not authorize it.
|
||||
`ops-mason` was asked on 2026-08-22 to provision only the namespace, default
|
||||
deny policy and runner service account from `plane/`. That message does not
|
||||
authorize a pod, a credential, or traffic. A real custody projection still
|
||||
waits on a new engagement ID.
|
||||
|
||||
## Sequencing
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue