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
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"
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue