340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""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())
|