Add governed test plane and close T04/T08
Encode fail-closed admission, target registrations, and a credential broker that never returns secret values. Calibrate audit-core shaped probes in-process. Send no packets and request no live credentials. Assistant: grok Assistant-Session: 01a02670-3345-76f2-a014-70fde8e2a2bb
This commit is contained in:
parent
0aab0cb4c6
commit
95129d7a35
35 changed files with 1599 additions and 103 deletions
83
src/whitehat_security/audit_fixtures.py
Normal file
83
src/whitehat_security/audit_fixtures.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .differential import DifferentialProbe
|
||||
from .model import Response
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditFixture:
|
||||
"""In-process audit-core shape used to calibrate the E2 pack. No sockets."""
|
||||
|
||||
enforce_tenant: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.events = {
|
||||
"event-a": {
|
||||
"id": "event-a", "tenant": "tenant-a",
|
||||
"correlation_id": "corr-shared", "label": "fixture-a",
|
||||
},
|
||||
"event-b": {
|
||||
"id": "event-b", "tenant": "tenant-b",
|
||||
"correlation_id": "corr-shared", "label": "fixture-b",
|
||||
},
|
||||
}
|
||||
|
||||
def snapshot_events(self) -> object:
|
||||
return deepcopy(self.events)
|
||||
|
||||
def _visible(self, actor_tenant: str, item: dict) -> bool:
|
||||
return (not self.enforce_tenant) or item["tenant"] == actor_tenant
|
||||
|
||||
def get(self, actor_tenant: str, event_id: str) -> Response:
|
||||
item = self.events.get(event_id)
|
||||
if item is None or not self._visible(actor_tenant, item):
|
||||
return Response(404, {"error": "not_found"})
|
||||
return Response(200, deepcopy(item))
|
||||
|
||||
def by_correlation(self, actor_tenant: str, correlation_id: str) -> Response:
|
||||
items = [
|
||||
deepcopy(item) for item in self.events.values()
|
||||
if item["correlation_id"] == correlation_id and self._visible(actor_tenant, item)
|
||||
]
|
||||
return Response(200, {"events": items})
|
||||
|
||||
def append(self, actor_tenant: str, event: dict) -> Response:
|
||||
if self.enforce_tenant and event["tenant"] != actor_tenant:
|
||||
return Response(403, {"error": "forbidden"})
|
||||
self.events[event["id"]] = deepcopy(event)
|
||||
return Response(202, {"id": event["id"], "tenant": event["tenant"]})
|
||||
|
||||
|
||||
def audit_probe_suite(service: AuditFixture) -> list[DifferentialProbe]:
|
||||
markers = ("event-b", "tenant-b", "fixture-b")
|
||||
forged = {
|
||||
"id": "event-forged-b", "tenant": "tenant-b",
|
||||
"correlation_id": "corr-shared", "label": "fixture-forged",
|
||||
}
|
||||
return [
|
||||
DifferentialProbe(
|
||||
"audit-event-by-id", "read",
|
||||
lambda: service.get("tenant-b", "event-b"),
|
||||
lambda: service.get("tenant-a", "event-b"),
|
||||
lambda: service.get("tenant-a", "absent"),
|
||||
markers,
|
||||
),
|
||||
DifferentialProbe(
|
||||
"audit-correlation-slice", "read",
|
||||
lambda: service.by_correlation("tenant-b", "corr-shared"),
|
||||
lambda: service.by_correlation("tenant-a", "corr-shared"),
|
||||
lambda: service.by_correlation("tenant-a", "corr-absent"),
|
||||
markers, hide_existence=False,
|
||||
),
|
||||
DifferentialProbe(
|
||||
"audit-append-as-b", "create",
|
||||
lambda: Response(202, {"id": "owner-control"}),
|
||||
lambda: service.append("tenant-a", forged),
|
||||
lambda: Response(403, {"error": "forbidden"}),
|
||||
markers + ("event-forged-b",),
|
||||
state=service.snapshot_events, hide_existence=False,
|
||||
),
|
||||
]
|
||||
|
|
@ -6,19 +6,26 @@ import sys
|
|||
from dataclasses import asdict
|
||||
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 .engagement import AuthorizationError, Engagement
|
||||
from .fixtures import FixtureService, probe_suite
|
||||
from .model import RunReport, utc_now
|
||||
from .reporting import risk_nexus_message
|
||||
from .plane import KillSwitch, admit, default_broker, retired_ids
|
||||
from .reporting import queue_risk_nexus, risk_nexus_message
|
||||
from .targets import load_catalog, load_registration
|
||||
|
||||
|
||||
def fixture_calibration() -> dict:
|
||||
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))]
|
||||
good_generic = probe_suite(FixtureService(enforce_tenant=True))
|
||||
bad_generic = probe_suite(FixtureService(enforce_tenant=False))
|
||||
good_audit = audit_probe_suite(AuditFixture(enforce_tenant=True))
|
||||
bad_audit = audit_probe_suite(AuditFixture(enforce_tenant=False))
|
||||
good = [execute(probe) for probe in (*good_generic, *good_audit)]
|
||||
bad = [execute(probe) for probe in (*bad_generic, *bad_audit)]
|
||||
detected = all(result.outcome == "finding" for result in bad)
|
||||
rejected = all(result.outcome == "pass" for result in good)
|
||||
return {
|
||||
|
|
@ -78,6 +85,15 @@ def main(argv: list[str] | None = None) -> None:
|
|||
engagement.add_argument("path")
|
||||
packs = commands.add_parser("validate-packs")
|
||||
packs.add_argument("path")
|
||||
targets = commands.add_parser("validate-targets")
|
||||
targets.add_argument("path")
|
||||
admit_plane = commands.add_parser("admit-plane")
|
||||
admit_plane.add_argument("engagement")
|
||||
admit_plane.add_argument("registration")
|
||||
commands.add_parser("kill-switch")
|
||||
deliver = commands.add_parser("deliver")
|
||||
deliver.add_argument("report")
|
||||
deliver.add_argument("--outbox", default="outbox")
|
||||
commands.add_parser("e3-plan")
|
||||
commands.add_parser("capacity-fixture")
|
||||
message = commands.add_parser("risk-message")
|
||||
|
|
@ -108,6 +124,42 @@ def main(argv: list[str] | None = None) -> None:
|
|||
validate_pack(path)
|
||||
print(f"validated {len(paths)} probe packs")
|
||||
return
|
||||
if args.command == "validate-targets":
|
||||
try:
|
||||
catalog = load_catalog(args.path)
|
||||
except (AuthorizationError, OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"not authorized: {error}", file=sys.stderr)
|
||||
raise SystemExit(2) from None
|
||||
print(f"validated {len(catalog)} target registrations")
|
||||
return
|
||||
if args.command == "admit-plane":
|
||||
try:
|
||||
record = Engagement.load(args.engagement)
|
||||
registration = load_registration(args.registration)
|
||||
lease = admit(engagement=record, registration=registration,
|
||||
broker=default_broker(record), kill_switch=KillSwitch(),
|
||||
retired=retired_ids())
|
||||
except (AuthorizationError, OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"not authorized: {error}", file=sys.stderr)
|
||||
raise SystemExit(2) from None
|
||||
print(f"admitted: {lease.engagement.raw['engagement_id']} lease={lease.lease_id}")
|
||||
return
|
||||
if args.command == "kill-switch":
|
||||
switch = KillSwitch()
|
||||
if switch.engaged():
|
||||
print(f"engaged: {switch.path}")
|
||||
raise SystemExit(1)
|
||||
print("clear")
|
||||
return
|
||||
if args.command == "deliver":
|
||||
try:
|
||||
report = RunReport(**json.loads(Path(args.report).read_text(encoding="utf-8")))
|
||||
path = queue_risk_nexus(report, args.outbox)
|
||||
except (AuthorizationError, OSError, ValueError, json.JSONDecodeError, TypeError) as error:
|
||||
print(f"not authorized: {error}", file=sys.stderr)
|
||||
raise SystemExit(2) from None
|
||||
print(f"queued: {path}")
|
||||
return
|
||||
if args.command == "e3-plan":
|
||||
print(json.dumps({"cadence": CADENCE, "probes": [asdict(probe) for probe in PROBES]},
|
||||
indent=2, sort_keys=True))
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Callable
|
||||
|
||||
from .engagement import Engagement
|
||||
from .model import Observation, ProbeResult, Response, observe
|
||||
from .plane import PlaneLease
|
||||
|
||||
Request = Callable[[], Response]
|
||||
State = Callable[[], object]
|
||||
|
|
@ -49,12 +50,17 @@ def execute(probe: DifferentialProbe, *, salt: bytes | None = None) -> ProbeResu
|
|||
)
|
||||
|
||||
|
||||
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 execute_authorized(probe: DifferentialProbe, *, lease: PlaneLease,
|
||||
route: str, salt: bytes | None = None,
|
||||
now: datetime | None = None) -> ProbeResult:
|
||||
"""Live entry point: plane admission is rechecked immediately before calls."""
|
||||
lease.engagement.validate(now=now)
|
||||
lease.permits(technique="e2-differential", route=route)
|
||||
lease.watcher.acquire()
|
||||
try:
|
||||
return execute(probe, salt=salt)
|
||||
finally:
|
||||
lease.watcher.release()
|
||||
|
||||
|
||||
def _equivalent_denial(left: Observation, right: Observation) -> bool:
|
||||
|
|
|
|||
263
src/whitehat_security/plane.py
Normal file
263
src/whitehat_security/plane.py
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .engagement import AuthorizationError, Engagement
|
||||
|
||||
PLANE_NAMESPACE = "whitehat"
|
||||
STANDING_E2_TTL = 900
|
||||
TERMINAL_IDS = frozenset({
|
||||
"WH-ENG-20260821-AUDIT-E2",
|
||||
"WH-ENG-20260821-TENANT-E2",
|
||||
})
|
||||
APPROVAL_CLASSES = {
|
||||
"fixture-e2": frozenset({"e2-differential"}),
|
||||
"live-e2": frozenset({"e2-differential"}),
|
||||
"e3": frozenset({"e3-rls"}),
|
||||
"capacity": frozenset({"p1-noisy-neighbour", "p2-noisy-neighbour"}),
|
||||
}
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def retired_ids(engagements_dir: str | Path | None = None) -> set[str]:
|
||||
retired = set(TERMINAL_IDS)
|
||||
root = Path(engagements_dir) if engagements_dir else repo_root() / "engagements"
|
||||
if not root.exists():
|
||||
return retired
|
||||
for path in root.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if data.get("status") == "cancelled" and data.get("engagement_id"):
|
||||
retired.add(data["engagement_id"])
|
||||
return retired
|
||||
|
||||
|
||||
class KillSwitch:
|
||||
def __init__(self, path: str | Path | None = None) -> None:
|
||||
default = os.environ.get("WHITEHAT_KILL_SWITCH_PATH", str(repo_root() / "plane" / "KILL"))
|
||||
self.path = Path(path) if path is not None else Path(default)
|
||||
|
||||
def engaged(self) -> bool:
|
||||
flag = os.environ.get("WHITEHAT_KILL_SWITCH", "").strip().lower()
|
||||
if flag in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
return self.path.exists()
|
||||
|
||||
|
||||
class RateWatcher:
|
||||
def __init__(self, *, per_minute: int, max_concurrency: int, max_requests: int) -> None:
|
||||
if max_concurrency != 1:
|
||||
raise AuthorizationError("v0.1 permits exactly one in-flight API/database operation")
|
||||
if not 1 <= per_minute <= 60:
|
||||
raise AuthorizationError("API rate ceiling must be between 1 and 60 per minute")
|
||||
if max_requests < 1:
|
||||
raise AuthorizationError("request ceiling must be at least 1")
|
||||
self.per_minute = per_minute
|
||||
self.max_requests = max_requests
|
||||
self._times: list[float] = []
|
||||
self._in_flight = 0
|
||||
self._count = 0
|
||||
|
||||
def acquire(self) -> None:
|
||||
if self._in_flight:
|
||||
raise AuthorizationError("concurrency ceiling reached")
|
||||
now = time.monotonic()
|
||||
self._times = [stamp for stamp in self._times if now - stamp < 60]
|
||||
if len(self._times) >= self.per_minute:
|
||||
raise AuthorizationError("rate ceiling reached")
|
||||
if self._count >= self.max_requests:
|
||||
raise AuthorizationError("request ceiling reached")
|
||||
self._times.append(now)
|
||||
self._count += 1
|
||||
self._in_flight = 1
|
||||
|
||||
def release(self) -> None:
|
||||
self._in_flight = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityHandle:
|
||||
role: str
|
||||
mount_path: str
|
||||
lease_id: str
|
||||
expires_at: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"IdentityHandle(role={self.role!r}, mount_path={self.mount_path!r}, "
|
||||
f"lease_id={self.lease_id!r}, expires_at={self.expires_at!r})"
|
||||
)
|
||||
|
||||
|
||||
class CredentialBroker(Protocol):
|
||||
def project(self, engagement: Engagement, registration: dict[str, Any]
|
||||
) -> tuple[IdentityHandle, IdentityHandle]:
|
||||
...
|
||||
|
||||
def revoke(self, lease_id: str) -> None:
|
||||
...
|
||||
|
||||
|
||||
class UnconnectedCustodyBroker:
|
||||
"""Fail-closed live broker. It never requests or returns credential values."""
|
||||
|
||||
def project(self, engagement: Engagement, registration: dict[str, Any]
|
||||
) -> tuple[IdentityHandle, IdentityHandle]:
|
||||
raise AuthorizationError(
|
||||
"live custody broker is not connected; no credential was requested"
|
||||
)
|
||||
|
||||
def revoke(self, lease_id: str) -> None:
|
||||
raise AuthorizationError(
|
||||
f"live custody broker is not connected; lease {lease_id} was not created"
|
||||
)
|
||||
|
||||
|
||||
class LocalBroker:
|
||||
"""In-process projection for fixture-e2. Secret bytes never leave this object."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._secrets: dict[str, bytes] = {}
|
||||
self._leases: dict[str, tuple[str, str]] = {}
|
||||
|
||||
def project(self, engagement: Engagement, registration: dict[str, Any]
|
||||
) -> tuple[IdentityHandle, IdentityHandle]:
|
||||
if registration.get("applicability") != "applicable":
|
||||
raise AuthorizationError("broker will not project identities for a non-applicable target")
|
||||
lease_id = os.urandom(8).hex()
|
||||
ttl = min(int(engagement.raw["credential_max_ttl_seconds"]), STANDING_E2_TTL)
|
||||
expires_at = (datetime.now(UTC) + timedelta(seconds=ttl)).isoformat().replace("+00:00", "Z")
|
||||
owner = IdentityHandle("owner", f"/var/run/secrets/whitehat/{lease_id}/token-b",
|
||||
lease_id, expires_at)
|
||||
attacker = IdentityHandle("attacker", f"/var/run/secrets/whitehat/{lease_id}/token-a",
|
||||
lease_id, expires_at)
|
||||
self._secrets[owner.mount_path] = os.urandom(32)
|
||||
self._secrets[attacker.mount_path] = os.urandom(32)
|
||||
self._leases[lease_id] = (owner.mount_path, attacker.mount_path)
|
||||
return owner, attacker
|
||||
|
||||
def revoke(self, lease_id: str) -> None:
|
||||
paths = self._leases.pop(lease_id, None)
|
||||
if paths is None:
|
||||
raise AuthorizationError("lease is unknown; cleanup cannot be proven")
|
||||
for path in paths:
|
||||
self._secrets.pop(path, None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaneLease:
|
||||
engagement: Engagement
|
||||
registration: dict[str, Any]
|
||||
identities: tuple[IdentityHandle, IdentityHandle]
|
||||
watcher: RateWatcher
|
||||
lease_id: str
|
||||
broker_name: str
|
||||
|
||||
def permits(self, *, technique: str, route: str) -> None:
|
||||
self.engagement.permits(technique=technique, route=route)
|
||||
if route not in self.registration["routes"]:
|
||||
raise AuthorizationError(f"route not registered: {route}")
|
||||
|
||||
|
||||
def default_broker(engagement: Engagement) -> CredentialBroker:
|
||||
if (engagement.raw.get("environment") == "fixture"
|
||||
and engagement.raw.get("approval_class") == "fixture-e2"):
|
||||
return LocalBroker()
|
||||
return UnconnectedCustodyBroker()
|
||||
|
||||
|
||||
def admit(*, engagement: Engagement, registration: dict[str, Any],
|
||||
broker: CredentialBroker | None = None,
|
||||
kill_switch: KillSwitch | None = None,
|
||||
now: datetime | None = None,
|
||||
retired: set[str] | None = None) -> PlaneLease:
|
||||
engagement.validate(now=now)
|
||||
switch = kill_switch or KillSwitch()
|
||||
if switch.engaged():
|
||||
raise AuthorizationError("kill switch is engaged")
|
||||
engagement_id = engagement.raw["engagement_id"]
|
||||
if engagement_id in (retired if retired is not None else retired_ids()):
|
||||
raise AuthorizationError("engagement id is retired and must not be reused")
|
||||
if registration.get("applicability") != "applicable":
|
||||
raise AuthorizationError(
|
||||
f"target is {registration.get('applicability', 'unregistered')}"
|
||||
)
|
||||
target_id = engagement.raw.get("target_id")
|
||||
if not target_id:
|
||||
raise AuthorizationError("engagement missing target_id for plane admission")
|
||||
if target_id != registration["target_id"]:
|
||||
raise AuthorizationError("engagement target_id does not match registration")
|
||||
approval_class = engagement.raw.get("approval_class")
|
||||
allowed = APPROVAL_CLASSES.get(approval_class)
|
||||
if allowed is None:
|
||||
raise AuthorizationError("engagement missing or unknown approval_class")
|
||||
if approval_class not in registration["approval_classes"]:
|
||||
raise AuthorizationError("approval_class is not registered for this target")
|
||||
for technique in engagement.raw["techniques"]:
|
||||
if technique not in allowed:
|
||||
raise AuthorizationError(
|
||||
f"technique {technique} not in approval class {approval_class}"
|
||||
)
|
||||
for route in engagement.raw["routes"]:
|
||||
if route not in registration["routes"]:
|
||||
raise AuthorizationError(f"route not registered: {route}")
|
||||
if approval_class == "live-e2":
|
||||
if engagement.raw.get("plane_namespace") != PLANE_NAMESPACE:
|
||||
raise AuthorizationError("live E2 requires the whitehat plane namespace")
|
||||
if not engagement.raw.get("runner_image_digest"):
|
||||
raise AuthorizationError("live E2 requires a pinned runner image digest")
|
||||
if engagement.raw["credential_max_ttl_seconds"] > STANDING_E2_TTL:
|
||||
raise AuthorizationError("E2 credential TTL must be at most 900 seconds")
|
||||
if engagement.raw["environment"] == "fixture":
|
||||
raise AuthorizationError("live E2 cannot use the fixture environment")
|
||||
elif approval_class == "fixture-e2":
|
||||
if engagement.raw["environment"] != "fixture":
|
||||
raise AuthorizationError("fixture-e2 requires environment=fixture")
|
||||
elif approval_class in {"e3", "capacity"}:
|
||||
raise AuthorizationError(
|
||||
f"{approval_class} requires a separate explicit operator window; "
|
||||
"the E2 plane will not admit it"
|
||||
)
|
||||
selected = broker or default_broker(engagement)
|
||||
identities = selected.project(engagement, registration)
|
||||
if len(identities) != 2:
|
||||
raise AuthorizationError("broker must project exactly two identities")
|
||||
roles = {handle.role for handle in identities}
|
||||
if roles != {"owner", "attacker"}:
|
||||
raise AuthorizationError("broker must project owner and attacker identities")
|
||||
if any(hasattr(handle, "secret") and getattr(handle, "secret") for handle in identities):
|
||||
raise AuthorizationError("broker exposed a credential value")
|
||||
watcher = RateWatcher(
|
||||
per_minute=engagement.raw["rate_limit_per_minute"],
|
||||
max_concurrency=engagement.raw["max_concurrency"],
|
||||
max_requests=int(engagement.raw.get("maximum_requests")
|
||||
or engagement.raw["rate_limit_per_minute"]),
|
||||
)
|
||||
return PlaneLease(
|
||||
engagement=engagement,
|
||||
registration=registration,
|
||||
identities=identities,
|
||||
watcher=watcher,
|
||||
lease_id=identities[0].lease_id,
|
||||
broker_name=type(selected).__name__,
|
||||
)
|
||||
|
||||
|
||||
def cleanup(lease: PlaneLease, broker: CredentialBroker) -> dict[str, str]:
|
||||
broker.revoke(lease.lease_id)
|
||||
return {
|
||||
"fixtures": "delete only recorded fixture_ids",
|
||||
"credential_revocation": "revoked",
|
||||
"lease_id": lease.lease_id,
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .engagement import AuthorizationError
|
||||
from .model import RunReport
|
||||
|
||||
|
||||
|
|
@ -29,3 +30,14 @@ def risk_nexus_message(report: RunReport) -> str:
|
|||
lines.extend(["", "Reporter supplies facts only; risk-nexus owns severity and disclosure."])
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def queue_risk_nexus(report: RunReport, outbox: str | Path) -> Path:
|
||||
"""Persist a delivery artifact. Fixture calibration is not target assurance."""
|
||||
if report.evidence_class != "target":
|
||||
raise AuthorizationError("fixture evidence is not delivered as target assurance")
|
||||
directory = Path(outbox)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{report.run_id}.md"
|
||||
path.write_text(risk_nexus_message(report), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
|
|
|||
61
src/whitehat_security/targets.py
Normal file
61
src/whitehat_security/targets.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .engagement import AuthorizationError
|
||||
|
||||
REQUIRED = {
|
||||
"schema_version", "target_id", "posture_claim", "attacker_model",
|
||||
"applicability", "applicability_reason", "approval_classes", "routes",
|
||||
"identities", "abort_telemetry",
|
||||
}
|
||||
APPLICABLE_REQUIRED = {
|
||||
"adapter", "probe_pack", "known_bad_calibration", "fixture_lifecycle", "egress",
|
||||
}
|
||||
APPLICABILITY = {"applicable", "not_applicable", "pending"}
|
||||
|
||||
|
||||
def load_registration(path: str | Path) -> dict[str, Any]:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
missing = sorted(REQUIRED - data.keys())
|
||||
if missing:
|
||||
raise AuthorizationError(f"{path}: missing {', '.join(missing)}")
|
||||
if data["schema_version"] != "whitehat-target/v1":
|
||||
raise AuthorizationError(f"{path}: unsupported schema_version")
|
||||
if data["applicability"] not in APPLICABILITY:
|
||||
raise AuthorizationError(f"{path}: invalid applicability")
|
||||
if not data["applicability_reason"]:
|
||||
raise AuthorizationError(f"{path}: applicability_reason is required")
|
||||
if not data["approval_classes"]:
|
||||
raise AuthorizationError(f"{path}: at least one approval class is required")
|
||||
if data["applicability"] == "applicable":
|
||||
missing_live = sorted(APPLICABLE_REQUIRED - data.keys())
|
||||
if missing_live:
|
||||
raise AuthorizationError(f"{path}: applicable target missing {', '.join(missing_live)}")
|
||||
if not data["routes"]:
|
||||
raise AuthorizationError(f"{path}: applicable target must register routes")
|
||||
identities = data["identities"]
|
||||
if identities.get("count") != 2:
|
||||
raise AuthorizationError(f"{path}: E2 registration must project two identities")
|
||||
if identities.get("ttl_seconds", 0) > 900:
|
||||
raise AuthorizationError(f"{path}: identity TTL must be at most 900 seconds")
|
||||
if data.get("known_bad_calibration") in {None, "", "pending"}:
|
||||
raise AuthorizationError(f"{path}: applicable target needs completed known-bad calibration")
|
||||
return data
|
||||
|
||||
|
||||
def load_catalog(directory: str | Path) -> dict[str, dict[str, Any]]:
|
||||
root = Path(directory)
|
||||
paths = sorted(root.glob("*.json"))
|
||||
if not paths:
|
||||
raise AuthorizationError(f"{root}: no target registrations found")
|
||||
catalog: dict[str, dict[str, Any]] = {}
|
||||
for path in paths:
|
||||
registration = load_registration(path)
|
||||
target_id = registration["target_id"]
|
||||
if target_id in catalog:
|
||||
raise AuthorizationError(f"duplicate target_id: {target_id}")
|
||||
catalog[target_id] = registration
|
||||
return catalog
|
||||
Loading…
Add table
Add a link
Reference in a new issue