Ported from authority branch agent/g7-security-critical-closure-20260914 (d5f5c9b, base=beabd053).
S1 auth: role/user from server token->identity mapping, ignore client X-AIOS-* headers.
S2 bootstrap credential: PBKDF2 random-salt, fail-closed SECRET, no plaintext fallback, session HMAC+expiry.
S3 path containment + control-plane file-read hardening.
S4 admin UI XSS + real governed approval + dashboard CSRF/branch validation.
S5 runtime delivery: registry.yaml conflict, openapi securityScheme, install.sh fail propagation, do_POST shadowing.
Tests: 5 negative suites (66 tests). Evidence pack in deliverables/g7-evidence/.
131 lines
5.0 KiB
Python
131 lines
5.0 KiB
Python
"""Read-only validation of recovered campaign authorization evidence."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
import stat
|
|
from typing import Any
|
|
|
|
|
|
class RecoveryEvidenceError(RuntimeError):
|
|
pass
|
|
|
|
|
|
# Relative evidence path: a safe relative path, never absolute, never with
|
|
# traversal segments, never with backslash separators.
|
|
_SAFE_EVIDENCE_REL = re.compile(r"^[A-Za-z0-9_./@-]+$")
|
|
|
|
|
|
def _contained_evidence(path: Path, base: Path) -> Path:
|
|
"""Resolve an evidence path and guarantee it stays under `base`."""
|
|
raw = str(path)
|
|
if (
|
|
os.path.isabs(raw)
|
|
or "\\" in raw
|
|
or raw.startswith("/")
|
|
or ".." in Path(raw).parts
|
|
or not _SAFE_EVIDENCE_REL.fullmatch(raw)
|
|
):
|
|
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_PATH_INVALID")
|
|
base_resolved = base.resolve()
|
|
target = (base_resolved / raw).resolve()
|
|
if base_resolved != target and not str(target).startswith(str(base_resolved) + os.sep):
|
|
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_PATH_ESCAPE")
|
|
return target
|
|
|
|
|
|
def _digest(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
h.update(block)
|
|
return h.hexdigest()
|
|
|
|
|
|
def _secure_json(path: Path) -> dict[str, Any]:
|
|
if path.is_symlink() or not path.is_file():
|
|
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_MISSING")
|
|
info = path.stat(follow_symlinks=False)
|
|
if info.st_uid != 0 or stat.S_IMODE(info.st_mode) & 0o022:
|
|
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_PERMISSIONS")
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_INVALID") from exc
|
|
if not isinstance(value, dict):
|
|
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_OBJECT_REQUIRED")
|
|
return value
|
|
|
|
|
|
def load_recovered_authorization(path: str | Path) -> dict[str, Any]:
|
|
"""Verify the recovery receipt, sidecar, and all referenced immutable files."""
|
|
receipt_path = Path(path)
|
|
receipt = _secure_json(receipt_path)
|
|
sidecar = receipt_path.with_suffix(receipt_path.suffix + ".sha256")
|
|
if sidecar.is_symlink() or not sidecar.is_file():
|
|
raise RecoveryEvidenceError("RECOVERY_DIGEST_SIDECAR_MISSING")
|
|
expected = sidecar.read_text(encoding="ascii").split()[0]
|
|
actual = _digest(receipt_path)
|
|
if expected != actual:
|
|
raise RecoveryEvidenceError("RECOVERY_DIGEST_MISMATCH")
|
|
required = {
|
|
"schema",
|
|
"campaign_id",
|
|
"original_event_id",
|
|
"original_authorization_digest",
|
|
"signed_grant_id",
|
|
"signed_grant_digest",
|
|
"task_id",
|
|
"repository",
|
|
"original_base_oid",
|
|
"branch",
|
|
"expected_branch_oid",
|
|
"recovered_from_existing_evidence",
|
|
"new_owner_interaction",
|
|
"recovery_state",
|
|
"production_side_effect_executed",
|
|
"global_writes_enabled",
|
|
"grant_consumed",
|
|
"owner_authorization_consumed",
|
|
"original_receipts_immutable",
|
|
"recovered_at",
|
|
"evidence_files",
|
|
}
|
|
if set(receipt) != required or receipt["schema"] != "aios.owner-authorization-recovery.v1":
|
|
raise RecoveryEvidenceError("RECOVERY_SCHEMA_INVALID")
|
|
if receipt["recovered_from_existing_evidence"] is not True or receipt["new_owner_interaction"] is not False:
|
|
raise RecoveryEvidenceError("RECOVERY_NOT_APPEND_ONLY")
|
|
if receipt["production_side_effect_executed"] is not False or receipt["global_writes_enabled"] is not False:
|
|
raise RecoveryEvidenceError("RECOVERY_SCOPE_INVALID")
|
|
if receipt["recovery_state"] != "RECOVERED_PENDING_CONTINUATION_LEASE":
|
|
raise RecoveryEvidenceError("RECOVERY_STATE_INVALID")
|
|
evidence = receipt["evidence_files"]
|
|
if not isinstance(evidence, list) or not evidence:
|
|
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_LIST_INVALID")
|
|
base = receipt_path.resolve().parent
|
|
for item in evidence:
|
|
if not isinstance(item, dict) or set(item) != {"path", "sha256"}:
|
|
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_ENTRY_INVALID")
|
|
target = _contained_evidence(Path(item["path"]), base)
|
|
if _digest(target) != item["sha256"]:
|
|
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_DIGEST_MISMATCH")
|
|
return {
|
|
"schema": receipt["schema"],
|
|
"state": receipt["recovery_state"],
|
|
"campaign_id": receipt["campaign_id"],
|
|
"original_event_id": receipt["original_event_id"],
|
|
"original_authorization_digest": receipt["original_authorization_digest"],
|
|
"signed_grant_id": receipt["signed_grant_id"],
|
|
"task_id": receipt["task_id"],
|
|
"repository": receipt["repository"],
|
|
"original_base_oid": receipt["original_base_oid"],
|
|
"branch": receipt["branch"],
|
|
"expected_branch_oid": receipt["expected_branch_oid"],
|
|
"digest": actual,
|
|
"new_owner_interaction": False,
|
|
}
|