197 lines
7.9 KiB
Python
197 lines
7.9 KiB
Python
#!/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)(?<![A-Za-z0-9])AGE-SECRET-KEY-1[A-Z0-9]{20,}"),
|
|
re.compile(rb"(?i)PuTTY-User-Key-File"),
|
|
re.compile(
|
|
rb"(?im)^[ \t]*authorization[ \t]*:[ \t]*(?:basic|bearer|token)[ \t]+"
|
|
rb"(?=[A-Za-z0-9+/_=.:-]*[0-9+/=.-])[A-Za-z0-9+/_=.:-]{8,}"
|
|
),
|
|
)
|
|
|
|
|
|
def _read_pkt_line(stream: BinaryIO) -> 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
|