711 lines
26 KiB
Python
711 lines
26 KiB
Python
"""Schema-v1, secret-free evidence records and private atomic storage."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as _dt
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import stat
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .redaction import assert_no_leaks
|
|
|
|
SCHEMA_VERSION = 1
|
|
RETENTION_DAYS = 30
|
|
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
_COMMIT = re.compile(r"^[0-9a-f]{40}$")
|
|
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
_QUARANTINE_ID = re.compile(r"^\.purge-[0-9a-f]{32}$")
|
|
_UTC = re.compile(
|
|
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$"
|
|
)
|
|
_STATUSES = frozenset({"passed", "failed", "blocked", "not-run"})
|
|
_OUTCOME_VALUES = frozenset({"allowed", "denied", "unknown", "not-run"})
|
|
_TOP_LEVEL_FIELDS = frozenset(
|
|
{
|
|
"schemaVersion",
|
|
"runId",
|
|
"testId",
|
|
"suite",
|
|
"repositoryCommit",
|
|
"policySnapshotDigest",
|
|
"installedSnapshotDigest",
|
|
"nonoVersion",
|
|
"status",
|
|
"approver",
|
|
"startedAt",
|
|
"finishedAt",
|
|
"rawEvidence",
|
|
"resultHashes",
|
|
"observations",
|
|
}
|
|
)
|
|
_OBSERVATION_FIELDS = frozenset(
|
|
{"id", "capability", "expected", "actual", "status", "hash"}
|
|
)
|
|
_RAW_FIELD_NAMES = frozenset(
|
|
{
|
|
"argv",
|
|
"args",
|
|
"command",
|
|
"env",
|
|
"environment",
|
|
"exception",
|
|
"output",
|
|
"raw",
|
|
"stderr",
|
|
"stdout",
|
|
}
|
|
)
|
|
|
|
|
|
class EvidenceValidationError(ValueError):
|
|
"""A summary or evidence envelope does not satisfy the closed contract."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RawEvidenceRef:
|
|
run_id: str
|
|
path: Path
|
|
metadata_path: Path
|
|
sha256: str
|
|
retained_until: str
|
|
|
|
|
|
def utc_timestamp(value: _dt.datetime | None = None) -> str:
|
|
current = value or _dt.datetime.now(_dt.timezone.utc)
|
|
if current.tzinfo is None:
|
|
current = current.replace(tzinfo=_dt.timezone.utc)
|
|
current = current.astimezone(_dt.timezone.utc)
|
|
return current.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
|
|
|
|
def sha256_bytes(value: bytes) -> str:
|
|
return hashlib.sha256(value).hexdigest()
|
|
|
|
|
|
def sha256_json(value: Any) -> str:
|
|
return sha256_bytes(_canonical_json(value))
|
|
|
|
|
|
def _canonical_json(value: Any) -> bytes:
|
|
try:
|
|
return json.dumps(
|
|
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
).encode("utf-8")
|
|
except (TypeError, ValueError) as error:
|
|
raise EvidenceValidationError(f"evidence is not JSON serializable: {error}") from error
|
|
|
|
|
|
def _safe_id(value: object, field: str) -> str:
|
|
if not isinstance(value, str) or _ID.fullmatch(value) is None:
|
|
raise EvidenceValidationError(f"{field} must be a safe identifier")
|
|
return value
|
|
|
|
|
|
def _safe_digest(value: object, field: str) -> str:
|
|
if not isinstance(value, str) or _SHA256.fullmatch(value) is None:
|
|
raise EvidenceValidationError(f"{field} must be a lowercase SHA-256 digest")
|
|
return value
|
|
|
|
|
|
def _timestamp(value: object, field: str) -> _dt.datetime:
|
|
if not isinstance(value, str) or _UTC.fullmatch(value) is None:
|
|
raise EvidenceValidationError(f"{field} must be a UTC timestamp ending in Z")
|
|
try:
|
|
parsed = _dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError as error:
|
|
raise EvidenceValidationError(f"{field} is not a valid timestamp") from error
|
|
if parsed.tzinfo != _dt.timezone.utc:
|
|
raise EvidenceValidationError(f"{field} must use UTC")
|
|
return parsed
|
|
|
|
|
|
def _assert_no_raw_fields(value: Any, path: str = "$") -> None:
|
|
if isinstance(value, Mapping):
|
|
for key, child in value.items():
|
|
if isinstance(key, str) and key.lower() in _RAW_FIELD_NAMES:
|
|
raise EvidenceValidationError(f"raw evidence field is not allowed in summary: {path}.{key}")
|
|
_assert_no_raw_fields(child, f"{path}.{key}")
|
|
elif isinstance(value, (list, tuple)):
|
|
for index, child in enumerate(value):
|
|
_assert_no_raw_fields(child, f"{path}[{index}]")
|
|
|
|
|
|
def _normalize_observation(
|
|
observation: Mapping[str, Any],
|
|
) -> dict[str, str]:
|
|
if not isinstance(observation, Mapping):
|
|
raise EvidenceValidationError("each observation must be an object")
|
|
# Hash is intentionally explicit. Callers must hash an observation before
|
|
# it enters a summary instead of accidentally putting command output here.
|
|
result = dict(observation)
|
|
if "observationHash" in result and "hash" not in result:
|
|
result["hash"] = result.pop("observationHash")
|
|
expected_fields = _OBSERVATION_FIELDS
|
|
if set(result) != expected_fields:
|
|
missing = sorted(expected_fields - set(result))
|
|
extra = sorted(set(result) - expected_fields)
|
|
raise EvidenceValidationError(
|
|
f"observation fields mismatch (missing={missing}, extra={extra})"
|
|
)
|
|
if any(not isinstance(result[field], str) for field in expected_fields):
|
|
raise EvidenceValidationError("observation fields must be strings")
|
|
_safe_id(result["id"], "observation.id")
|
|
_safe_id(result["capability"], "observation.capability")
|
|
if result["expected"] not in _OUTCOME_VALUES:
|
|
raise EvidenceValidationError("observation.expected is invalid")
|
|
if result["actual"] not in _OUTCOME_VALUES:
|
|
raise EvidenceValidationError("observation.actual is invalid")
|
|
if result["status"] not in _STATUSES:
|
|
raise EvidenceValidationError("observation.status is invalid")
|
|
_safe_digest(result["hash"], "observation.hash")
|
|
return {field: result[field] for field in expected_fields}
|
|
|
|
|
|
def create_summary(
|
|
*,
|
|
run_id: str,
|
|
test_id: str,
|
|
suite: str,
|
|
repository_commit: str,
|
|
policy_snapshot_digest: str,
|
|
installed_snapshot_digest: str,
|
|
nono_version: str,
|
|
status: str,
|
|
started_at: str,
|
|
finished_at: str,
|
|
raw_evidence_sha256: str,
|
|
raw_evidence_retained_until: str,
|
|
result_hashes: Mapping[str, str],
|
|
observations: Sequence[Mapping[str, Any]],
|
|
approver: str | None = None,
|
|
canaries: Sequence[str] = (),
|
|
credential_name_patterns: Sequence[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Create and validate a redacted summary; raw command fields are rejected."""
|
|
summary: dict[str, Any] = {
|
|
"schemaVersion": SCHEMA_VERSION,
|
|
"runId": _safe_id(run_id, "runId"),
|
|
"testId": _safe_id(test_id, "testId"),
|
|
"suite": suite,
|
|
"repositoryCommit": repository_commit,
|
|
"policySnapshotDigest": _safe_digest(policy_snapshot_digest, "policySnapshotDigest"),
|
|
"installedSnapshotDigest": _safe_digest(
|
|
installed_snapshot_digest, "installedSnapshotDigest"
|
|
),
|
|
"nonoVersion": nono_version,
|
|
"status": status,
|
|
"approver": approver,
|
|
"startedAt": started_at,
|
|
"finishedAt": finished_at,
|
|
"rawEvidence": {
|
|
"sha256": raw_evidence_sha256,
|
|
"retainedUntil": raw_evidence_retained_until,
|
|
},
|
|
"resultHashes": dict(result_hashes),
|
|
"observations": [
|
|
_normalize_observation(item) for item in observations
|
|
],
|
|
}
|
|
_assert_no_raw_fields(summary)
|
|
# A caller that forgot to redact gets an explicit failure, never a best-effort
|
|
# committed summary.
|
|
assert_no_leaks(
|
|
summary,
|
|
canaries=canaries,
|
|
credential_name_patterns=credential_name_patterns,
|
|
)
|
|
validate_summary(summary)
|
|
return summary
|
|
|
|
|
|
def validate_summary(summary: Any) -> None:
|
|
if not isinstance(summary, Mapping):
|
|
raise EvidenceValidationError("summary must be a JSON object")
|
|
if set(summary) != _TOP_LEVEL_FIELDS:
|
|
raise EvidenceValidationError(
|
|
f"summary fields mismatch (missing={sorted(_TOP_LEVEL_FIELDS - set(summary))}, "
|
|
f"extra={sorted(set(summary) - _TOP_LEVEL_FIELDS)})"
|
|
)
|
|
if summary["schemaVersion"] != SCHEMA_VERSION:
|
|
raise EvidenceValidationError("unsupported schemaVersion")
|
|
_safe_id(summary["runId"], "runId")
|
|
_safe_id(summary["testId"], "testId")
|
|
if summary["suite"] not in {"hermetic", "host", "live"}:
|
|
raise EvidenceValidationError("suite must be hermetic, host, or live")
|
|
if not isinstance(summary["repositoryCommit"], str) or _COMMIT.fullmatch(
|
|
summary["repositoryCommit"]
|
|
) is None:
|
|
raise EvidenceValidationError("repositoryCommit must be a 40-character commit SHA")
|
|
_safe_digest(summary["policySnapshotDigest"], "policySnapshotDigest")
|
|
_safe_digest(summary["installedSnapshotDigest"], "installedSnapshotDigest")
|
|
if not isinstance(summary["nonoVersion"], str) or re.fullmatch(
|
|
r"0\.74\.\d+", summary["nonoVersion"]
|
|
) is None:
|
|
raise EvidenceValidationError("nonoVersion must be in the 0.74.x line")
|
|
if summary["status"] not in _STATUSES:
|
|
raise EvidenceValidationError("invalid summary status")
|
|
if summary["suite"] == "live":
|
|
if not isinstance(summary["approver"], str) or not summary["approver"].strip():
|
|
raise EvidenceValidationError("live evidence requires an approver")
|
|
elif summary["approver"] is not None and not isinstance(summary["approver"], str):
|
|
raise EvidenceValidationError("approver must be null outside live mode")
|
|
started = _timestamp(summary["startedAt"], "startedAt")
|
|
finished = _timestamp(summary["finishedAt"], "finishedAt")
|
|
if finished < started:
|
|
raise EvidenceValidationError("finishedAt precedes startedAt")
|
|
|
|
raw = summary["rawEvidence"]
|
|
if not isinstance(raw, Mapping) or set(raw) != {"sha256", "retainedUntil"}:
|
|
raise EvidenceValidationError("rawEvidence fields mismatch")
|
|
_safe_digest(raw["sha256"], "rawEvidence.sha256")
|
|
_timestamp(raw["retainedUntil"], "rawEvidence.retainedUntil")
|
|
|
|
hashes = summary["resultHashes"]
|
|
if not isinstance(hashes, Mapping):
|
|
raise EvidenceValidationError("resultHashes must be an object")
|
|
for key, digest in hashes.items():
|
|
_safe_id(key, "resultHashes key")
|
|
_safe_digest(digest, f"resultHashes.{key}")
|
|
|
|
observations = summary["observations"]
|
|
if not isinstance(observations, list):
|
|
raise EvidenceValidationError("observations must be an array")
|
|
for item in observations:
|
|
_normalize_observation(item)
|
|
_assert_no_raw_fields(summary)
|
|
assert_no_leaks(summary)
|
|
|
|
|
|
class EvidenceStore:
|
|
"""Store raw evidence and summaries beneath caller-injected roots only."""
|
|
|
|
def __init__(self, state_root: str | os.PathLike[str], summary_root: str | os.PathLike[str]):
|
|
self.state_root = self._root(Path(state_root), "state_root")
|
|
self.summary_root = self._root(Path(summary_root), "summary_root")
|
|
|
|
@staticmethod
|
|
def _root(path: Path, name: str) -> Path:
|
|
if not path.is_absolute():
|
|
raise ValueError(f"{name} must be absolute")
|
|
if _has_symlink_component(path):
|
|
raise ValueError(f"{name} cannot contain a symlinked path component")
|
|
if path.exists():
|
|
if not path.is_dir():
|
|
raise ValueError(f"{name} must be a regular directory, not a symlink")
|
|
if path.stat().st_uid != os.geteuid() or stat_mode(path) != 0o700:
|
|
raise ValueError(f"{name} must be caller-owned mode 0700")
|
|
else:
|
|
path.mkdir(parents=True, mode=0o700)
|
|
os.chmod(path, 0o700)
|
|
return path
|
|
|
|
def _raw_dir(self, run_id: str, *, create: bool = True) -> Path:
|
|
_safe_id(run_id, "runId")
|
|
base = self.state_root / "pi-sbx" / "evidence"
|
|
directory = base / run_id
|
|
if create:
|
|
_private_dir(base)
|
|
_private_dir(directory)
|
|
else:
|
|
if (
|
|
not base.is_dir()
|
|
or base.is_symlink()
|
|
or base.stat().st_uid != os.geteuid()
|
|
or stat_mode(base) != 0o700
|
|
):
|
|
raise EvidenceValidationError("retained evidence root does not exist privately")
|
|
if (
|
|
not directory.is_dir()
|
|
or directory.is_symlink()
|
|
or directory.stat().st_uid != os.geteuid()
|
|
or stat_mode(directory) != 0o700
|
|
):
|
|
raise EvidenceValidationError("retained evidence run directory is unavailable")
|
|
return directory
|
|
|
|
def write_raw_evidence(
|
|
self,
|
|
run_id: str,
|
|
evidence: Any,
|
|
*,
|
|
now: _dt.datetime | None = None,
|
|
) -> RawEvidenceRef:
|
|
raw_dir = self._raw_dir(run_id)
|
|
started = now or _dt.datetime.now(_dt.timezone.utc)
|
|
retained = utc_timestamp(started + _dt.timedelta(days=RETENTION_DAYS))
|
|
payload = _canonical_json(evidence)
|
|
digest = sha256_bytes(payload)
|
|
raw_path = raw_dir / "raw.json"
|
|
metadata_path = raw_dir / "metadata.json"
|
|
_atomic_write(raw_path, payload)
|
|
metadata = {
|
|
"schemaVersion": SCHEMA_VERSION,
|
|
"runId": run_id,
|
|
"sha256": digest,
|
|
"createdAt": utc_timestamp(started),
|
|
"retainedUntil": retained,
|
|
}
|
|
_atomic_write(metadata_path, _canonical_json(metadata))
|
|
return RawEvidenceRef(run_id, raw_path, metadata_path, digest, retained)
|
|
|
|
def read_raw_evidence(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
now: _dt.datetime | None = None,
|
|
allow_expired: bool = False,
|
|
) -> tuple[Any, dict[str, Any]]:
|
|
raw_dir = self._raw_dir(run_id, create=False)
|
|
directory_fd = _private_directory_fd(raw_dir)
|
|
try:
|
|
return self._read_raw_evidence_fd(
|
|
run_id,
|
|
directory_fd,
|
|
now=now,
|
|
allow_expired=allow_expired,
|
|
)
|
|
finally:
|
|
os.close(directory_fd)
|
|
|
|
def _read_raw_evidence_fd(
|
|
self,
|
|
run_id: str,
|
|
directory_fd: int,
|
|
*,
|
|
now: _dt.datetime | None,
|
|
allow_expired: bool,
|
|
) -> tuple[Any, dict[str, Any]]:
|
|
raw_bytes = _read_nofollow_at(directory_fd, "raw.json", "raw.json")
|
|
metadata_bytes = _read_nofollow_at(
|
|
directory_fd,
|
|
"metadata.json",
|
|
"metadata.json",
|
|
)
|
|
try:
|
|
payload = json.loads(raw_bytes.decode("utf-8"))
|
|
metadata = json.loads(metadata_bytes.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
raise EvidenceValidationError(f"invalid retained raw evidence: {error}") from error
|
|
if not isinstance(metadata, dict):
|
|
raise EvidenceValidationError("raw evidence metadata is not an object")
|
|
metadata_fields = {"schemaVersion", "runId", "sha256", "createdAt", "retainedUntil"}
|
|
if set(metadata) != metadata_fields or metadata.get("schemaVersion") != SCHEMA_VERSION:
|
|
raise EvidenceValidationError("raw evidence metadata fields mismatch")
|
|
if metadata.get("sha256") != sha256_bytes(raw_bytes):
|
|
raise EvidenceValidationError("raw evidence hash does not match metadata")
|
|
if metadata.get("runId") != run_id:
|
|
raise EvidenceValidationError("raw evidence run ID mismatch")
|
|
created = _timestamp(metadata.get("createdAt"), "raw metadata createdAt")
|
|
retained = _timestamp(metadata.get("retainedUntil"), "raw metadata retainedUntil")
|
|
if retained != created + _dt.timedelta(days=RETENTION_DAYS):
|
|
raise EvidenceValidationError("raw evidence retention must be exactly 30 days")
|
|
current = now or _dt.datetime.now(_dt.timezone.utc)
|
|
if not allow_expired and retained <= current.astimezone(_dt.timezone.utc):
|
|
raise EvidenceValidationError("raw evidence retention has expired")
|
|
return payload, metadata
|
|
|
|
def purge_expired(self, *, now: _dt.datetime | None = None) -> list[str]:
|
|
base = self.state_root / "pi-sbx" / "evidence"
|
|
if not base.exists():
|
|
return []
|
|
current = now or _dt.datetime.now(_dt.timezone.utc)
|
|
removed: list[str] = []
|
|
base_fd = _private_directory_fd(base)
|
|
try:
|
|
for name in sorted(os.listdir(base_fd)):
|
|
before = os.stat(name, dir_fd=base_fd, follow_symlinks=False)
|
|
if _QUARANTINE_ID.fullmatch(name):
|
|
if (
|
|
not stat.S_ISDIR(before.st_mode)
|
|
or stat.S_IMODE(before.st_mode) != 0o700
|
|
or before.st_uid != os.geteuid()
|
|
):
|
|
raise EvidenceValidationError(
|
|
f"unsafe interrupted purge entry: {base / name}"
|
|
)
|
|
_remove_tree_at(
|
|
base_fd,
|
|
name,
|
|
(before.st_dev, before.st_ino),
|
|
)
|
|
continue
|
|
run_id = _safe_id(name, "retained runId")
|
|
if (
|
|
not stat.S_ISDIR(before.st_mode)
|
|
or stat.S_IMODE(before.st_mode) != 0o700
|
|
or before.st_uid != os.geteuid()
|
|
):
|
|
raise EvidenceValidationError(f"unsafe evidence entry: {base / name}")
|
|
run_fd = os.open(
|
|
name,
|
|
os.O_RDONLY
|
|
| getattr(os, "O_DIRECTORY", 0)
|
|
| getattr(os, "O_NOFOLLOW", 0),
|
|
dir_fd=base_fd,
|
|
)
|
|
try:
|
|
opened = os.fstat(run_fd)
|
|
if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
|
|
raise EvidenceValidationError(
|
|
f"evidence directory changed before purge read: {base / name}"
|
|
)
|
|
_payload, metadata = self._read_raw_evidence_fd(
|
|
run_id,
|
|
run_fd,
|
|
now=current,
|
|
allow_expired=True,
|
|
)
|
|
finally:
|
|
os.close(run_fd)
|
|
after = os.stat(name, dir_fd=base_fd, follow_symlinks=False)
|
|
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
|
|
raise EvidenceValidationError(
|
|
f"evidence directory changed during purge: {base / name}"
|
|
)
|
|
retained = _timestamp(
|
|
metadata["retainedUntil"],
|
|
"raw metadata retainedUntil",
|
|
)
|
|
if retained <= current.astimezone(_dt.timezone.utc):
|
|
quarantine = f".purge-{secrets.token_hex(16)}"
|
|
os.rename(
|
|
name,
|
|
quarantine,
|
|
src_dir_fd=base_fd,
|
|
dst_dir_fd=base_fd,
|
|
)
|
|
quarantined = os.stat(
|
|
quarantine,
|
|
dir_fd=base_fd,
|
|
follow_symlinks=False,
|
|
)
|
|
if (quarantined.st_dev, quarantined.st_ino) != (
|
|
before.st_dev,
|
|
before.st_ino,
|
|
):
|
|
raise EvidenceValidationError(
|
|
f"evidence directory changed during quarantine: {base / name}"
|
|
)
|
|
_remove_tree_at(
|
|
base_fd,
|
|
quarantine,
|
|
(before.st_dev, before.st_ino),
|
|
)
|
|
removed.append(run_id)
|
|
finally:
|
|
os.close(base_fd)
|
|
return removed
|
|
|
|
def summary_path(self, repository_id: str, policy_digest: str, run_id: str) -> Path:
|
|
_safe_id(repository_id, "repository_id")
|
|
_safe_digest(policy_digest, "policy_digest")
|
|
_safe_id(run_id, "run_id")
|
|
directory = self.summary_root / repository_id / policy_digest
|
|
_private_dir(directory)
|
|
return directory / f"{run_id}.json"
|
|
|
|
def write_summary(
|
|
self,
|
|
summary: Mapping[str, Any],
|
|
*,
|
|
repository_id: str,
|
|
policy_digest: str,
|
|
) -> Path:
|
|
validate_summary(summary)
|
|
path = self.summary_path(repository_id, policy_digest, str(summary["runId"]))
|
|
_atomic_write(path, _canonical_json(summary))
|
|
return path
|
|
|
|
|
|
def stat_mode(path: Path) -> int:
|
|
return path.stat().st_mode & 0o777
|
|
|
|
|
|
def _has_symlink_component(path: Path) -> bool:
|
|
current = path
|
|
while True:
|
|
if current.is_symlink():
|
|
return True
|
|
if current.parent == current:
|
|
return False
|
|
current = current.parent
|
|
|
|
|
|
def _private_dir(path: Path) -> None:
|
|
if _has_symlink_component(path):
|
|
raise ValueError(f"evidence directory cannot contain a symlinked component: {path}")
|
|
if path.exists():
|
|
if (
|
|
path.is_symlink()
|
|
or not path.is_dir()
|
|
or path.stat().st_uid != os.geteuid()
|
|
or stat_mode(path) != 0o700
|
|
):
|
|
raise ValueError(f"evidence directory must be caller-owned mode-0700: {path}")
|
|
return
|
|
missing: list[Path] = []
|
|
current = path
|
|
while not current.exists():
|
|
missing.append(current)
|
|
current = current.parent
|
|
if (
|
|
current.is_symlink()
|
|
or not current.is_dir()
|
|
or current.stat().st_uid != os.geteuid()
|
|
or stat_mode(current) != 0o700
|
|
):
|
|
raise ValueError(f"evidence parent must be caller-owned mode-0700: {current}")
|
|
for directory in reversed(missing):
|
|
directory.mkdir(mode=0o700)
|
|
os.chmod(directory, 0o700)
|
|
|
|
|
|
def _private_directory_fd(path: Path) -> int:
|
|
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
descriptor = os.open(path, flags)
|
|
directory_stat = os.fstat(descriptor)
|
|
if (
|
|
not stat.S_ISDIR(directory_stat.st_mode)
|
|
or stat.S_IMODE(directory_stat.st_mode) != 0o700
|
|
or directory_stat.st_uid != os.geteuid()
|
|
):
|
|
os.close(descriptor)
|
|
raise EvidenceValidationError(
|
|
f"evidence directory is not caller-owned mode-0700: {path}"
|
|
)
|
|
return descriptor
|
|
|
|
|
|
def _remove_tree_at(
|
|
parent_fd: int,
|
|
name: str,
|
|
expected_identity: tuple[int, int],
|
|
) -> None:
|
|
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
directory_fd = os.open(name, flags, dir_fd=parent_fd)
|
|
try:
|
|
directory_stat = os.fstat(directory_fd)
|
|
if (directory_stat.st_dev, directory_stat.st_ino) != expected_identity:
|
|
raise EvidenceValidationError(
|
|
f"evidence directory identity changed before removal: {name}"
|
|
)
|
|
for child in os.listdir(directory_fd):
|
|
child_stat = os.stat(child, dir_fd=directory_fd, follow_symlinks=False)
|
|
if stat.S_ISDIR(child_stat.st_mode):
|
|
_remove_tree_at(
|
|
directory_fd,
|
|
child,
|
|
(child_stat.st_dev, child_stat.st_ino),
|
|
)
|
|
elif stat.S_ISREG(child_stat.st_mode):
|
|
os.unlink(child, dir_fd=directory_fd)
|
|
else:
|
|
raise EvidenceValidationError(
|
|
f"unsafe entry in expired evidence directory: {name}/{child}"
|
|
)
|
|
finally:
|
|
os.close(directory_fd)
|
|
final_stat = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
if (final_stat.st_dev, final_stat.st_ino) != expected_identity:
|
|
raise EvidenceValidationError(
|
|
f"evidence directory identity changed before final removal: {name}"
|
|
)
|
|
os.rmdir(name, dir_fd=parent_fd)
|
|
|
|
|
|
def _atomic_write(path: Path, payload: bytes) -> None:
|
|
directory_fd = _private_directory_fd(path.parent)
|
|
temporary_name = f".{path.name}.{secrets.token_hex(8)}"
|
|
descriptor = -1
|
|
try:
|
|
descriptor = os.open(
|
|
temporary_name,
|
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
|
0o600,
|
|
dir_fd=directory_fd,
|
|
)
|
|
written = 0
|
|
while written < len(payload):
|
|
written += os.write(descriptor, payload[written:])
|
|
os.fchmod(descriptor, 0o600)
|
|
os.fsync(descriptor)
|
|
os.close(descriptor)
|
|
descriptor = -1
|
|
os.rename(
|
|
temporary_name,
|
|
path.name,
|
|
src_dir_fd=directory_fd,
|
|
dst_dir_fd=directory_fd,
|
|
)
|
|
os.fsync(directory_fd)
|
|
finally:
|
|
if descriptor != -1:
|
|
os.close(descriptor)
|
|
try:
|
|
os.unlink(temporary_name, dir_fd=directory_fd)
|
|
except FileNotFoundError:
|
|
pass
|
|
os.close(directory_fd)
|
|
|
|
|
|
def _read_nofollow_at(directory_fd: int, name: str, display: str) -> bytes:
|
|
flags = (
|
|
os.O_RDONLY
|
|
| getattr(os, "O_NOFOLLOW", 0)
|
|
| getattr(os, "O_NONBLOCK", 0)
|
|
)
|
|
try:
|
|
descriptor = os.open(name, flags, dir_fd=directory_fd)
|
|
except OSError as error:
|
|
raise EvidenceValidationError(
|
|
f"cannot read retained evidence {display}: {error}"
|
|
) from error
|
|
try:
|
|
file_stat = os.fstat(descriptor)
|
|
if (
|
|
not stat.S_ISREG(file_stat.st_mode)
|
|
or stat.S_IMODE(file_stat.st_mode) != 0o600
|
|
or file_stat.st_uid != os.geteuid()
|
|
):
|
|
raise EvidenceValidationError(
|
|
"retained evidence is not a caller-owned mode-0600 regular file: "
|
|
+ display
|
|
)
|
|
remaining = file_stat.st_size
|
|
chunks: list[bytes] = []
|
|
while remaining:
|
|
chunk = os.read(descriptor, remaining)
|
|
if not chunk:
|
|
break
|
|
chunks.append(chunk)
|
|
remaining -= len(chunk)
|
|
return b"".join(chunks)
|
|
finally:
|
|
os.close(descriptor)
|
|
|
|
|
|
validate_evidence_summary = validate_summary
|
|
|
|
|
|
__all__ = [
|
|
"EvidenceStore",
|
|
"EvidenceValidationError",
|
|
"RawEvidenceRef",
|
|
"RETENTION_DAYS",
|
|
"SCHEMA_VERSION",
|
|
"create_summary",
|
|
"sha256_bytes",
|
|
"sha256_json",
|
|
"utc_timestamp",
|
|
"validate_evidence_summary",
|
|
"validate_summary",
|
|
]
|