421 lines
17 KiB
Python
421 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Fail-closed verifier for machine-readable quote proof manifests.
|
|
|
|
The verifier never executes the recorded argv. It validates a captured execution
|
|
record against repository source bytes. Disk output is opt-in via ``--output``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
|
|
SCHEMA_VERSION = "proof-manifest/v1"
|
|
RESULT_SCHEMA_VERSION = "proof-manifest-result/v1"
|
|
DEFAULT_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
DEFAULT_PROFILES = DEFAULT_REPO_ROOT / "harness/source/execution-profiles.json"
|
|
HEX_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
SAFE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
SAFE_ROLE = re.compile(r"^[a-z][a-z0-9_-]*$")
|
|
|
|
|
|
class ManifestValidationError(Exception):
|
|
"""Raised when one or more fail-closed proof checks fail."""
|
|
|
|
def __init__(self, issues: Iterable[Mapping[str, str]]) -> None:
|
|
self.issues = [dict(issue) for issue in issues]
|
|
super().__init__(f"proof manifest validation failed ({len(self.issues)} issue(s))")
|
|
|
|
|
|
def _issue(issues: list[dict[str, str]], code: str, location: str, message: str) -> None:
|
|
issues.append({"code": code, "location": location, "message": message})
|
|
|
|
|
|
def _is_int(value: Any) -> bool:
|
|
return isinstance(value, int) and not isinstance(value, bool)
|
|
|
|
|
|
def _require_object(
|
|
value: Any,
|
|
location: str,
|
|
required: set[str],
|
|
optional: set[str],
|
|
issues: list[dict[str, str]],
|
|
) -> Mapping[str, Any] | None:
|
|
if not isinstance(value, dict):
|
|
_issue(issues, "INVALID_TYPE", location, "must be a JSON object")
|
|
return None
|
|
keys = set(value)
|
|
for missing in sorted(required - keys):
|
|
_issue(issues, "MISSING_FIELD", f"{location}.{missing}", "required field is missing")
|
|
for unknown in sorted(keys - required - optional):
|
|
_issue(issues, "UNKNOWN_FIELD", f"{location}.{unknown}", "unknown field is not allowed")
|
|
return value
|
|
|
|
|
|
def _load_json(path: Path) -> Any:
|
|
with path.open("r", encoding="utf-8") as stream:
|
|
return json.load(stream)
|
|
|
|
|
|
def load_allowed_profiles(path: Path) -> set[str]:
|
|
try:
|
|
document = _load_json(path)
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
raise ManifestValidationError(
|
|
[{"code": "PROFILE_SOURCE_ERROR", "location": str(path), "message": str(exc)}]
|
|
) from exc
|
|
if not isinstance(document, dict) or document.get("schema_version") != "execution-profiles/v1":
|
|
raise ManifestValidationError(
|
|
[
|
|
{
|
|
"code": "PROFILE_SOURCE_SCHEMA",
|
|
"location": str(path),
|
|
"message": "expected execution-profiles/v1",
|
|
}
|
|
]
|
|
)
|
|
profiles = document.get("profiles")
|
|
if not isinstance(profiles, dict) or not profiles:
|
|
raise ManifestValidationError(
|
|
[{"code": "PROFILE_SOURCE_SCHEMA", "location": str(path), "message": "profiles must be a non-empty object"}]
|
|
)
|
|
return set(profiles)
|
|
|
|
|
|
def _resolve_source(root: Path, relative_path: str) -> Path | None:
|
|
candidate = Path(relative_path)
|
|
if candidate.is_absolute():
|
|
return None
|
|
resolved = (root / candidate).resolve()
|
|
try:
|
|
resolved.relative_to(root)
|
|
except ValueError:
|
|
return None
|
|
return resolved
|
|
|
|
|
|
def _validate_proof(
|
|
proof: Any,
|
|
index: int,
|
|
repo_root: Path,
|
|
run_root: Path | None,
|
|
seen_finding_roles: set[tuple[str, str]],
|
|
issues: list[dict[str, str]],
|
|
) -> None:
|
|
base = f"proofs[{index}]"
|
|
proof_obj = _require_object(proof, base, {"finding", "source", "execution"}, set(), issues)
|
|
if proof_obj is None:
|
|
return
|
|
|
|
finding = _require_object(proof_obj.get("finding"), f"{base}.finding", {"id", "role"}, set(), issues)
|
|
source = _require_object(
|
|
proof_obj.get("source"),
|
|
f"{base}.source",
|
|
{"path", "sha256", "line_start", "line_end", "quote_utf8"},
|
|
{"namespace"},
|
|
issues,
|
|
)
|
|
execution = _require_object(
|
|
proof_obj.get("execution"),
|
|
f"{base}.execution",
|
|
{"argv", "exit_code", "stdout_utf8", "stdout_sha256", "exact_match"},
|
|
set(),
|
|
issues,
|
|
)
|
|
|
|
finding_id: str | None = None
|
|
role: str | None = None
|
|
if finding is not None:
|
|
finding_id_value = finding.get("id")
|
|
role_value = finding.get("role")
|
|
if not isinstance(finding_id_value, str) or not SAFE_IDENTIFIER.fullmatch(finding_id_value):
|
|
_issue(issues, "INVALID_FINDING_ID", f"{base}.finding.id", "must match [A-Za-z0-9][A-Za-z0-9._-]*")
|
|
else:
|
|
finding_id = finding_id_value
|
|
if not isinstance(role_value, str) or not SAFE_ROLE.fullmatch(role_value):
|
|
_issue(issues, "INVALID_FINDING_ROLE", f"{base}.finding.role", "must be a lowercase role identifier")
|
|
else:
|
|
role = role_value
|
|
if finding_id is not None and role is not None:
|
|
key = (finding_id, role)
|
|
if key in seen_finding_roles:
|
|
_issue(issues, "DUPLICATE_FINDING_ROLE", f"{base}.finding", f"duplicate pair: {finding_id}/{role}")
|
|
else:
|
|
seen_finding_roles.add(key)
|
|
|
|
quote_utf8: str | None = None
|
|
source_bytes: bytes | None = None
|
|
selected_bytes: bytes | None = None
|
|
if source is not None:
|
|
namespace = source.get("namespace", "repo")
|
|
relative_path = source.get("path")
|
|
expected_sha256 = source.get("sha256")
|
|
line_start = source.get("line_start")
|
|
line_end = source.get("line_end")
|
|
quote_value = source.get("quote_utf8")
|
|
|
|
if namespace not in {"repo", "run"}:
|
|
_issue(issues, "INVALID_SOURCE_NAMESPACE", f"{base}.source.namespace", "must be repo or run")
|
|
source_root = None
|
|
elif namespace == "run" and run_root is None:
|
|
_issue(issues, "RUN_ROOT_REQUIRED", f"{base}.source.namespace", "run namespace requires a run root")
|
|
source_root = None
|
|
else:
|
|
source_root = repo_root if namespace == "repo" else run_root
|
|
|
|
if not isinstance(relative_path, str) or not relative_path or "\\" in relative_path:
|
|
_issue(issues, "INVALID_SOURCE_PATH", f"{base}.source.path", "must be a non-empty repo-relative POSIX path")
|
|
resolved_source = None
|
|
elif source_root is None:
|
|
resolved_source = None
|
|
else:
|
|
resolved_source = _resolve_source(source_root, relative_path)
|
|
if resolved_source is None:
|
|
_issue(issues, "SOURCE_OUTSIDE_NAMESPACE", f"{base}.source.path", f"path escapes the {namespace} root")
|
|
elif not resolved_source.is_file():
|
|
_issue(issues, "SOURCE_NOT_FOUND", f"{base}.source.path", "source file does not exist")
|
|
else:
|
|
try:
|
|
source_bytes = resolved_source.read_bytes()
|
|
except OSError as exc:
|
|
_issue(issues, "SOURCE_READ_ERROR", f"{base}.source.path", str(exc))
|
|
|
|
if not isinstance(expected_sha256, str) or not HEX_SHA256.fullmatch(expected_sha256):
|
|
_issue(issues, "INVALID_SOURCE_SHA256", f"{base}.source.sha256", "must be 64 lowercase hexadecimal characters")
|
|
elif source_bytes is not None:
|
|
actual_source_sha256 = hashlib.sha256(source_bytes).hexdigest()
|
|
if actual_source_sha256 != expected_sha256:
|
|
_issue(
|
|
issues,
|
|
"SOURCE_HASH_MISMATCH",
|
|
f"{base}.source.sha256",
|
|
f"expected {expected_sha256}, observed {actual_source_sha256}",
|
|
)
|
|
|
|
valid_range = True
|
|
if not _is_int(line_start) or line_start < 1:
|
|
_issue(issues, "INVALID_LINE_RANGE", f"{base}.source.line_start", "must be an integer >= 1")
|
|
valid_range = False
|
|
if not _is_int(line_end) or (_is_int(line_start) and line_end < line_start):
|
|
_issue(issues, "INVALID_LINE_RANGE", f"{base}.source.line_end", "must be an integer >= line_start")
|
|
valid_range = False
|
|
if not isinstance(quote_value, str) or not quote_value:
|
|
_issue(issues, "INVALID_QUOTE", f"{base}.source.quote_utf8", "must be a non-empty UTF-8 string")
|
|
else:
|
|
quote_utf8 = quote_value
|
|
|
|
if source_bytes is not None:
|
|
try:
|
|
source_bytes.decode("utf-8", errors="strict")
|
|
except UnicodeDecodeError as exc:
|
|
_issue(issues, "SOURCE_NOT_UTF8", f"{base}.source.path", str(exc))
|
|
source_bytes = None
|
|
|
|
if source_bytes is not None and valid_range:
|
|
source_lines = source_bytes.splitlines(keepends=True)
|
|
if line_end > len(source_lines):
|
|
_issue(
|
|
issues,
|
|
"LINE_RANGE_OUT_OF_BOUNDS",
|
|
f"{base}.source.line_end",
|
|
f"source has {len(source_lines)} line(s)",
|
|
)
|
|
else:
|
|
selected_bytes = b"".join(source_lines[line_start - 1 : line_end])
|
|
if quote_utf8 is not None and quote_utf8.encode("utf-8") not in selected_bytes:
|
|
_issue(
|
|
issues,
|
|
"QUOTE_MISMATCH",
|
|
f"{base}.source.quote_utf8",
|
|
"exact quote bytes were not found inside the declared line range",
|
|
)
|
|
|
|
if execution is not None:
|
|
argv = execution.get("argv")
|
|
exit_code = execution.get("exit_code")
|
|
stdout_utf8 = execution.get("stdout_utf8")
|
|
stdout_sha256 = execution.get("stdout_sha256")
|
|
exact_match = execution.get("exact_match")
|
|
|
|
if (
|
|
not isinstance(argv, list)
|
|
or not argv
|
|
or any(not isinstance(arg, str) or not arg for arg in argv)
|
|
):
|
|
_issue(issues, "INVALID_ARGV", f"{base}.execution.argv", "must be a non-empty array of non-empty strings")
|
|
if not _is_int(exit_code):
|
|
_issue(issues, "INVALID_EXIT_CODE", f"{base}.execution.exit_code", "must be an integer")
|
|
elif exit_code != 0:
|
|
_issue(issues, "COMMAND_FAILED", f"{base}.execution.exit_code", "recorded verification command did not exit 0")
|
|
if not isinstance(stdout_utf8, str):
|
|
_issue(issues, "INVALID_STDOUT", f"{base}.execution.stdout_utf8", "must be a UTF-8 string")
|
|
if not isinstance(stdout_sha256, str) or not HEX_SHA256.fullmatch(stdout_sha256):
|
|
_issue(issues, "INVALID_STDOUT_SHA256", f"{base}.execution.stdout_sha256", "must be 64 lowercase hexadecimal characters")
|
|
elif isinstance(stdout_utf8, str):
|
|
actual_stdout_sha256 = hashlib.sha256(stdout_utf8.encode("utf-8")).hexdigest()
|
|
if actual_stdout_sha256 != stdout_sha256:
|
|
_issue(
|
|
issues,
|
|
"STDOUT_HASH_MISMATCH",
|
|
f"{base}.execution.stdout_sha256",
|
|
f"expected {stdout_sha256}, observed {actual_stdout_sha256}",
|
|
)
|
|
if not isinstance(exact_match, bool):
|
|
_issue(issues, "INVALID_EXACT_MATCH", f"{base}.execution.exact_match", "must be a boolean")
|
|
elif not exact_match:
|
|
_issue(issues, "EXACT_MATCH_FALSE", f"{base}.execution.exact_match", "proof cannot pass with exact_match=false")
|
|
if isinstance(stdout_utf8, str) and quote_utf8 is not None and stdout_utf8 != quote_utf8:
|
|
_issue(
|
|
issues,
|
|
"STDOUT_QUOTE_MISMATCH",
|
|
f"{base}.execution.stdout_utf8",
|
|
"recorded stdout is not byte-for-byte equal to quote_utf8",
|
|
)
|
|
|
|
|
|
def verify_manifest(
|
|
manifest: Any,
|
|
repo_root: Path,
|
|
allowed_profiles: set[str],
|
|
*,
|
|
run_root: Path | None = None,
|
|
) -> dict[str, Any]:
|
|
issues: list[dict[str, str]] = []
|
|
root = _require_object(manifest, "$", {"schema_version", "run", "proofs"}, {"verification"}, issues)
|
|
if root is None:
|
|
raise ManifestValidationError(issues)
|
|
|
|
if root.get("schema_version") != SCHEMA_VERSION:
|
|
_issue(issues, "SCHEMA_VERSION_MISMATCH", "$.schema_version", f"expected {SCHEMA_VERSION}")
|
|
|
|
run = _require_object(root.get("run"), "$.run", {"id", "profile"}, set(), issues)
|
|
if run is not None:
|
|
run_id = run.get("id")
|
|
profile = run.get("profile")
|
|
if not isinstance(run_id, str) or not SAFE_IDENTIFIER.fullmatch(run_id):
|
|
_issue(issues, "INVALID_RUN_ID", "$.run.id", "must match [A-Za-z0-9][A-Za-z0-9._-]*")
|
|
if not isinstance(profile, str) or profile not in allowed_profiles:
|
|
_issue(issues, "INVALID_PROFILE", "$.run.profile", f"must be one of {sorted(allowed_profiles)}")
|
|
|
|
proofs = root.get("proofs")
|
|
if not isinstance(proofs, list) or not proofs:
|
|
_issue(issues, "INVALID_PROOFS", "$.proofs", "must be a non-empty array")
|
|
else:
|
|
seen_finding_roles: set[tuple[str, str]] = set()
|
|
for index, proof in enumerate(proofs):
|
|
_validate_proof(proof, index, repo_root, run_root, seen_finding_roles, issues)
|
|
|
|
if issues:
|
|
raise ManifestValidationError(issues)
|
|
|
|
verified = dict(root)
|
|
verified["verification"] = {
|
|
"schema_version": RESULT_SCHEMA_VERSION,
|
|
"status": "PASS",
|
|
"proof_count": len(proofs),
|
|
"pass_count": len(proofs),
|
|
"fail_count": 0,
|
|
}
|
|
return verified
|
|
|
|
|
|
def manifest_bytes(document: Mapping[str, Any]) -> bytes:
|
|
"""Return the canonical bytes used for persisted manifest hashing."""
|
|
return (json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
|
|
|
|
def _failure_document(exc: ManifestValidationError) -> dict[str, Any]:
|
|
return {
|
|
"schema_version": RESULT_SCHEMA_VERSION,
|
|
"status": "FAIL",
|
|
"errors": exc.issues,
|
|
}
|
|
|
|
|
|
def _atomic_write_json(path: Path, document: Mapping[str, Any]) -> None:
|
|
# ``os.replace`` 는 대상 심링크를 *따라가지 않고* 그 자리를 실파일로 갈아치운다.
|
|
# cutover 이후 raw/·wiki/ 경로는 vault 정본을 가리키는 심링크이므로, 그런 경로를
|
|
# 그대로 받으면 링크가 끊겨 정본과 분리된다(split-brain, 그리고 조용하다). 심링크면
|
|
# 정본 경로에 write-through 해 링크를 보존한다.
|
|
target = Path(os.path.abspath(path.resolve())) if path.is_symlink() else path
|
|
parent = target.parent.resolve()
|
|
if not parent.is_dir():
|
|
raise OSError(f"output parent directory does not exist: {parent}")
|
|
temporary_name: str | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w",
|
|
encoding="utf-8",
|
|
dir=parent,
|
|
prefix=f".{target.name}.",
|
|
suffix=".tmp",
|
|
delete=False,
|
|
) as stream:
|
|
temporary_name = stream.name
|
|
stream.write(manifest_bytes(document).decode("utf-8"))
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary_name, target)
|
|
except Exception:
|
|
if temporary_name is not None:
|
|
try:
|
|
os.unlink(temporary_name)
|
|
except FileNotFoundError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def _emit(document: Mapping[str, Any]) -> None:
|
|
json.dump(document, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
|
|
sys.stdout.write("\n")
|
|
|
|
|
|
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("manifest", type=Path, help="input proof manifest JSON")
|
|
parser.add_argument("--repo-root", type=Path, default=DEFAULT_REPO_ROOT, help="source path root")
|
|
parser.add_argument("--run-root", type=Path, help="source root for source.namespace=run")
|
|
parser.add_argument("--profiles", type=Path, default=DEFAULT_PROFILES, help="execution profile JSON source")
|
|
parser.add_argument("--output", type=Path, help="atomically write the verified manifest; omitted by default")
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = _parse_args(argv)
|
|
try:
|
|
repo_root = args.repo_root.resolve(strict=True)
|
|
if not repo_root.is_dir():
|
|
raise OSError(f"repo root is not a directory: {repo_root}")
|
|
allowed_profiles = load_allowed_profiles(args.profiles.resolve(strict=True))
|
|
manifest = _load_json(args.manifest)
|
|
run_root = args.run_root.resolve(strict=True) if args.run_root is not None else None
|
|
if run_root is not None and not run_root.is_dir():
|
|
raise OSError(f"run root is not a directory: {run_root}")
|
|
verified = verify_manifest(manifest, repo_root, allowed_profiles, run_root=run_root)
|
|
if args.output is not None:
|
|
_atomic_write_json(args.output, verified)
|
|
_emit(verified)
|
|
return 0
|
|
except ManifestValidationError as exc:
|
|
_emit(_failure_document(exc))
|
|
return 2
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
failure = ManifestValidationError(
|
|
[{"code": "IO_OR_JSON_ERROR", "location": str(args.manifest), "message": str(exc)}]
|
|
)
|
|
_emit(_failure_document(failure))
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|