279 lines
16 KiB
Python
279 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Bounded, read-only acceptance of independently captured delegation evidence."""
|
|
from __future__ import annotations
|
|
import argparse, ast, hashlib, json, os, re, selectors, signal, stat, subprocess, time
|
|
from typing import NoReturn
|
|
|
|
class EvidenceError(ValueError):
|
|
pass
|
|
|
|
_HEX40=re.compile(r"^[0-9a-f]{40}$"); _HEX64=re.compile(r"^[0-9a-f]{64}$")
|
|
_CONTEXT="gitea-agent.delegation-evidence-context/v1"; _RESULT="gitea-agent.delegation-evidence-result/v1"
|
|
_MAX_JSON=1024*1024; _MAX_SOURCE=1024*1024; _MAX_LIST=512; _GIT="/usr/bin/git"
|
|
|
|
def _bad() -> NoReturn: raise EvidenceError("rejected evidence input")
|
|
def _keys(v, names):
|
|
if not isinstance(v,dict) or set(v)!=set(names): _bad()
|
|
def _nonempty(v): return isinstance(v,str) and bool(v.strip()) and "\x00" not in v
|
|
def _path(v):
|
|
if not isinstance(v,str) or not v or v.startswith("/") or "\\" in v or "\x00" in v or any(x in ("",".","..") for x in v.split("/")): _bad()
|
|
return v
|
|
def _digest(v, rx=_HEX64):
|
|
if not isinstance(v,str) or rx.fullmatch(v) is None: _bad()
|
|
return v
|
|
def _bounded_list(v):
|
|
if not isinstance(v,list) or len(v)>_MAX_LIST: _bad()
|
|
return v
|
|
def _abs(v):
|
|
if not isinstance(v,str) or not os.path.isabs(v) or v!=os.path.normpath(v) or "\x00" in v: _bad()
|
|
return v
|
|
|
|
def _directory_fd(path):
|
|
_abs(path); fd=os.open("/",os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC)
|
|
try:
|
|
for part in path.split("/")[1:]:
|
|
if part:
|
|
n=os.open(part,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=fd); os.close(fd); fd=n
|
|
if not stat.S_ISDIR(os.fstat(fd).st_mode): _bad()
|
|
return fd
|
|
except (OSError,EvidenceError):
|
|
try: os.close(fd)
|
|
except OSError: pass
|
|
_bad()
|
|
|
|
def _open_rel(root_fd,path):
|
|
cur=os.dup(root_fd)
|
|
try:
|
|
parts=path.split("/")
|
|
for part in parts[:-1]:
|
|
n=os.open(part,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW|os.O_CLOEXEC,dir_fd=cur); os.close(cur); cur=n
|
|
fd=os.open(parts[-1],os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC|os.O_NONBLOCK,dir_fd=cur)
|
|
if not stat.S_ISREG(os.fstat(fd).st_mode): os.close(fd); _bad()
|
|
return fd
|
|
except (OSError,EvidenceError): _bad()
|
|
finally:
|
|
try: os.close(cur)
|
|
except OSError: pass
|
|
|
|
def _read(root_fd,path,limit=_MAX_SOURCE):
|
|
fd=_open_rel(root_fd,path)
|
|
try: data=os.read(fd,limit+1)
|
|
finally: os.close(fd)
|
|
if len(data)>limit: _bad()
|
|
return data
|
|
|
|
def _json_file(path):
|
|
rawpath=_abs(path); parent=_directory_fd(os.path.dirname(rawpath))
|
|
try:
|
|
fd=_open_rel(parent,os.path.basename(rawpath))
|
|
try: raw=os.read(fd,_MAX_JSON+1)
|
|
finally: os.close(fd)
|
|
finally: os.close(parent)
|
|
if len(raw)>_MAX_JSON: _bad()
|
|
def pairs(items):
|
|
out={}
|
|
for k,v in items:
|
|
if k in out: _bad()
|
|
out[k]=v
|
|
return out
|
|
try: return json.loads(raw.decode(),object_pairs_hook=pairs,parse_constant=lambda _: _bad())
|
|
except EvidenceError: raise
|
|
except (UnicodeError,ValueError,TypeError,RecursionError): _bad()
|
|
|
|
def _canon(v): return hashlib.sha256(json.dumps(v,sort_keys=True,separators=(",",":"),ensure_ascii=True).encode()).hexdigest()
|
|
def _env(): return {"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(gitdir,args,fd):
|
|
cmd=[_GIT,"--git-dir",gitdir,"--literal-pathspecs",*args]; p=None
|
|
try:
|
|
p=subprocess.Popen(cmd,env=_env(),stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,start_new_session=True,pass_fds=(fd,)); out=bytearray(); deadline=time.monotonic()+5
|
|
with selectors.DefaultSelector() as s:
|
|
s.register(p.stdout,selectors.EVENT_READ)
|
|
while True:
|
|
left=deadline-time.monotonic()
|
|
if left<=0 or not s.select(left): raise subprocess.TimeoutExpired(cmd,5)
|
|
chunk=os.read(p.stdout.fileno(),min(65536,_MAX_SOURCE-len(out)+1))
|
|
if not chunk: break
|
|
out.extend(chunk)
|
|
if len(out)>_MAX_SOURCE: raise subprocess.SubprocessError()
|
|
return p.wait(timeout=max(.001,deadline-time.monotonic())),bytes(out)
|
|
except (OSError,subprocess.SubprocessError):
|
|
if p and p.returncode is None:
|
|
try: os.killpg(p.pid,signal.SIGKILL)
|
|
except ProcessLookupError: pass
|
|
p.wait()
|
|
return None
|
|
finally:
|
|
if p and p.stdout: p.stdout.close()
|
|
|
|
def _context(c):
|
|
_keys(c,("schema","source","stage","bundle_sha256","files","allowed_actions","obligations","executions"))
|
|
if c["schema"]!=_CONTEXT or not _nonempty(c["stage"]): _bad()
|
|
src=c["source"]
|
|
if not isinstance(src,dict) or src.get("kind") not in ("git_tree","captured_worktree"): _bad()
|
|
if src["kind"]=="git_tree":
|
|
_keys(src,("kind","revision"))
|
|
if not _HEX40.fullmatch(src["revision"]): _bad()
|
|
else:
|
|
_keys(src,("kind","base_revision"))
|
|
if not _HEX40.fullmatch(src["base_revision"]): _bad()
|
|
files=c["files"]
|
|
if not isinstance(files,dict) or len(files)>_MAX_LIST: _bad()
|
|
for p,h in files.items(): _path(p); _digest(h)
|
|
if c["bundle_sha256"]!=_canon(files): _bad()
|
|
actions=_bounded_list(c["allowed_actions"])
|
|
if len(set(actions))!=len(actions) or any(a not in ("static","execute") for a in actions): _bad()
|
|
obs=_bounded_list(c["obligations"]); ids=set()
|
|
for o in obs:
|
|
_keys(o,("id","kind"))
|
|
if not _nonempty(o["id"]) or o["id"] in ids or o["kind"] not in ("static","execution","red_green"): _bad()
|
|
ids.add(o["id"])
|
|
exs=_bounded_list(c["executions"]); eids=set()
|
|
for e in exs:
|
|
_keys(e,("id","test_id","bundle_sha256","test_file_sha256","argv","exit_code","failure_kind","evidence_path","evidence_sha256","origin"))
|
|
if not _nonempty(e["id"]) or e["id"] in eids or not isinstance(e["test_id"],str) or e["origin"] not in ("direct","inherited"): _bad()
|
|
eids.add(e["id"]); _digest(e["bundle_sha256"]); _digest(e["test_file_sha256"]); av=_bounded_list(e["argv"])
|
|
if not av or any(not _nonempty(x) for x in av): _bad()
|
|
if isinstance(e["exit_code"],bool) or not isinstance(e["exit_code"],int) or e["failure_kind"] not in ("none","assertion","approved_behavior","import","environment","timeout"): _bad()
|
|
_path(e["evidence_path"]); _digest(e["evidence_sha256"])
|
|
return src,files,actions,obs,exs
|
|
|
|
def _result(r,c):
|
|
_keys(r,("schema","source","stage","bundle_sha256","claims"))
|
|
if r["schema"]!=_RESULT or not _nonempty(r["stage"]): _bad()
|
|
source=r["source"]
|
|
if not isinstance(source,dict) or source.get("kind") not in ("git_tree","captured_worktree"): _bad()
|
|
field="revision" if source["kind"]=="git_tree" else "base_revision"
|
|
_keys(source,("kind",field)); _digest(source[field],_HEX40); _digest(r["bundle_sha256"])
|
|
claims=_bounded_list(r["claims"]); seen=set()
|
|
for q in claims:
|
|
_keys(q,("id","status","anchors","execution_ids"))
|
|
if not _nonempty(q["id"]) or q["id"] in seen or q["status"] not in ("supported","indeterminate"): _bad()
|
|
seen.add(q["id"]); _bounded_list(q["anchors"]); ids=_bounded_list(q["execution_ids"])
|
|
if len(set(ids))!=len(ids) or any(not _nonempty(i) for i in ids): _bad()
|
|
return claims
|
|
|
|
def _has_test(raw,symbol):
|
|
if not isinstance(symbol,str) or not symbol or any(not x.isidentifier() for x in symbol.split(".")): return False
|
|
try: tree=ast.parse(raw.decode())
|
|
except (SyntaxError,UnicodeError,RecursionError): return False
|
|
want=tuple(symbol.split(".")); found=[]
|
|
def walk(nodes,prefix=()):
|
|
for n in nodes:
|
|
if isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef,ast.ClassDef)):
|
|
cur=prefix+(n.name,)
|
|
if cur==want and isinstance(n,(ast.FunctionDef,ast.AsyncFunctionDef)) and n.name.startswith("test"): found.append(n)
|
|
if isinstance(n,ast.ClassDef): walk(n.body,cur)
|
|
walk(tree.body); return len(found)==1
|
|
|
|
def _git_files(src,files,git_fd,root_fd):
|
|
if git_fd is None: return False,{}
|
|
gd=f"/proc/self/fd/{git_fd}"; revision=src["revision"]
|
|
frozen={p:_read(root_fd,p) for p in files}
|
|
if any(len(frozen[p])>_MAX_SOURCE or hashlib.sha256(frozen[p]).hexdigest()!=h for p,h in files.items()): return False, frozen
|
|
typ=_git(gd,["cat-file","-t",revision],git_fd)
|
|
if typ is None or typ[0]!=0 or typ[1].strip()!=b"commit": return False, frozen
|
|
if not files: return True, frozen
|
|
tree=_git(gd,["ls-tree","-rz","-r","--full-tree",revision,"--",*sorted(files)],git_fd)
|
|
if tree is None or tree[0]!=0: return False, frozen
|
|
out={}
|
|
try:
|
|
rows=tree[1].split(b"\0")
|
|
for row in rows:
|
|
if not row: continue
|
|
head,pathb=row.split(b"\t",1); mode,kind,obj=head.decode().split(" "); path=pathb.decode()
|
|
if mode not in ("100644","100755") or kind!="blob" or path not in files: return False, frozen
|
|
blob=_git(gd,["show",f"{revision}:{path}"],git_fd)
|
|
if blob is None or blob[0]!=0 or len(blob[1])>_MAX_SOURCE: return False, frozen
|
|
if hashlib.sha1(b"blob "+str(len(blob[1])).encode()+b"\0"+blob[1]).hexdigest()!=obj: return False, frozen
|
|
if hashlib.sha256(blob[1]).hexdigest()!=files[path] or blob[1]!=frozen[path]: return False, frozen
|
|
out[path]=blob[1]
|
|
except (UnicodeError,ValueError):
|
|
return False, frozen
|
|
return set(out)==set(files), frozen
|
|
|
|
def _git_has_path(src,path,git_fd):
|
|
out=_git(f"/proc/self/fd/{git_fd}",["ls-tree","-rz","-r","--full-tree",src["revision"],"--",path],git_fd)
|
|
if out is None or out[0]!=0: return None
|
|
return bool(out[1].rstrip(b"\0"))
|
|
|
|
def verify_evidence(bundle_root,context,result,git_dir=None):
|
|
rootfd=_directory_fd(_abs(bundle_root))
|
|
gitfd=None
|
|
try:
|
|
src,files,actions,obs,exs=_context(context); claims=_result(result,context); cmap={q["id"]:q for q in claims}; omap={o["id"]:o for o in obs}
|
|
if set(cmap)!=set(omap): _bad()
|
|
if src["kind"]=="git_tree":
|
|
if git_dir is None: ok,raw=False,{}
|
|
else:
|
|
try: gitfd=_directory_fd(git_dir)
|
|
except EvidenceError: ok,raw=False,{}
|
|
else: ok,raw=_git_files(src,files,gitfd,rootfd)
|
|
else: raw={p:_read(rootfd,p) for p in files}; ok=all(hashlib.sha256(raw[p]).hexdigest()==h for p,h in files.items())
|
|
reasons=[]
|
|
for field in ("source","stage","bundle_sha256"):
|
|
if result[field]!=context[field]: reasons.append("result_"+field+"_mismatch")
|
|
if not ok: reasons.append("source_hash_mismatch")
|
|
emap={e["id"]:e for e in exs}; anchors_n=0
|
|
for o in obs:
|
|
q=cmap[o["id"]]; anchors=q["anchors"]; anchors_n+=len(anchors)
|
|
if q["status"]=="indeterminate": reasons.append("indeterminate_claim")
|
|
if o["kind"]=="static" and "static" not in actions: reasons.append("unauthorized_static")
|
|
if o["kind"] in ("execution","red_green") and "execute" not in actions and any(emap.get(i,{}).get("origin")=="direct" for i in q["execution_ids"]): reasons.append("unauthorized_execute")
|
|
if o["kind"]=="static" and not anchors: reasons.append("missing_static_anchor")
|
|
if o["kind"] in ("execution","red_green") and not q["execution_ids"]: reasons.append("missing_execution_record")
|
|
for a in anchors:
|
|
if not isinstance(a,dict) or "kind" not in a: _bad()
|
|
if a["kind"]=="lines":
|
|
_keys(a,("kind","path","start_line","end_line","text_sha256")); p=_path(a["path"]); _digest(a["text_sha256"])
|
|
if p not in files or any(isinstance(a[x],bool) or not isinstance(a[x],int) for x in ("start_line","end_line")) or a["start_line"]<=0 or a["end_line"]<a["start_line"]: _bad()
|
|
ls=raw[p].splitlines(keepends=True)
|
|
if a["end_line"]>len(ls): reasons.append("anchor_range_invalid")
|
|
elif hashlib.sha256(b"".join(ls[a["start_line"]-1:a["end_line"]])).hexdigest()!=a["text_sha256"]: reasons.append("anchor_hash_mismatch")
|
|
elif a["kind"]=="test":
|
|
_keys(a,("kind","path","symbol")); p=_path(a["path"])
|
|
if p not in files or not p.endswith(".py") or not _has_test(raw[p],a["symbol"]): reasons.append("test_anchor_missing")
|
|
elif a["kind"]=="absence":
|
|
_keys(a,("kind","path")); p=_path(a["path"])
|
|
if src["kind"]!="git_tree": reasons.append("absence_indeterminate")
|
|
else:
|
|
present=None if gitfd is None else _git_has_path(src,p,gitfd)
|
|
if p in files or present is True: reasons.append("absence_not_proven")
|
|
elif present is None: reasons.append("absence_indeterminate")
|
|
else: _bad()
|
|
for eid in q["execution_ids"]:
|
|
if eid not in emap: reasons.append("unknown_execution_id"); continue
|
|
e=emap[eid]
|
|
if e["origin"]=="direct" and "execute" not in actions: reasons.append("unauthorized_execute")
|
|
if e["bundle_sha256"]!=context["bundle_sha256"] and not (o["kind"]=="red_green" and e["exit_code"]!=0 and e["failure_kind"] in ("assertion","approved_behavior")): reasons.append("execution_scope_mismatch")
|
|
if hashlib.sha256(_read(rootfd,e["evidence_path"])).hexdigest()!=e["evidence_sha256"]: reasons.append("evidence_hash_mismatch")
|
|
if o["kind"]=="red_green":
|
|
tests=[a for a in anchors if isinstance(a,dict) and a.get("kind")=="test"]
|
|
if len(tests)!=1: reasons.append("red_green_test_anchor_required")
|
|
else:
|
|
test_id=tests[0]["path"]+"::"+tests[0]["symbol"]; red=False; green=False
|
|
for eid in q["execution_ids"]:
|
|
if eid not in emap: continue
|
|
e=emap[eid]
|
|
if e["test_id"]!=test_id or e["test_file_sha256"]!=files.get(tests[0]["path"]): reasons.append("red_green_test_scope_mismatch")
|
|
if e["exit_code"]!=0 and e["failure_kind"] in ("assertion","approved_behavior"): red=True
|
|
if e["exit_code"]==0 and e["failure_kind"] in ("none","approved_behavior") and e["bundle_sha256"]==context["bundle_sha256"]: green=True
|
|
if not red: reasons.append("red_green_missing_red")
|
|
if not green: reasons.append("red_green_missing_green")
|
|
counts={"files":len(files),"obligations":len(obs),"claims":len(claims),"anchors":anchors_n,"executions":len(exs)}
|
|
return {"status":"block" if reasons else "pass","reason_codes":sorted(set(reasons)),"counts":counts}
|
|
except EvidenceError: raise
|
|
except (OSError,TypeError,ValueError,KeyError,RecursionError): _bad()
|
|
finally:
|
|
if gitfd is not None: os.close(gitfd)
|
|
os.close(rootfd)
|
|
|
|
class _Parser(argparse.ArgumentParser):
|
|
def error(self,message): _bad()
|
|
def main(argv=None):
|
|
p=_Parser(add_help=False,allow_abbrev=False); p.add_argument("--bundle-root",required=True); p.add_argument("--context",required=True); p.add_argument("--result",required=True); p.add_argument("--git-dir")
|
|
try:
|
|
a=p.parse_args(argv); out=verify_evidence(a.bundle_root,_json_file(a.context),_json_file(a.result),a.git_dir); print(json.dumps(out,sort_keys=True,separators=(",",":"))); return 0 if out["status"]=="pass" else 1
|
|
except Exception: print('{"status":"block","reason_codes":["rejected_input"],"counts":{}}'); return 2
|
|
if __name__=="__main__": raise SystemExit(main())
|