feat(nono): add isolated evidence harness
This commit is contained in:
@@ -0,0 +1,710 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Hermetic filesystem and environment fixtures for the Nono evidence tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Mapping
|
||||
|
||||
|
||||
_CREDENTIAL_NAME_HINTS = (
|
||||
"API_KEY",
|
||||
"TOKEN",
|
||||
"SECRET",
|
||||
"PASSWORD",
|
||||
"PRIVATE_KEY",
|
||||
"CREDENTIAL",
|
||||
"SSH_AUTH_SOCK",
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
)
|
||||
|
||||
|
||||
def _mode(path: Path, permissions: int) -> None:
|
||||
path.mkdir(parents=True, exist_ok=True, mode=permissions)
|
||||
os.chmod(path, permissions)
|
||||
|
||||
|
||||
def _write_executable(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
os.chmod(path, 0o700)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FixtureSession:
|
||||
"""Paths belonging to one disposable, independent session."""
|
||||
|
||||
name: str
|
||||
root: Path
|
||||
home: Path
|
||||
state: Path
|
||||
cache: Path
|
||||
runtime: Path
|
||||
tmp: Path
|
||||
repo: Path
|
||||
|
||||
def environment(self, base: Mapping[str, str]) -> dict[str, str]:
|
||||
"""Return a minimal environment whose state paths point at this session."""
|
||||
return _minimal_environment(
|
||||
base,
|
||||
home=self.home,
|
||||
config=self.home / ".config",
|
||||
state=self.state,
|
||||
cache=self.cache,
|
||||
runtime=self.runtime,
|
||||
tmp=self.tmp,
|
||||
)
|
||||
|
||||
|
||||
class IsolatedFixture:
|
||||
"""Context-managed fixture with no dependency on or mutation of host state."""
|
||||
|
||||
def __init__(self, *, prefix: str = "nono-evidence-") -> None:
|
||||
self._temporary: tempfile.TemporaryDirectory[str] | None = None
|
||||
self.prefix = prefix
|
||||
self.root: Path | None = None
|
||||
self.home: Path | None = None
|
||||
self.xdg_config_home: Path | None = None
|
||||
self.xdg_state_home: Path | None = None
|
||||
self.xdg_cache_home: Path | None = None
|
||||
self.xdg_runtime_dir: Path | None = None
|
||||
self.tmpdir: Path | None = None
|
||||
self.repos: Path | None = None
|
||||
self.sessions: Path | None = None
|
||||
self.stub_bin: Path | None = None
|
||||
self.env: dict[str, str] = {}
|
||||
self._session_map: dict[str, FixtureSession] = {}
|
||||
|
||||
def __enter__(self) -> "IsolatedFixture":
|
||||
self._temporary = tempfile.TemporaryDirectory(prefix=self.prefix)
|
||||
self.root = Path(self._temporary.name)
|
||||
os.chmod(self.root, 0o700)
|
||||
|
||||
self.home = self.root / "home"
|
||||
self.xdg_config_home = self.root / "xdg-config"
|
||||
self.xdg_state_home = self.root / "xdg-state"
|
||||
self.xdg_cache_home = self.root / "xdg-cache"
|
||||
self.xdg_runtime_dir = self.root / "xdg-runtime"
|
||||
self.tmpdir = self.root / "tmp"
|
||||
self.repos = self.root / "repos"
|
||||
self.sessions = self.root / "sessions"
|
||||
self.stub_bin = self.root / "bin"
|
||||
for directory in (
|
||||
self.home,
|
||||
self.xdg_config_home,
|
||||
self.xdg_state_home,
|
||||
self.xdg_cache_home,
|
||||
self.xdg_runtime_dir,
|
||||
self.tmpdir,
|
||||
self.repos,
|
||||
self.sessions,
|
||||
self.stub_bin,
|
||||
):
|
||||
_mode(directory, 0o700)
|
||||
|
||||
self.env = _minimal_environment(
|
||||
os.environ,
|
||||
home=self.home,
|
||||
config=self.xdg_config_home,
|
||||
state=self.xdg_state_home,
|
||||
cache=self.xdg_cache_home,
|
||||
runtime=self.xdg_runtime_dir,
|
||||
tmp=self.tmpdir,
|
||||
stub_bin=self.stub_bin,
|
||||
)
|
||||
self._create_fixture_repositories()
|
||||
self._create_stub_bin()
|
||||
self.new_session("one")
|
||||
self.new_session("two")
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
if self._temporary is not None:
|
||||
self._temporary.cleanup()
|
||||
self._temporary = None
|
||||
self.root = None
|
||||
|
||||
def new_session(self, name: str) -> FixtureSession:
|
||||
"""Create (or return) a distinct set of state and scratch directories."""
|
||||
if self.sessions is None or self.repos is None:
|
||||
raise RuntimeError("fixture is not active")
|
||||
if not name or name in {".", ".."} or "/" in name:
|
||||
raise ValueError("session names must be simple path components")
|
||||
if name in self._session_map:
|
||||
return self._session_map[name]
|
||||
root = self.sessions / name
|
||||
home = root / "home"
|
||||
state = root / "state"
|
||||
cache = root / "cache"
|
||||
runtime = root / "runtime"
|
||||
tmp = root / "tmp"
|
||||
repo = self.repos / name
|
||||
for directory in (root, home, home / ".config", state, cache, runtime, tmp, repo):
|
||||
_mode(directory, 0o700)
|
||||
session = FixtureSession(name, root, home, state, cache, runtime, tmp, repo)
|
||||
self._session_map[name] = session
|
||||
return session
|
||||
|
||||
def session(self, name: str) -> FixtureSession:
|
||||
if name not in self._session_map:
|
||||
return self.new_session(name)
|
||||
return self._session_map[name]
|
||||
|
||||
def path(self, *parts: str) -> Path:
|
||||
if self.root is None:
|
||||
raise RuntimeError("fixture is not active")
|
||||
return self.root.joinpath(*parts)
|
||||
|
||||
def _create_fixture_repositories(self) -> None:
|
||||
assert self.repos is not None
|
||||
for path in (
|
||||
self.repos / "primary",
|
||||
self.repos / "sibling-worktree",
|
||||
self.repos / "unrelated",
|
||||
):
|
||||
_mode(path, 0o700)
|
||||
(self.repos / "primary" / "README.txt").write_text(
|
||||
"fixture repository\n", encoding="utf-8"
|
||||
)
|
||||
(self.repos / "sibling-worktree" / "README.txt").write_text(
|
||||
"sibling fixture\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
def _create_stub_bin(self) -> None:
|
||||
assert self.stub_bin is not None
|
||||
_write_executable(
|
||||
self.stub_bin / "safe-probe",
|
||||
"#!/bin/sh\nprintf '%s\\n' safe-probe\n",
|
||||
)
|
||||
_write_executable(
|
||||
self.stub_bin / "credential-name-probe",
|
||||
"#!/bin/sh\nprintf '%s\\n' GITHUB_TOKEN\n",
|
||||
)
|
||||
|
||||
|
||||
def _minimal_environment(
|
||||
inherited: Mapping[str, str],
|
||||
*,
|
||||
home: Path,
|
||||
config: Path,
|
||||
state: Path,
|
||||
cache: Path,
|
||||
runtime: Path,
|
||||
tmp: Path,
|
||||
stub_bin: Path | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Build an allowlist environment; no inherited credential-like names survive."""
|
||||
path = f"{stub_bin}:/usr/bin:/bin" if stub_bin is not None else "/usr/bin:/bin"
|
||||
environment = {
|
||||
"HOME": str(home),
|
||||
"XDG_CONFIG_HOME": str(config),
|
||||
"XDG_STATE_HOME": str(state),
|
||||
"XDG_CACHE_HOME": str(cache),
|
||||
"XDG_RUNTIME_DIR": str(runtime),
|
||||
"TMPDIR": str(tmp),
|
||||
"PATH": path,
|
||||
"LANG": inherited.get("LANG", "C"),
|
||||
"LC_ALL": inherited.get("LC_ALL", "C"),
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
}
|
||||
# Deliberately construct this mapping instead of copying os.environ. Keep this
|
||||
# assertion close to the allowlist so a newly added variable is reviewed.
|
||||
for name in environment:
|
||||
upper = name.upper()
|
||||
if any(hint in upper for hint in _CREDENTIAL_NAME_HINTS):
|
||||
raise AssertionError(f"credential-like variable accidentally allowlisted: {name}")
|
||||
return environment
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def isolated_environment(prefix: str = "nono-evidence-") -> Iterator[IsolatedFixture]:
|
||||
"""Yield an :class:`IsolatedFixture` and clean every path on exit."""
|
||||
with IsolatedFixture(prefix=prefix) as fixture:
|
||||
yield fixture
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FixtureSession",
|
||||
"IsolatedFixture",
|
||||
"isolated_environment",
|
||||
]
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Small, argv-only adapter around the installed Nono executable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO, Iterable, Iterator, Mapping, Sequence
|
||||
|
||||
|
||||
_VERSION_RE = re.compile(r"\bnono\s+v?(\d+\.\d+\.\d+)\b", re.IGNORECASE)
|
||||
_REQUIRED_PATH_ENV = (
|
||||
"HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_STATE_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"TMPDIR",
|
||||
)
|
||||
_ALLOWED_ENVIRONMENT = frozenset(
|
||||
{
|
||||
*_REQUIRED_PATH_ENV,
|
||||
"PATH",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"PYTHONUNBUFFERED",
|
||||
"PYTHONDONTWRITEBYTECODE",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class NonoError(RuntimeError):
|
||||
"""Base error for adapter and host-gate failures."""
|
||||
|
||||
|
||||
class HostRequirementError(NonoError):
|
||||
"""The mandatory host gate cannot be satisfied."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResult:
|
||||
argv: tuple[str, ...]
|
||||
returncode: int | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
timed_out: bool = False
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.returncode == 0 and not self.timed_out
|
||||
|
||||
|
||||
class NonoAdapter:
|
||||
"""Execute only known Nono subcommands with an injected absolute executable.
|
||||
|
||||
The adapter never writes to a profile directory. Profile arguments are always
|
||||
absolute JSON paths, which also makes it impossible for a test to accidentally
|
||||
select an active profile by name.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
executable: str | os.PathLike[str],
|
||||
cwd: str | os.PathLike[str],
|
||||
timeout: float = 20.0,
|
||||
max_output: int = 64 * 1024,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
executable_path = Path(executable)
|
||||
cwd_path = Path(cwd)
|
||||
if not executable_path.is_absolute():
|
||||
raise ValueError("nono executable must be an absolute path")
|
||||
if not cwd_path.is_absolute():
|
||||
raise ValueError("adapter cwd must be an absolute path")
|
||||
if timeout <= 0:
|
||||
raise ValueError("timeout must be positive")
|
||||
if max_output < 1:
|
||||
raise ValueError("max_output must be positive")
|
||||
self.executable = str(executable_path)
|
||||
self.cwd = str(cwd_path)
|
||||
self.timeout = timeout
|
||||
if environment is None:
|
||||
raise ValueError("an explicit isolated environment is required")
|
||||
self.max_output = max_output
|
||||
self.environment = _validated_environment(environment)
|
||||
|
||||
def version(self) -> CommandResult:
|
||||
return self._execute((self.executable, "--version"))
|
||||
|
||||
def require_host_0_74(self) -> str:
|
||||
"""Require Linux and an installed Nono in the exact 0.74 release line."""
|
||||
if platform.system() != "Linux":
|
||||
raise HostRequirementError("mandatory Nono host checks require Linux")
|
||||
if not os.path.isfile(self.executable) or not os.access(self.executable, os.X_OK):
|
||||
raise HostRequirementError(f"Nono executable is unavailable: {self.executable}")
|
||||
try:
|
||||
result = self.version()
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
raise HostRequirementError(f"could not execute Nono: {error}") from error
|
||||
if not result.ok:
|
||||
raise HostRequirementError(
|
||||
f"Nono version check failed (exit {result.returncode}): {result.stderr}"
|
||||
)
|
||||
match = _VERSION_RE.search(result.stdout + "\n" + result.stderr)
|
||||
if match is None or not match.group(1).startswith("0.74."):
|
||||
observed = (result.stdout + result.stderr).strip() or "unknown"
|
||||
raise HostRequirementError(
|
||||
f"mandatory host checks require Nono 0.74.x; observed {observed!r}"
|
||||
)
|
||||
return match.group(1)
|
||||
|
||||
def validate(self, profile: str | os.PathLike[str], *, strict: bool = True) -> CommandResult:
|
||||
with self._profile_snapshot(profile) as profile_path:
|
||||
command = [self.executable, "profile", "validate"]
|
||||
if strict:
|
||||
command.append("--strict")
|
||||
command.append(str(profile_path))
|
||||
return self._execute(command)
|
||||
|
||||
def show(self, profile: str | os.PathLike[str]) -> CommandResult:
|
||||
with self._profile_snapshot(profile) as profile_path:
|
||||
return self._execute(
|
||||
[self.executable, "profile", "show", str(profile_path), "--json"]
|
||||
)
|
||||
|
||||
def run(
|
||||
self,
|
||||
profile: str | os.PathLike[str],
|
||||
command: Sequence[str | os.PathLike[str]],
|
||||
*,
|
||||
allow_cwd: bool = False,
|
||||
) -> CommandResult:
|
||||
if isinstance(command, (str, bytes)):
|
||||
raise TypeError("sandbox command must be an argv sequence, not a shell string")
|
||||
command_args = [os.fspath(argument) for argument in command]
|
||||
if not command_args or any(not isinstance(argument, str) for argument in command_args):
|
||||
raise ValueError("sandbox command must contain at least one argv string")
|
||||
with self._profile_snapshot(profile) as profile_path:
|
||||
nono_args = [self.executable, "run", "--profile", str(profile_path)]
|
||||
if allow_cwd:
|
||||
nono_args.append("--allow-cwd")
|
||||
return self._execute([*nono_args, "--", *command_args])
|
||||
|
||||
def validate_temporary_profile(
|
||||
self, profile: str | os.PathLike[str], *, strict: bool = True
|
||||
) -> CommandResult:
|
||||
return self.validate(profile, strict=strict)
|
||||
|
||||
def show_temporary_profile(self, profile: str | os.PathLike[str]) -> CommandResult:
|
||||
return self.show(profile)
|
||||
|
||||
def run_temporary_profile(
|
||||
self,
|
||||
profile: str | os.PathLike[str],
|
||||
command: Sequence[str | os.PathLike[str]],
|
||||
*,
|
||||
allow_cwd: bool = False,
|
||||
) -> CommandResult:
|
||||
return self.run(
|
||||
profile,
|
||||
command,
|
||||
allow_cwd=allow_cwd,
|
||||
)
|
||||
|
||||
def _open_temporary_profile(
|
||||
self,
|
||||
profile: str | os.PathLike[str],
|
||||
) -> tuple[Path, int]:
|
||||
path = Path(os.path.normpath(os.fspath(profile)))
|
||||
if not path.is_absolute():
|
||||
raise ValueError("temporary profile must be an absolute path")
|
||||
if path.suffix != ".json":
|
||||
raise ValueError("temporary profile must be a JSON file")
|
||||
if _has_symlink_component(path):
|
||||
raise ValueError("temporary profile cannot contain a symlinked path component")
|
||||
active_roots = self._active_profile_roots()
|
||||
if any(path.parent == root or root in path.parents for root in active_roots):
|
||||
raise ValueError("active Nono profile namespace is not permitted")
|
||||
try:
|
||||
descriptor = _open_path_nofollow(path)
|
||||
except OSError as error:
|
||||
raise ValueError(f"temporary profile is unavailable: {error}") from error
|
||||
file_stat = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(file_stat.st_mode):
|
||||
os.close(descriptor)
|
||||
raise ValueError("temporary profile must be a regular file")
|
||||
return path, descriptor
|
||||
|
||||
def _active_profile_roots(self) -> set[Path]:
|
||||
configured_home = self.environment["HOME"]
|
||||
return {
|
||||
(Path.home() / ".config" / "nono" / "profiles").resolve(),
|
||||
(
|
||||
Path(os.environ.get("HOME", str(Path.home())))
|
||||
/ ".config"
|
||||
/ "nono"
|
||||
/ "profiles"
|
||||
).resolve(),
|
||||
(Path(configured_home) / ".config" / "nono" / "profiles").resolve(),
|
||||
}
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _profile_snapshot(
|
||||
self,
|
||||
profile: str | os.PathLike[str],
|
||||
) -> Iterator[Path]:
|
||||
_source, descriptor = self._open_temporary_profile(profile)
|
||||
try:
|
||||
source_stat = os.fstat(descriptor)
|
||||
if source_stat.st_size > 1024 * 1024:
|
||||
raise ValueError("temporary profile must be a regular file no larger than 1 MiB")
|
||||
for active_root in self._active_profile_roots():
|
||||
if not active_root.is_dir():
|
||||
continue
|
||||
for active_profile in active_root.glob("*.json"):
|
||||
try:
|
||||
active_stat = active_profile.stat()
|
||||
except OSError:
|
||||
continue
|
||||
if (active_stat.st_dev, active_stat.st_ino) == (
|
||||
source_stat.st_dev,
|
||||
source_stat.st_ino,
|
||||
):
|
||||
raise ValueError(
|
||||
"temporary profile cannot alias an active Nono profile"
|
||||
)
|
||||
payload = b""
|
||||
while len(payload) <= source_stat.st_size:
|
||||
chunk = os.read(descriptor, min(65536, source_stat.st_size + 1 - len(payload)))
|
||||
if not chunk:
|
||||
break
|
||||
payload += chunk
|
||||
final_stat = os.fstat(descriptor)
|
||||
if (
|
||||
len(payload) != source_stat.st_size
|
||||
or final_stat.st_size != source_stat.st_size
|
||||
or final_stat.st_mtime_ns != source_stat.st_mtime_ns
|
||||
):
|
||||
raise ValueError("temporary profile changed while it was being read")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
scratch_root = Path(self.environment["TMPDIR"])
|
||||
with tempfile.TemporaryDirectory(prefix="nono-profile-", dir=scratch_root) as directory:
|
||||
os.chmod(directory, 0o700)
|
||||
snapshot = Path(directory) / "profile.json"
|
||||
snapshot.write_bytes(payload)
|
||||
os.chmod(snapshot, 0o600)
|
||||
yield snapshot
|
||||
|
||||
def _execute(self, argv: Iterable[str]) -> CommandResult:
|
||||
return run_bounded_command(
|
||||
argv,
|
||||
cwd=self.cwd,
|
||||
environment=self.environment,
|
||||
timeout=self.timeout,
|
||||
max_output=self.max_output,
|
||||
)
|
||||
|
||||
|
||||
def run_bounded_command(
|
||||
argv: Iterable[str],
|
||||
*,
|
||||
cwd: str | os.PathLike[str],
|
||||
environment: Mapping[str, str],
|
||||
timeout: float,
|
||||
max_output: int,
|
||||
) -> CommandResult:
|
||||
argument_list = tuple(str(value) for value in argv)
|
||||
if timeout <= 0 or max_output <= 0:
|
||||
raise ValueError("bounded commands require positive timeout and output limits")
|
||||
cwd_path = Path(cwd)
|
||||
if not cwd_path.is_absolute():
|
||||
raise ValueError("bounded command cwd must be absolute")
|
||||
selected_environment = _validated_environment(environment)
|
||||
buffers = {"stdout": bytearray(), "stderr": bytearray()}
|
||||
|
||||
def drain(stream: BinaryIO, name: str) -> None:
|
||||
with stream:
|
||||
while chunk := stream.read(65536):
|
||||
remaining = max_output - len(buffers[name])
|
||||
if remaining > 0:
|
||||
buffers[name].extend(chunk[:remaining])
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
argument_list,
|
||||
cwd=os.fspath(cwd),
|
||||
env=selected_environment,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as error:
|
||||
return CommandResult(
|
||||
argument_list,
|
||||
None,
|
||||
"",
|
||||
_bounded_text(str(error), max_output),
|
||||
)
|
||||
assert process.stdout is not None and process.stderr is not None
|
||||
threads = [
|
||||
threading.Thread(target=drain, args=(process.stdout, "stdout"), daemon=True),
|
||||
threading.Thread(target=drain, args=(process.stderr, "stderr"), daemon=True),
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
timed_out = False
|
||||
try:
|
||||
process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise NonoError("timed-out process group did not terminate") from error
|
||||
for thread in threads:
|
||||
thread.join(timeout=5)
|
||||
if thread.is_alive():
|
||||
raise NonoError("subprocess output pipe did not close")
|
||||
return CommandResult(
|
||||
argument_list,
|
||||
None if timed_out else process.returncode,
|
||||
bytes(buffers["stdout"]).decode("utf-8", errors="replace"),
|
||||
bytes(buffers["stderr"]).decode("utf-8", errors="replace"),
|
||||
timed_out,
|
||||
)
|
||||
|
||||
|
||||
def _bounded_text(value: str, limit: int) -> str:
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return value[: max(0, limit - 3)] + "..."
|
||||
|
||||
|
||||
def _validated_environment(environment: Mapping[str, str]) -> dict[str, str]:
|
||||
selected = dict(environment)
|
||||
for name in _REQUIRED_PATH_ENV:
|
||||
value = selected.get(name)
|
||||
if not value or not Path(value).is_absolute():
|
||||
raise ValueError(f"the isolated environment requires an absolute {name}")
|
||||
path = Path(value)
|
||||
if _has_symlink_component(path):
|
||||
raise ValueError(f"the isolated environment forbids symlinked {name}")
|
||||
try:
|
||||
path_stat = path.stat()
|
||||
except OSError as error:
|
||||
raise ValueError(f"the isolated environment path is unavailable: {name}") from error
|
||||
if (
|
||||
not stat.S_ISDIR(path_stat.st_mode)
|
||||
or stat.S_IMODE(path_stat.st_mode) != 0o700
|
||||
or path_stat.st_uid != os.geteuid()
|
||||
):
|
||||
raise ValueError(
|
||||
f"the isolated environment requires caller-owned mode-0700 {name}"
|
||||
)
|
||||
unknown = sorted(set(selected) - _ALLOWED_ENVIRONMENT)
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
"environment variable is not in the isolated allowlist: " + unknown[0]
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def _open_path_nofollow(path: Path) -> int:
|
||||
if not path.is_absolute() or not path.name:
|
||||
raise ValueError("profile path must identify an absolute file")
|
||||
directory_flags = (
|
||||
os.O_RDONLY
|
||||
| getattr(os, "O_DIRECTORY", 0)
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
)
|
||||
directory_fd = os.open(path.anchor, directory_flags)
|
||||
try:
|
||||
for component in path.parts[1:-1]:
|
||||
next_fd = os.open(component, directory_flags, dir_fd=directory_fd)
|
||||
os.close(directory_fd)
|
||||
directory_fd = next_fd
|
||||
return os.open(
|
||||
path.name,
|
||||
os.O_RDONLY
|
||||
| getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_NONBLOCK", 0),
|
||||
dir_fd=directory_fd,
|
||||
)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
def _has_symlink_component(path: Path) -> bool:
|
||||
current = path
|
||||
while current.parent != current:
|
||||
if current.is_symlink():
|
||||
return True
|
||||
current = current.parent
|
||||
return current.is_symlink()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CommandResult",
|
||||
"HostRequirementError",
|
||||
"NonoAdapter",
|
||||
"NonoError",
|
||||
"run_bounded_command",
|
||||
]
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Recursive redaction and leak checks for evidence values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Iterable, Pattern
|
||||
|
||||
|
||||
_REDACTED = "[REDACTED]"
|
||||
_REDACTED_NAME = "[REDACTED_CREDENTIAL_NAME]"
|
||||
DEFAULT_CREDENTIAL_NAME_PATTERNS = (
|
||||
r"(?<![A-Za-z0-9])OPENAI_API_KEY(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])ANTHROPIC_API_KEY(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])MORPH_API_KEY(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])GITHUB_TOKEN(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])GH_TOKEN(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])SSH_AUTH_SOCK(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])AWS_(?:ACCESS_KEY_ID|SECRET_ACCESS_KEY)(?![A-Za-z0-9])",
|
||||
r"(?<![A-Za-z0-9])(?:API_KEY|ACCESS_TOKEN|REFRESH_TOKEN|PRIVATE_KEY|PASSWORD|SECRET)(?![A-Za-z0-9])",
|
||||
)
|
||||
# This expression is deliberately applied to mapping keys only. Thus ordinary
|
||||
# fields such as "status" and "provider" remain untouched.
|
||||
_DEFAULT_KEY_PATTERN = re.compile(
|
||||
r"^(?:[A-Z][A-Z0-9]*(?:_|-))*"
|
||||
r"(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|PRIVATE[_-]?KEY|CREDENTIAL|SSH[_-]?AUTH[_-]?SOCK)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class RedactionError(ValueError):
|
||||
"""A value cannot be safely included in a committed summary."""
|
||||
|
||||
|
||||
def _patterns(patterns: Iterable[str | Pattern[str]] | None) -> tuple[Pattern[str], ...]:
|
||||
selected = (*DEFAULT_CREDENTIAL_NAME_PATTERNS, *(patterns or ()))
|
||||
result: list[Pattern[str]] = []
|
||||
for pattern in selected:
|
||||
result.append(re.compile(pattern) if isinstance(pattern, str) else pattern)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _is_credential_name(key: object, patterns: tuple[Pattern[str], ...]) -> bool:
|
||||
if not isinstance(key, str):
|
||||
return False
|
||||
if _DEFAULT_KEY_PATTERN.fullmatch(key):
|
||||
return True
|
||||
return any(pattern.fullmatch(key) is not None for pattern in patterns)
|
||||
|
||||
|
||||
def _redact_text(
|
||||
value: str,
|
||||
canaries: tuple[str, ...],
|
||||
credential_patterns: tuple[Pattern[str], ...],
|
||||
) -> str:
|
||||
# Protect our own marker while applying user-supplied patterns. A pattern
|
||||
# such as ``CREDENTIAL`` must not recursively redact the marker itself.
|
||||
marker_sentinel = "\x00NONO_REDACTED_CREDENTIAL_NAME\x00"
|
||||
result = value.replace(_REDACTED_NAME, marker_sentinel)
|
||||
# Longer values first prevents a short canary from obscuring a longer one in
|
||||
# the common case where one test fixture intentionally nests both.
|
||||
for canary in sorted((item for item in canaries if item), key=len, reverse=True):
|
||||
result = result.replace(canary, _REDACTED)
|
||||
for pattern in credential_patterns:
|
||||
result = pattern.sub(_REDACTED_NAME, result)
|
||||
return result.replace(marker_sentinel, _REDACTED_NAME)
|
||||
|
||||
|
||||
def redact(
|
||||
value: Any,
|
||||
*,
|
||||
canaries: Iterable[str] = (),
|
||||
credential_name_patterns: Iterable[str | Pattern[str]] | None = None,
|
||||
) -> Any:
|
||||
"""Return a recursively redacted copy of JSON-like evidence.
|
||||
|
||||
Credential patterns match mapping keys in full, and match credential-name
|
||||
tokens in free text. This distinction protects safe field names from an
|
||||
over-broad substring replacement.
|
||||
"""
|
||||
canary_values = tuple(str(item) for item in canaries if str(item))
|
||||
patterns = _patterns(credential_name_patterns)
|
||||
|
||||
def visit(item: Any) -> Any:
|
||||
if isinstance(item, Mapping):
|
||||
result: dict[Any, Any] = {}
|
||||
for key, child in item.items():
|
||||
if _is_credential_name(key, patterns):
|
||||
result[_REDACTED_NAME] = _REDACTED
|
||||
else:
|
||||
safe_key = visit(key) if isinstance(key, (str, int, float, bool)) else str(key)
|
||||
result[safe_key] = visit(child)
|
||||
return result
|
||||
if isinstance(item, list):
|
||||
return [visit(child) for child in item]
|
||||
if isinstance(item, tuple):
|
||||
return [visit(child) for child in item]
|
||||
if isinstance(item, set):
|
||||
return [visit(child) for child in sorted(item, key=str)]
|
||||
if isinstance(item, BaseException):
|
||||
return _redact_text(str(item), canary_values, patterns)
|
||||
if isinstance(item, str):
|
||||
return _redact_text(item, canary_values, patterns)
|
||||
return item
|
||||
|
||||
return visit(value)
|
||||
|
||||
|
||||
def scan_for_leaks(
|
||||
value: Any,
|
||||
*,
|
||||
canaries: Iterable[str] = (),
|
||||
credential_name_patterns: Iterable[str | Pattern[str]] | None = None,
|
||||
) -> list[str]:
|
||||
"""Return JSON-style paths where an unredacted canary/name is present."""
|
||||
canary_values = tuple(str(item) for item in canaries if str(item))
|
||||
patterns = _patterns(credential_name_patterns)
|
||||
leaks: list[str] = []
|
||||
|
||||
def inspect(item: Any, path: str) -> None:
|
||||
if isinstance(item, Mapping):
|
||||
for key, child in item.items():
|
||||
key_path = f"{path}.{key}" if path else str(key)
|
||||
if _is_credential_name(key, patterns):
|
||||
leaks.append(key_path)
|
||||
if isinstance(key, str) and any(
|
||||
canary and canary in key for canary in canary_values
|
||||
):
|
||||
leaks.append(key_path)
|
||||
inspect(child, key_path)
|
||||
return
|
||||
if isinstance(item, (list, tuple, set)):
|
||||
for index, child in enumerate(item):
|
||||
inspect(child, f"{path}[{index}]")
|
||||
return
|
||||
if isinstance(item, BaseException):
|
||||
inspect(str(item), path)
|
||||
return
|
||||
if isinstance(item, str):
|
||||
if any(canary and canary in item for canary in canary_values):
|
||||
leaks.append(path or "$")
|
||||
# Do not rediscover the marker emitted by this module when a
|
||||
# generic configured pattern includes the word CREDENTIAL.
|
||||
scan_text = item.replace(_REDACTED_NAME, "")
|
||||
if any(pattern.search(scan_text) is not None for pattern in patterns):
|
||||
leaks.append(path or "$")
|
||||
|
||||
inspect(value, "")
|
||||
return leaks
|
||||
|
||||
|
||||
def assert_no_leaks(
|
||||
value: Any,
|
||||
*,
|
||||
canaries: Iterable[str] = (),
|
||||
credential_name_patterns: Iterable[str | Pattern[str]] | None = None,
|
||||
) -> None:
|
||||
leaks = scan_for_leaks(
|
||||
value,
|
||||
canaries=canaries,
|
||||
credential_name_patterns=credential_name_patterns,
|
||||
)
|
||||
if leaks:
|
||||
raise RedactionError("unredacted evidence at " + ", ".join(leaks))
|
||||
|
||||
|
||||
# Explicit aliases make call sites read naturally and preserve a stable harness API.
|
||||
redact_recursive = redact
|
||||
redact_evidence = redact
|
||||
leak_scan = scan_for_leaks
|
||||
assert_clean = assert_no_leaks
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_CREDENTIAL_NAME_PATTERNS",
|
||||
"RedactionError",
|
||||
"assert_clean",
|
||||
"assert_no_leaks",
|
||||
"leak_scan",
|
||||
"redact",
|
||||
"redact_evidence",
|
||||
"redact_recursive",
|
||||
"scan_for_leaks",
|
||||
]
|
||||
@@ -0,0 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from .evidence import (
|
||||
EvidenceStore,
|
||||
EvidenceValidationError,
|
||||
create_summary,
|
||||
sha256_bytes,
|
||||
sha256_json,
|
||||
utc_timestamp,
|
||||
validate_summary,
|
||||
)
|
||||
|
||||
|
||||
class EvidenceTests(unittest.TestCase):
|
||||
def test_documented_schema_matches_the_runtime_contract(self) -> None:
|
||||
root = Path(__file__).parents[3]
|
||||
schema = json.loads(
|
||||
(root / "config" / "nono" / "evidence.schema.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
summary = self._summary()
|
||||
self.assertEqual(set(schema["required"]), set(summary))
|
||||
self.assertFalse(schema["additionalProperties"])
|
||||
self.assertEqual(
|
||||
set(schema["properties"]["suite"]["enum"]),
|
||||
{"hermetic", "host", "live"},
|
||||
)
|
||||
self.assertEqual(
|
||||
set(schema["properties"]["status"]["enum"]),
|
||||
{"passed", "failed", "blocked", "not-run"},
|
||||
)
|
||||
observation = summary["observations"][0]
|
||||
self.assertEqual(
|
||||
set(schema["$defs"]["observation"]["required"]),
|
||||
set(observation),
|
||||
)
|
||||
self.assertFalse(schema["$defs"]["observation"]["additionalProperties"])
|
||||
|
||||
def _summary(self, **changes: object) -> dict[str, object]:
|
||||
observation = {
|
||||
"id": "negative-tmp",
|
||||
"capability": "filesystem",
|
||||
"expected": "denied",
|
||||
"actual": "denied",
|
||||
"status": "passed",
|
||||
"hash": sha256_json({"actual": "denied", "path": "tmp"}),
|
||||
}
|
||||
values: dict[str, object] = {
|
||||
"run_id": "run-001",
|
||||
"test_id": "negative-matrix",
|
||||
"suite": "hermetic",
|
||||
"repository_commit": "0" * 40,
|
||||
"policy_snapshot_digest": "1" * 64,
|
||||
"installed_snapshot_digest": "2" * 64,
|
||||
"nono_version": "0.74.0",
|
||||
"status": "passed",
|
||||
"started_at": "2026-08-27T04:00:00Z",
|
||||
"finished_at": "2026-08-27T04:00:01Z",
|
||||
"raw_evidence_sha256": "3" * 64,
|
||||
"raw_evidence_retained_until": "2026-09-26T04:00:00Z",
|
||||
"result_hashes": {"result": "4" * 64},
|
||||
"observations": [observation],
|
||||
}
|
||||
values.update(changes)
|
||||
return create_summary(**values) # type: ignore[arg-type]
|
||||
|
||||
def test_summary_contract_is_closed_and_validates_hashes_times_status(self) -> None:
|
||||
summary = self._summary()
|
||||
validate_summary(summary)
|
||||
for mutation in (
|
||||
{"missing": "schemaVersion"},
|
||||
{"extra": "not allowed"},
|
||||
{"policySnapshotDigest": "not-a-digest"},
|
||||
{"startedAt": "2026-08-27T04:00:00+00:00"},
|
||||
{"status": "skipped"},
|
||||
{"rawEvidence": {"sha256": "3" * 64, "retainedUntil": "bad"}},
|
||||
):
|
||||
candidate = dict(summary)
|
||||
if "missing" in mutation:
|
||||
candidate.pop(str(mutation["missing"]))
|
||||
elif "extra" in mutation:
|
||||
candidate["extra"] = mutation["extra"]
|
||||
else:
|
||||
candidate.update(mutation)
|
||||
with self.assertRaises(EvidenceValidationError, msg=str(mutation)):
|
||||
validate_summary(candidate)
|
||||
|
||||
def test_raw_command_fields_and_live_approval_contract(self) -> None:
|
||||
with self.assertRaises(EvidenceValidationError):
|
||||
self._summary(result_hashes={"stdout": "4" * 64})
|
||||
with self.assertRaises(EvidenceValidationError):
|
||||
self._summary(suite="live")
|
||||
live = self._summary(suite="live", approver="JB", status="blocked")
|
||||
validate_summary(live)
|
||||
with self.assertRaises(EvidenceValidationError):
|
||||
self._summary(observations=[{"stdout": "raw"}])
|
||||
|
||||
def test_raw_files_are_private_atomic_and_traceable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
state = root / "state"
|
||||
summaries = root / "summaries"
|
||||
state.mkdir(mode=0o700)
|
||||
summaries.mkdir(mode=0o700)
|
||||
os.chmod(state, 0o700)
|
||||
os.chmod(summaries, 0o700)
|
||||
store = EvidenceStore(state, summaries)
|
||||
payload = {
|
||||
"runId": "run-001",
|
||||
"result": {"stdout": "raw fixture output", "stderr": "raw error"},
|
||||
}
|
||||
reference = store.write_raw_evidence(
|
||||
"run-001", payload, now=dt.datetime(2026, 8, 27, tzinfo=dt.timezone.utc)
|
||||
)
|
||||
self.assertEqual(stat.S_IMODE(reference.path.stat().st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(reference.metadata_path.stat().st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(reference.path.parent.stat().st_mode), 0o700)
|
||||
self.assertEqual(stat.S_IMODE((state / "pi-sbx").stat().st_mode), 0o700)
|
||||
self.assertEqual(stat.S_IMODE((state / "pi-sbx" / "evidence").stat().st_mode), 0o700)
|
||||
self.assertEqual(
|
||||
reference.sha256,
|
||||
sha256_bytes(reference.path.read_bytes()),
|
||||
)
|
||||
restored, metadata = store.read_raw_evidence(
|
||||
"run-001",
|
||||
now=dt.datetime(2026, 8, 28, tzinfo=dt.timezone.utc),
|
||||
)
|
||||
self.assertEqual(restored, payload)
|
||||
self.assertEqual(metadata["sha256"], reference.sha256)
|
||||
self.assertEqual(metadata["retainedUntil"], "2026-09-26T00:00:00Z")
|
||||
summary = self._summary(raw_evidence_sha256=reference.sha256)
|
||||
summary_path = store.write_summary(
|
||||
summary,
|
||||
repository_id="fixture-repo",
|
||||
policy_digest="1" * 64,
|
||||
)
|
||||
self.assertEqual(stat.S_IMODE(summary_path.stat().st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(summary_path.parent.stat().st_mode), 0o700)
|
||||
committed = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
self.assertNotIn("stdout", json.dumps(committed))
|
||||
self.assertNotIn("stderr", json.dumps(committed))
|
||||
self.assertEqual(committed["rawEvidence"]["sha256"], reference.sha256)
|
||||
self.assertEqual(list(summary_path.parent.glob(".*")), [])
|
||||
expired_at = dt.datetime(2026, 9, 26, tzinfo=dt.timezone.utc)
|
||||
with self.assertRaisesRegex(EvidenceValidationError, "expired"):
|
||||
store.read_raw_evidence("run-001", now=expired_at)
|
||||
self.assertEqual(store.purge_expired(now=expired_at), ["run-001"])
|
||||
self.assertFalse(reference.path.parent.exists())
|
||||
|
||||
def test_raw_fifo_is_rejected_without_blocking(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
state = root / "state"
|
||||
summaries = root / "summaries"
|
||||
state.mkdir(mode=0o700)
|
||||
summaries.mkdir(mode=0o700)
|
||||
store = EvidenceStore(state, summaries)
|
||||
reference = store.write_raw_evidence("run-fifo", {"status": "blocked"})
|
||||
reference.path.unlink()
|
||||
os.mkfifo(reference.path, mode=0o600)
|
||||
with self.assertRaisesRegex(EvidenceValidationError, "regular file"):
|
||||
store.read_raw_evidence("run-fifo")
|
||||
|
||||
def test_interrupted_quarantine_is_recovered(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
state = root / "state"
|
||||
summaries = root / "summaries"
|
||||
state.mkdir(mode=0o700)
|
||||
summaries.mkdir(mode=0o700)
|
||||
store = EvidenceStore(state, summaries)
|
||||
reference = store.write_raw_evidence("run-interrupted", {"status": "blocked"})
|
||||
quarantine = reference.path.parent.parent / (".purge-" + "a" * 32)
|
||||
reference.path.parent.rename(quarantine)
|
||||
self.assertEqual(store.purge_expired(), [])
|
||||
self.assertFalse(quarantine.exists())
|
||||
|
||||
def test_paths_cannot_escape_injected_roots(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
state = root / "state"
|
||||
summaries = root / "summaries"
|
||||
state.mkdir(mode=0o700)
|
||||
summaries.mkdir(mode=0o700)
|
||||
os.chmod(state, 0o700)
|
||||
os.chmod(summaries, 0o700)
|
||||
store = EvidenceStore(state, summaries)
|
||||
with self.assertRaises(EvidenceValidationError):
|
||||
store.read_raw_evidence("../outside")
|
||||
with self.assertRaises(EvidenceValidationError):
|
||||
store.summary_path("../outside", "1" * 64, "run-1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import unittest
|
||||
|
||||
from .fixtures import isolated_environment
|
||||
|
||||
|
||||
class FixtureTests(unittest.TestCase):
|
||||
def test_fixture_uses_private_directories_and_separate_sessions(self) -> None:
|
||||
inherited = os.environ.copy()
|
||||
inherited.update(
|
||||
{
|
||||
"OPENAI_API_KEY": "must-not-cross-boundary",
|
||||
"GITHUB_TOKEN": "must-not-cross-boundary",
|
||||
"SSH_AUTH_SOCK": "/tmp/host-agent.sock",
|
||||
"CUSTOM_SECRET_VALUE": "must-not-cross-boundary",
|
||||
}
|
||||
)
|
||||
with isolated_environment() as fixture:
|
||||
# The fixture intentionally does not accept an inherited environment;
|
||||
# this test still checks that the resulting allowlist is safe.
|
||||
self.assertNotIn("OPENAI_API_KEY", fixture.env)
|
||||
self.assertNotIn("GITHUB_TOKEN", fixture.env)
|
||||
self.assertNotIn("SSH_AUTH_SOCK", fixture.env)
|
||||
self.assertNotIn("CUSTOM_SECRET_VALUE", fixture.env)
|
||||
session_environment = fixture.session("one").environment(inherited)
|
||||
self.assertNotIn("OPENAI_API_KEY", session_environment)
|
||||
self.assertNotIn("GITHUB_TOKEN", session_environment)
|
||||
self.assertNotIn("SSH_AUTH_SOCK", session_environment)
|
||||
self.assertNotIn("CUSTOM_SECRET_VALUE", session_environment)
|
||||
self.assertEqual(
|
||||
set(fixture.env),
|
||||
{
|
||||
"HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_STATE_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"TMPDIR",
|
||||
"PATH",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"PYTHONDONTWRITEBYTECODE",
|
||||
},
|
||||
)
|
||||
for path in (
|
||||
fixture.root,
|
||||
fixture.home,
|
||||
fixture.xdg_config_home,
|
||||
fixture.xdg_state_home,
|
||||
fixture.xdg_cache_home,
|
||||
fixture.xdg_runtime_dir,
|
||||
fixture.tmpdir,
|
||||
fixture.repos,
|
||||
fixture.sessions,
|
||||
fixture.stub_bin,
|
||||
):
|
||||
assert path is not None
|
||||
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o700, path)
|
||||
first = fixture.session("one")
|
||||
second = fixture.session("two")
|
||||
self.assertNotEqual(first.tmp, second.tmp)
|
||||
self.assertNotEqual(first.state, second.state)
|
||||
self.assertEqual(stat.S_IMODE(first.tmp.stat().st_mode), 0o700)
|
||||
self.assertEqual(stat.S_IMODE(second.state.stat().st_mode), 0o700)
|
||||
|
||||
def test_session_environment_points_only_at_its_paths(self) -> None:
|
||||
with isolated_environment() as fixture:
|
||||
session = fixture.session("one")
|
||||
environment = session.environment(fixture.env)
|
||||
self.assertEqual(environment["HOME"], str(session.home))
|
||||
self.assertEqual(environment["XDG_STATE_HOME"], str(session.state))
|
||||
self.assertEqual(environment["XDG_RUNTIME_DIR"], str(session.runtime))
|
||||
self.assertEqual(environment["TMPDIR"], str(session.tmp))
|
||||
self.assertNotIn("GH_TOKEN", environment)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from .fixtures import isolated_environment
|
||||
from .nono import NonoAdapter
|
||||
|
||||
|
||||
class NonoAdapterTests(unittest.TestCase):
|
||||
def _environment(self, root: Path, *, home: Path | None = None) -> dict[str, str]:
|
||||
return {
|
||||
"HOME": str(home or root),
|
||||
"XDG_CONFIG_HOME": str(root),
|
||||
"XDG_STATE_HOME": str(root),
|
||||
"XDG_CACHE_HOME": str(root),
|
||||
"XDG_RUNTIME_DIR": str(root),
|
||||
"TMPDIR": str(root),
|
||||
"PATH": "/usr/bin:/bin",
|
||||
}
|
||||
|
||||
def _fake_nono(self, directory: Path, log: Path) -> Path:
|
||||
executable = directory / "fake-nono"
|
||||
executable.write_text(
|
||||
textwrap.dedent(
|
||||
f"""\
|
||||
#!/usr/bin/env python3
|
||||
import pathlib, sys, time
|
||||
pathlib.Path({str(log)!r}).write_text('\\n'.join(sys.argv[1:]), encoding='utf-8')
|
||||
if '--version' in sys.argv:
|
||||
print('nono 0.74.0')
|
||||
if 'slow' in sys.argv:
|
||||
print('x' * (2 * 1024 * 1024), flush=True)
|
||||
print('y' * (2 * 1024 * 1024), file=sys.stderr, flush=True)
|
||||
time.sleep(2)
|
||||
print('stdout:' + ('x' * 500))
|
||||
print('stderr:' + ('y' * 500), file=sys.stderr)
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
executable.chmod(0o700)
|
||||
return executable
|
||||
|
||||
def test_validate_show_and_run_are_exact_argv_and_no_active_namespace(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
log = root / "argv.log"
|
||||
executable = self._fake_nono(root, log)
|
||||
profile = root / "temporary.json"
|
||||
profile.write_text("{}", encoding="utf-8")
|
||||
adapter = NonoAdapter(
|
||||
executable,
|
||||
cwd=root,
|
||||
environment=self._environment(root),
|
||||
)
|
||||
|
||||
adapter.validate_temporary_profile(profile)
|
||||
validation_argv = log.read_text(encoding="utf-8").splitlines()
|
||||
self.assertEqual(validation_argv[:3], ["profile", "validate", "--strict"])
|
||||
self.assertEqual(Path(validation_argv[3]).name, "profile.json")
|
||||
self.assertNotEqual(validation_argv[3], str(profile))
|
||||
adapter.show_temporary_profile(profile)
|
||||
show_argv = log.read_text(encoding="utf-8").splitlines()
|
||||
self.assertEqual(show_argv[:2], ["profile", "show"])
|
||||
self.assertEqual(Path(show_argv[2]).name, "profile.json")
|
||||
self.assertEqual(show_argv[3:], ["--json"])
|
||||
adapter.run_temporary_profile(profile, ["/usr/bin/true", "--fixed"])
|
||||
run_argv = log.read_text(encoding="utf-8").splitlines()
|
||||
self.assertEqual(run_argv[:2], ["run", "--profile"])
|
||||
self.assertEqual(Path(run_argv[2]).name, "profile.json")
|
||||
self.assertEqual(run_argv[3:], ["--", "/usr/bin/true", "--fixed"])
|
||||
adapter.run_temporary_profile(
|
||||
profile,
|
||||
["/usr/bin/true"],
|
||||
allow_cwd=True,
|
||||
)
|
||||
cwd_argv = log.read_text(encoding="utf-8").splitlines()
|
||||
self.assertEqual(cwd_argv[:2], ["run", "--profile"])
|
||||
self.assertEqual(Path(cwd_argv[2]).name, "profile.json")
|
||||
self.assertEqual(cwd_argv[3:], ["--allow-cwd", "--", "/usr/bin/true"])
|
||||
|
||||
active = root / "active" / ".config" / "nono" / "profiles" / "name.json"
|
||||
# An explicitly injected active namespace is rejected by the same
|
||||
# path guard used for HOME's conventional namespace.
|
||||
active.parent.mkdir(parents=True)
|
||||
os.chmod(root / "active", 0o700)
|
||||
adapter_with_home = NonoAdapter(
|
||||
executable,
|
||||
cwd=root,
|
||||
environment=self._environment(root, home=root / "active"),
|
||||
)
|
||||
active.write_text("{}", encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
adapter_with_home.validate(active)
|
||||
hardlink = root / "active-hardlink.json"
|
||||
os.link(active, hardlink)
|
||||
with self.assertRaisesRegex(ValueError, "cannot alias"):
|
||||
adapter_with_home.validate(hardlink)
|
||||
|
||||
def test_result_output_is_bounded_and_timeout_is_recorded(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
executable = self._fake_nono(root, root / "argv.log")
|
||||
profile = root / "temporary.json"
|
||||
profile.write_text("{}", encoding="utf-8")
|
||||
adapter = NonoAdapter(
|
||||
executable,
|
||||
cwd=root,
|
||||
timeout=0.1,
|
||||
max_output=32,
|
||||
environment=self._environment(root),
|
||||
)
|
||||
result = adapter.run(profile, ["slow"])
|
||||
self.assertTrue(result.timed_out)
|
||||
self.assertLessEqual(len(result.stdout), 32)
|
||||
self.assertLessEqual(len(result.stderr), 32)
|
||||
|
||||
def test_commands_are_argv_only(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
executable = self._fake_nono(root, root / "argv.log")
|
||||
profile = root / "temporary.json"
|
||||
profile.write_text("{}", encoding="utf-8")
|
||||
adapter = NonoAdapter(
|
||||
executable,
|
||||
cwd=root,
|
||||
environment=self._environment(root),
|
||||
)
|
||||
with self.assertRaises(TypeError):
|
||||
adapter.run(profile, "echo unsafe") # type: ignore[arg-type]
|
||||
|
||||
def test_environment_is_required_and_symlinked_profiles_are_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
executable = self._fake_nono(root, root / "argv.log")
|
||||
with self.assertRaisesRegex(ValueError, "explicit isolated environment"):
|
||||
NonoAdapter(executable, cwd=root)
|
||||
for unsafe_name in ("GITHUB_TOKEN", "AWS_ACCESS_KEY_ID", "LD_PRELOAD"):
|
||||
with self.subTest(environment=unsafe_name):
|
||||
unsafe_environment = self._environment(root)
|
||||
unsafe_environment[unsafe_name] = "must-not-pass"
|
||||
with self.assertRaisesRegex(ValueError, "allowlist"):
|
||||
NonoAdapter(
|
||||
executable,
|
||||
cwd=root,
|
||||
environment=unsafe_environment,
|
||||
)
|
||||
profile = root / "profile.json"
|
||||
profile.write_text("{}", encoding="utf-8")
|
||||
linked = root / "linked.json"
|
||||
linked.symlink_to(profile)
|
||||
adapter = NonoAdapter(
|
||||
executable,
|
||||
cwd=root,
|
||||
environment=self._environment(root),
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "symlinked"):
|
||||
adapter.validate(linked)
|
||||
fifo = root / "fifo-profile.json"
|
||||
os.mkfifo(fifo, mode=0o600)
|
||||
with self.assertRaisesRegex(ValueError, "regular file"):
|
||||
adapter.validate(fifo)
|
||||
|
||||
|
||||
class FixtureProfileValidationTests(unittest.TestCase):
|
||||
def test_fixture_directories_are_not_install_targets(self) -> None:
|
||||
with isolated_environment() as fixture:
|
||||
profile = Path(__file__).parents[1] / "fixtures" / "core" / "minimal.json"
|
||||
self.assertNotIn(str(profile), fixture.env.values())
|
||||
self.assertTrue(profile.is_file())
|
||||
self.assertNotEqual(stat.S_IMODE(profile.stat().st_mode), 0o600)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from .redaction import RedactionError, assert_no_leaks, redact, scan_for_leaks
|
||||
|
||||
|
||||
class RedactionTests(unittest.TestCase):
|
||||
def test_recursive_canary_redaction_across_all_evidence_surfaces(self) -> None:
|
||||
canary = "canary-value-93f1"
|
||||
evidence = {
|
||||
canary + "-key": "key canary",
|
||||
"environment": {"OPENAI_API_KEY": canary, "SAFE_MODE": "on"},
|
||||
"argv": ["--token", canary],
|
||||
"stdout": "output " + canary,
|
||||
"stderr": "error " + canary,
|
||||
"exception": RuntimeError("failed with " + canary),
|
||||
"nested": [{"GITHUB_TOKEN": "another-value"}],
|
||||
}
|
||||
self.assertTrue(scan_for_leaks(evidence, canaries=[canary]))
|
||||
redacted = redact(evidence, canaries=[canary])
|
||||
assert_no_leaks(redacted, canaries=[canary])
|
||||
self.assertEqual(redacted["environment"]["SAFE_MODE"], "on")
|
||||
self.assertNotIn(canary, str(redacted))
|
||||
self.assertIn("[REDACTED]", str(redacted))
|
||||
|
||||
def test_configured_name_pattern_is_precise_for_safe_fields(self) -> None:
|
||||
evidence = {
|
||||
"status": "passed",
|
||||
"provider": "fixture",
|
||||
"tokenCount": 2,
|
||||
"MY_SECRET_ALPHA": "fixture-secret",
|
||||
"message": "MY_SECRET_ALPHA was used",
|
||||
}
|
||||
patterns = [r"(?<![A-Za-z0-9])MY_SECRET_[A-Z]+(?![A-Za-z0-9])"]
|
||||
leaks = scan_for_leaks(evidence, credential_name_patterns=patterns)
|
||||
self.assertIn("MY_SECRET_ALPHA", leaks)
|
||||
self.assertIn("message", leaks)
|
||||
self.assertNotIn("status", leaks)
|
||||
self.assertNotIn("provider", leaks)
|
||||
self.assertNotIn("tokenCount", leaks)
|
||||
cleaned = redact(evidence, credential_name_patterns=patterns)
|
||||
assert_no_leaks(cleaned, credential_name_patterns=patterns)
|
||||
self.assertEqual(cleaned["status"], "passed")
|
||||
self.assertEqual(cleaned["provider"], "fixture")
|
||||
self.assertEqual(cleaned["tokenCount"], 2)
|
||||
|
||||
def test_leak_assertion_rejects_unredacted_summary_material(self) -> None:
|
||||
with self.assertRaises(RedactionError):
|
||||
assert_no_leaks(
|
||||
{"observation": "GITHUB_TOKEN still visible"},
|
||||
canaries=["a-secret-canary"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user