#!/usr/bin/env python3 """Validate Git receive-pack requests and scan pushed object content. Ref commands may only create new namespaced feature branches. Every pushed object is then inflated in quarantine and its decompressed payload scanned, so credentials cannot ride through the broker inside compressed pack data. The screening is a fail-closed accident barrier for credential-shaped content: exact runtime-token forms, private keys, SSH key material, and known provider token formats. Entropy heuristics are deliberately excluded so SOPS ciphertext, pinned digests, and lock files remain pushable; a short secret disguised as prose is not reliably detectable and must never be supplied by callers. """ from __future__ import annotations import io import hashlib import re from typing import BinaryIO import git_pack_objects from gitea_api_policy import ( STANDALONE_SECRET_PATTERNS, PolicyError, _validate_ref, ) ZERO_SHA = b"0" * 40 MAX_PUSH_COMMANDS = 16 SCAN_CHUNK = 256 * 1024 SCAN_CARRY = 8 * 1024 FEATURE_REF_RE = re.compile( r"refs/heads/(?:(?:feature|fix|hermes|handoff)/[A-Za-z0-9][A-Za-z0-9._/-]{0,190})\Z" ) GRANTED_REF_RE = re.compile( r"refs/heads/(?:(?:feature|fix|chore|docs|test|refactor|wt|review|hermes|hermes-repair|handoff)/[A-Za-z0-9][A-Za-z0-9._/-]{0,190})\Z" ) PKT_HEADER_RE = re.compile(rb"[0-9a-f]{4}\Z") CONTENT_SECRET_PATTERNS = tuple( re.compile(pattern.pattern.encode("ascii"), re.IGNORECASE) for pattern in STANDALONE_SECRET_PATTERNS ) + ( re.compile(rb"(?i)(? bytes | None: """Read one pkt-line; None marks the flush packet before the pack.""" header = stream.read(4) if not header: raise PolicyError("Git receive-pack request omitted its command terminator") if not PKT_HEADER_RE.fullmatch(header): raise PolicyError("Git receive-pack command framing is invalid") size = int(header, 16) if size == 0: return None if size < 4: raise PolicyError("Git receive-pack command framing is invalid") value = stream.read(size - 4) if len(value) != size - 4: raise PolicyError("Git receive-pack command framing is invalid") return value def _validate_command( line: bytes, token: str, forbidden: tuple[bytes, ...], *, granted: bool ) -> tuple[str, str, str]: """Parse one namespaced branch command without trusting Git's client.""" if any(form in line for form in forbidden): raise PolicyError("Git request contains runtime credential material") command = line.rstrip(b"\n").split(b"\x00", 1)[0] fields = command.split(b" ") if len(fields) != 3 or not all( re.fullmatch(rb"[0-9a-f]{40}", item) for item in fields[:2] ): raise PolicyError("Git receive-pack ref command is invalid") old_sha, new_sha, raw_ref = fields if new_sha == ZERO_SHA: raise PolicyError("Git broker permits only new feature-branch creation or granted updates") try: ref = raw_ref.decode("ascii") except UnicodeDecodeError as exc: raise PolicyError("Git ref must be canonical ASCII") from exc allowed = GRANTED_REF_RE if granted else FEATURE_REF_RE if not allowed.fullmatch(ref): raise PolicyError("Git push is limited to namespaced feature branches") _validate_ref(ref.removeprefix("refs/heads/"), "head", forbidden=(token,)) return old_sha.decode("ascii"), new_sha.decode("ascii"), ref.removeprefix("refs/heads/") def _scan_payload(payload: BinaryIO, forbidden: tuple[bytes, ...]) -> None: """Scan one decompressed object payload across chunk boundaries.""" carry = b"" while True: chunk = payload.read(SCAN_CHUNK) if not chunk: return window = carry + chunk if any(form in window for form in forbidden): raise PolicyError("Git push contains runtime credential material") if any(pattern.search(window) for pattern in CONTENT_SECRET_PATTERNS): raise PolicyError("Git push contains credential-shaped content") carry = window[-SCAN_CARRY:] def _scan_pack(stream: BinaryIO, forbidden: tuple[bytes, ...]) -> dict[str, tuple[str, ...]]: objects = git_pack_objects.unpack_objects(stream) try: if stream.read(1): raise PolicyError("Git request has bytes after its pack") commits: dict[str, tuple[str, ...]] = {} for type_code, payload in objects: if type_code == 1: raw = payload.read() digest = hashlib.sha1(b"commit " + str(len(raw)).encode("ascii") + b"\0" + raw).hexdigest() headers = raw.split(b"\n\n", 1)[0].splitlines() parents = tuple(line[7:].decode("ascii") for line in headers if line.startswith(b"parent ")) if not all(re.fullmatch(r"[0-9a-f]{40}", parent) for parent in parents): raise PolicyError("Git commit parent is invalid") commits[digest] = parents payload.seek(0) _scan_payload(payload, forbidden) return commits finally: for _type_code, payload in objects: payload.close() def _proves_descends(new_head: str, expected_old: str, commits: dict[str, tuple[str, ...]]) -> bool: """Walk quarantined commit parents to prove a non-forced update. The old head is a trusted stop anchor and need not be present in the pack. Missing parent objects cannot establish ancestry and therefore fail closed. """ if expected_old == ZERO_SHA.decode("ascii"): return True pending, seen = [new_head], set() while pending and len(seen) < 1024: current = pending.pop() if current == expected_old: return True if current in seen: continue seen.add(current) parents = commits.get(current) if parents is None: continue pending.extend(parents) return False def validate_receive_pack( body: bytes | BinaryIO, token: str, forbidden: tuple[bytes, ...], *, expected: tuple[str, str, str] | None = None, ) -> tuple[str, str, str]: """Validate one whole receive-pack request: commands, pack, and content.""" stream = io.BytesIO(body) if isinstance(body, bytes) else body stream.seek(0) try: commands: list[tuple[str, str, str]] = [] while True: line = _read_pkt_line(stream) if line is None: break commands.append(_validate_command(line, token, forbidden, granted=expected is not None)) if len(commands) > MAX_PUSH_COMMANDS: raise PolicyError("Git push contains too many ref commands") if not commands: raise PolicyError("Git receive-pack request has no ref command") if len(commands) != 1: raise PolicyError("Git task push must update exactly one branch") command = commands[0] if expected is None: if command[0] != ZERO_SHA.decode("ascii"): raise PolicyError("Git broker permits only new feature-branch creation") elif command != expected: raise PolicyError("Git command does not match its task grant") commits = _scan_pack(stream, forbidden) if expected is not None and not _proves_descends(command[1], command[0], commits): raise PolicyError("Git update does not prove fast-forward ancestry") return command finally: stream.seek(0) def scan_loose_object(value: bytes, forbidden: tuple[bytes, ...]) -> int: """Scan one quarantined loose object and return its parsed type code.""" type_code, payload = git_pack_objects.parse_loose_object(value) _scan_payload(io.BytesIO(payload), forbidden) return type_code