[verified] Bind test inventories to exact Git sources
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
"""Read-only binding preflight for recorded test inventories."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import selectors
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
import sys
|
||||
from typing import NoReturn
|
||||
|
||||
|
||||
class BindingError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_SCHEMA = "gitea-agent.test-inventory-binding/v1"
|
||||
_MAX_JSON = 1024 * 1024
|
||||
_MAX_OUTPUT = 64 * 1024 * 1024
|
||||
_GIT_EXECUTABLE = "/usr/bin/git"
|
||||
|
||||
|
||||
def _reject(message="rejected inventory binding input") -> NoReturn:
|
||||
raise BindingError(message)
|
||||
|
||||
|
||||
def _keys(value, expected):
|
||||
if not isinstance(value, dict) or set(value) != set(expected):
|
||||
_reject()
|
||||
|
||||
|
||||
def _nonempty(value):
|
||||
return isinstance(value, str) and bool(value.strip()) and "\x00" not in value
|
||||
|
||||
|
||||
def _sha(value, pattern):
|
||||
return isinstance(value, str) and pattern.fullmatch(value) is not None
|
||||
|
||||
|
||||
def _safe_path(value, allow_empty=False):
|
||||
if not isinstance(value, str) or (not value and not allow_empty):
|
||||
_reject()
|
||||
if value.startswith("/") or "\\" in value or "\x00" in value:
|
||||
_reject()
|
||||
parts = value.split("/")
|
||||
if any(part in ("", ".", "..") for part in parts):
|
||||
_reject()
|
||||
return value
|
||||
|
||||
|
||||
def _safe_paths(values):
|
||||
if not isinstance(values, list) or len(set(values)) != len(values):
|
||||
_reject()
|
||||
return [_safe_path(v) for v in values]
|
||||
|
||||
|
||||
def _directory_fd(raw):
|
||||
if not isinstance(raw, str) or not os.path.isabs(raw) or raw != os.path.normpath(raw):
|
||||
_reject()
|
||||
if raw != "/" and any(p in ("", ".", "..") for p in raw.split("/")[1:]):
|
||||
_reject()
|
||||
fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||
try:
|
||||
for part in raw.split("/")[1:]:
|
||||
if not part:
|
||||
continue
|
||||
nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
|
||||
os.close(fd)
|
||||
fd = nxt
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
_reject()
|
||||
except (BindingError, OSError, ValueError):
|
||||
os.close(fd)
|
||||
_reject()
|
||||
return fd
|
||||
|
||||
|
||||
def _canonical_git_dir(raw):
|
||||
return _directory_fd(raw)
|
||||
|
||||
|
||||
def _env():
|
||||
return {
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
"HOME": "/nonexistent",
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_CONFIG_GLOBAL": os.devnull,
|
||||
"GIT_CONFIG_SYSTEM": os.devnull,
|
||||
"GIT_NO_LAZY_FETCH": "1",
|
||||
"GIT_NO_REPLACE_OBJECTS": "1",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
}
|
||||
|
||||
|
||||
def _git(git_dir, args, check=False, git_fd=None):
|
||||
command = [_GIT_EXECUTABLE, "--git-dir", git_dir, *args]
|
||||
process = None
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
process = subprocess.Popen(
|
||||
command, env=_env(), stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
pass_fds=() if git_fd is None else (git_fd,),
|
||||
)
|
||||
if process.stdout is None:
|
||||
raise OSError("Git output pipe unavailable")
|
||||
output = bytearray()
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0 or not selector.select(remaining):
|
||||
raise subprocess.TimeoutExpired(command, 5)
|
||||
chunk = os.read(process.stdout.fileno(), min(65536, _MAX_OUTPUT - len(output) + 1))
|
||||
if not chunk:
|
||||
break
|
||||
output.extend(chunk)
|
||||
if len(output) > _MAX_OUTPUT:
|
||||
raise subprocess.SubprocessError("bounded Git output exceeded")
|
||||
returncode = process.wait(timeout=max(0.001, deadline - time.monotonic()))
|
||||
result = subprocess.CompletedProcess(command, returncode, bytes(output))
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
if check:
|
||||
_reject()
|
||||
return None
|
||||
finally:
|
||||
if process is not None:
|
||||
if process.returncode is None:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
process.wait()
|
||||
if process.stdout is not None:
|
||||
process.stdout.close()
|
||||
if check and result.returncode != 0:
|
||||
_reject()
|
||||
return result
|
||||
|
||||
|
||||
def _json_file(path):
|
||||
if not isinstance(path, str) or not os.path.isabs(path) or path != os.path.normpath(path):
|
||||
_reject()
|
||||
try:
|
||||
parent = _directory_fd(os.path.dirname(path))
|
||||
try:
|
||||
fd = os.open(os.path.basename(path), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
|
||||
finally:
|
||||
os.close(parent)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
_reject()
|
||||
raw = os.read(fd, _MAX_JSON + 1)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except BindingError:
|
||||
raise
|
||||
except (OSError, ValueError):
|
||||
_reject()
|
||||
if len(raw) > _MAX_JSON:
|
||||
_reject()
|
||||
|
||||
def pairs(items):
|
||||
out = {}
|
||||
for key, value in items:
|
||||
if key in out:
|
||||
_reject()
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"), object_pairs_hook=pairs,
|
||||
parse_constant=lambda _: _reject())
|
||||
except BindingError:
|
||||
raise
|
||||
except (UnicodeError, ValueError, TypeError, RecursionError):
|
||||
_reject()
|
||||
|
||||
|
||||
def _parse(data):
|
||||
_keys(data, ("schema", "source_revision", "stage", "ids", "file_sha256", "collection"))
|
||||
if data["schema"] != _SCHEMA or not _sha(data["source_revision"], _HEX40) or not _nonempty(data["stage"]):
|
||||
_reject()
|
||||
ids = data["ids"]
|
||||
if not isinstance(ids, list) or len(set(ids)) != len(ids):
|
||||
_reject()
|
||||
id_paths = []
|
||||
for item in ids:
|
||||
if not _nonempty(item) or "::" not in item:
|
||||
_reject()
|
||||
path, symbol = item.split("::", 1)
|
||||
_safe_path(path)
|
||||
if not _nonempty(symbol):
|
||||
_reject()
|
||||
id_paths.append(path)
|
||||
files = data["file_sha256"]
|
||||
if not isinstance(files, dict) or set(files) != set(id_paths):
|
||||
_reject()
|
||||
for path, digest in files.items():
|
||||
_safe_path(path)
|
||||
if not _sha(digest, _HEX64):
|
||||
_reject()
|
||||
c = data["collection"]
|
||||
_keys(c, ("source_revision", "stage", "argv", "scope_paths", "scope_sha256", "evidence"))
|
||||
if c["source_revision"] != data["source_revision"] or c["stage"] != data["stage"]:
|
||||
_reject()
|
||||
if not isinstance(c["argv"], list) or not c["argv"] or any(not _nonempty(x) for x in c["argv"]):
|
||||
_reject()
|
||||
# This is recorded argv, not a shell command; it is never executed here.
|
||||
scope = _safe_paths(c["scope_paths"])
|
||||
if not _sha(c["scope_sha256"], _HEX64) or not _nonempty(c["evidence"]):
|
||||
_reject()
|
||||
if not set(id_paths).issubset(scope):
|
||||
_reject()
|
||||
return data, ids, id_paths, files, c, scope
|
||||
|
||||
|
||||
def _tree(git_dir, revision, paths, git_fd=None):
|
||||
if not paths:
|
||||
return {}
|
||||
p = _git(git_dir, ["ls-tree", "-rz", "-r", "--full-tree", revision, "--", *paths], git_fd=git_fd)
|
||||
if p is None or p.returncode != 0:
|
||||
return None
|
||||
found = {}
|
||||
for record in p.stdout.split(b"\0"):
|
||||
if not record:
|
||||
continue
|
||||
try:
|
||||
header, pathb = record.split(b"\t", 1)
|
||||
mode, kind, obj = header.decode("ascii").split(" ")
|
||||
path = pathb.decode("utf-8")
|
||||
except (ValueError, UnicodeError):
|
||||
_reject()
|
||||
if path in paths:
|
||||
found[path] = (mode, kind, obj)
|
||||
return found
|
||||
|
||||
|
||||
def _blob(git_dir, revision, path, git_fd=None):
|
||||
p = _git(git_dir, ["show", f"{revision}:{path}"], git_fd=git_fd)
|
||||
if p is None or p.returncode != 0:
|
||||
return None
|
||||
return p.stdout
|
||||
|
||||
|
||||
def _result(status, reasons, counts):
|
||||
return {"status": status, "reason_codes": sorted(set(reasons)), "counts": counts}
|
||||
|
||||
|
||||
def verify_inventory(git_dir, data, expected_revision, expected_stage):
|
||||
git_fd = _canonical_git_dir(git_dir)
|
||||
try:
|
||||
if not _sha(expected_revision, _HEX40) or not _nonempty(expected_stage):
|
||||
_reject()
|
||||
data, ids, id_paths, files, collection, scope = _parse(data)
|
||||
counts = {"ids": len(ids), "files": len(files), "scope_paths": len(scope)}
|
||||
reasons = []
|
||||
if data["source_revision"] != expected_revision:
|
||||
reasons.append("source_revision_mismatch")
|
||||
if data["stage"] != expected_stage:
|
||||
reasons.append("stage_mismatch")
|
||||
if reasons:
|
||||
return _result("block", reasons, counts)
|
||||
|
||||
# Validate repository kind without consulting a work tree or refs.
|
||||
pinned_git_dir = f"/proc/self/fd/{git_fd}"
|
||||
bare = _git(pinned_git_dir, ["rev-parse", "--is-bare-repository"], git_fd=git_fd)
|
||||
if bare is None or bare.returncode != 0 or bare.stdout.strip() not in (b"true", b"false"):
|
||||
return _result("block", ["git_directory_unavailable"], counts)
|
||||
commit = _git(pinned_git_dir, ["cat-file", "-t", expected_revision], git_fd=git_fd)
|
||||
if commit is None or commit.returncode != 0 or commit.stdout.strip() != b"commit":
|
||||
return _result("block", ["revision_not_found"], counts)
|
||||
tree = _tree(pinned_git_dir, expected_revision, sorted(set(scope)), git_fd=git_fd)
|
||||
if tree is None:
|
||||
return _result("block", ["scope_unavailable"], counts)
|
||||
digest_map = {}
|
||||
for path in scope:
|
||||
entry = tree.get(path)
|
||||
if entry is None:
|
||||
reasons.append("scope_file_missing")
|
||||
continue
|
||||
mode, kind, obj = entry
|
||||
if mode == "120000" or kind != "blob" or mode not in ("100644", "100755"):
|
||||
reasons.append("scope_file_not_regular")
|
||||
continue
|
||||
raw = _blob(pinned_git_dir, expected_revision, path, git_fd=git_fd)
|
||||
if raw is None:
|
||||
reasons.append("scope_blob_unavailable")
|
||||
continue
|
||||
git_blob = hashlib.sha1(b"blob " + str(len(raw)).encode() + bytes([0]) + raw).hexdigest()
|
||||
if git_blob != obj:
|
||||
reasons.append("scope_blob_identity_mismatch")
|
||||
digest_map[path] = hashlib.sha256(raw).hexdigest()
|
||||
if path in files and digest_map[path] != files[path]:
|
||||
reasons.append("recorded_file_hash_mismatch")
|
||||
digest = hashlib.sha256(json.dumps(digest_map, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
if digest != collection["scope_sha256"]:
|
||||
reasons.append("scope_digest_mismatch")
|
||||
return _result("block" if reasons else "pass", reasons, counts)
|
||||
except BindingError:
|
||||
raise
|
||||
except (OSError, TypeError, ValueError, KeyError, RecursionError):
|
||||
_reject()
|
||||
finally:
|
||||
os.close(git_fd)
|
||||
|
||||
|
||||
class _Parser(argparse.ArgumentParser):
|
||||
def error(self, message) -> NoReturn:
|
||||
# argparse normally echoes rejected argument values to stderr.
|
||||
_reject()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = _Parser(add_help=True, allow_abbrev=False)
|
||||
parser.add_argument("--git-dir", required=True)
|
||||
parser.add_argument("--input", required=True)
|
||||
parser.add_argument("--expected-revision", required=True)
|
||||
parser.add_argument("--expected-stage", required=True)
|
||||
try:
|
||||
args = parser.parse_args(argv)
|
||||
result = verify_inventory(args.git_dir, _json_file(args.input), args.expected_revision, args.expected_stage)
|
||||
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
||||
return 0 if result["status"] == "pass" else 1
|
||||
except (BindingError, OSError, ValueError, TypeError, KeyError, RecursionError):
|
||||
print(json.dumps({"status": "block", "reason_codes": ["rejected_input"], "counts": {}}, separators=(",", ":")))
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user