From 6e14e7fe6345206f2df88cf5f1848d4069e05afb Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 16:47:12 +0100 Subject: [PATCH 01/23] feat(git): Hold a sandbox's git access as rules in one grant A sandbox holds one grant at a time, and a grant names one operation and a list of repositories, so a pod that both fetches and pushes can't get both, and nothing limits either to branches. GrantV2 holds a list of rules, each a repository, an operation and the branches it may touch. A fetch rule sets any_object instead of branches to read everything, so a rule that leaves its branches out is refused rather than read as a full read. Grant, version 1, still signs and verifies, and its pushes are relayed unread, so signers can switch once verifiers read both. GitServer reads a push's ref updates before the pack and refuses the whole push if one updates an unlisted branch, deletes a branch, updates another ref or carries push options. git reads the answer only after sending the whole push, so the server reads the pack to the end, then answers with a status report that git shows as rejected refs with their reasons. Fetch rules that list branches are refused until the server can keep a fetch to them. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- bin/git-server | 86 +++++++--- src/scm/errors.py | 12 ++ src/scm/git/grant.py | 20 ++- src/scm/git/push.py | 224 +++++++++++++++++++++++++ src/scm/git/relay.py | 40 ++++- src/scm/git/server.py | 73 ++++++++- src/scm/git/types.py | 157 ++++++++++++++++-- tests/unit/git_builders.py | 45 ++++- tests/unit/test_git_grant.py | 258 ++++++++++++++++++++++++++--- tests/unit/test_git_push.py | 298 ++++++++++++++++++++++++++++++++++ tests/unit/test_git_relay.py | 97 ++++++++--- tests/unit/test_git_server.py | 172 ++++++++++++++++++-- 13 files changed, 1372 insertions(+), 112 deletions(-) create mode 100644 src/scm/git/push.py create mode 100644 tests/unit/test_git_push.py diff --git a/README.md b/README.md index bc5cc02d..f73d5cbb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The platform exposes four subsystems: - **Actions** — outbound SCM operations initiated by Sentry code. The `SourceCodeManager` class provides 70+ methods covering comments, reactions, pull requests, branches, git objects, reviews, and check runs. With more actions planned to be added as we port more use cases. - **Actions RPC** - outbound SCM commands exposed over the network. The `SourceCodeManager` is fully available over the network. This enables usage of the SCM for services outside the monolith. -- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant naming the organization, operation and repositories with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it. +- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. A `GrantV2` names the organization and a list of rules, each a repository, an operation (`FetchRule` or `PushRule`) and the branches it may touch, or `any_object` for a fetch that may read any object. `Grant`, the deprecated version 1, names one operation and its repositories. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. For a push under a push rule it first reads the ref updates, and answers a push that updates any other ref, deletes a branch or carries push options with a status report git shows as rejected refs. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it. - **Event Stream** — inbound webhook processing. SCM service-providers push events which are deserialized into typed, provider-neutral dataclasses (`CheckRunEvent`, `CommentEvent`, `PullRequestEvent`) which are then dispatched to registered listener functions. ### Why diff --git a/bin/git-server b/bin/git-server index 6096ef24..f5ffdb25 100755 --- a/bin/git-server +++ b/bin/git-server @@ -50,14 +50,15 @@ Configuration: GITLAB_BASE_URL Instance URL (default: https://gitlab.com) Serving: - The server prints a fetch grant and a push grant for the repository, valid - for an hour, with commands that pass them to git: + The server prints a grant to fetch any object of the repository and one to + push its branch my-branch, valid for an hour, with commands that pass them + to git: git -c http.extraHeader="X-Scm-Git-Grant: $FETCH_GRANT" \\ clone http://127.0.0.1:8080/OWNER/REPO.git The URL path is the repository's path, which the server uses as its external - id, so a grant names the repository as (provider, path). + id, so a grant's rule names the repository as (provider, path). Checks: --check starts the server on a free port and runs git against it: @@ -68,16 +69,18 @@ Checks: for credentials - a repository the token cannot see fails with the provider's own message, which the relay passes on - With --push it also commits a 256 KiB file on a new branch, pushes it with - a push grant, sending the pack chunked as git does for a large push, and - then deletes the branch. This writes to the repository, so point it at a - test repository. + With --push it also commits a 256 KiB file, pushes it with a push grant for + a new branch, sending the pack chunked as git does for a large push, and + checks that the grant can neither push another branch nor delete its own. + It then deletes the branch with the provider token. This writes to the + repository, so point it at a test repository. git runs without the user's or the system's git config, so credential helpers and URL rewrites cannot hide a failure. """ import argparse +import base64 import importlib.machinery import importlib.util import logging @@ -100,10 +103,10 @@ from scm.errors import GitGrantKeyError from scm.git.grant import generate_private_key, public_key_of, sign_grant from scm.git.relay import GitRelay from scm.git.server import GitServer -from scm.git.types import GRANT_HEADER, GetToken, GitOperation, Grant, Readable +from scm.git.types import GRANT_HEADER, FetchRule, GetToken, GrantV2, PushRule, Readable, Rule from scm.providers.github.provider import GitHubProvider from scm.providers.gitlab.provider import GitLabProvider -from scm.types import Provider, ProviderName, Repository, RepositoryId +from scm.types import GetGitRemoteProtocol, Provider, ProviderName, Repository, RepositoryId logger = logging.getLogger(__name__) @@ -278,17 +281,20 @@ def make_git_server( ) -def grant_for(private_key: Secret, operation: GitOperation, repository_id: RepositoryId) -> str: - grant = Grant( +def grant_for(private_key: Secret, rule: Rule) -> str: + grant = GrantV2( organization_id=ORGANIZATION_ID, referrer=REFERRER, - operation=operation, - repositories=[repository_id], + rules=[rule], expires_at=int(time.time()) + GRANT_TTL, ) return sign_grant(private_key, grant) +def fetch_grant_for(private_key: Secret, repository_id: RepositoryId) -> str: + return grant_for(private_key, FetchRule(repository=repository_id, any_object=True)) + + class GitHandler(BaseHTTPRequestHandler): """Serves /.git/, as Sentry's endpoint serves a repository's git routes. @@ -408,8 +414,8 @@ def serve(backend: Backend, args: argparse.Namespace) -> None: (args.host, args.port), make_git_server(backend, args.public_key, args.ca_bundle), backend.provider_name ) url = f"{server.url}/{backend.repository['name']}.git" - fetch_grant = grant_for(args.private_key, "fetch", backend.repository_id) - push_grant = grant_for(args.private_key, "push", backend.repository_id) + fetch_grant = fetch_grant_for(args.private_key, backend.repository_id) + push_grant = grant_for(args.private_key, PushRule(repository=backend.repository_id, branches=["my-branch"])) logger.info( f"Serving {backend.provider_name} repository {backend.repository['name']} at {url}\n\n" f"Grants, valid for {GRANT_TTL // 60} minutes:\n" @@ -474,11 +480,17 @@ class Checks: # git shows the text of a refused first request as "remote: ". return self.check(name, result, result.returncode != 0 and f"remote: {message}" in result.stderr) + def expect_rejection(self, name: str, result: subprocess.CompletedProcess[str], reason: str) -> bool: + # git shows a ref the server's status report refuses as "! [remote rejected] ()". + rejected = "[remote rejected]" in result.stderr and f"({reason})" in result.stderr + return self.check(name, result, result.returncode != 0 and rejected) + def run(self) -> int: repository_id = self.backend.repository_id other_repository_id = (self.backend.provider_name, f"{self.backend.repository['name']}-other") - fetch_grant = grant_for(self.args.private_key, "fetch", repository_id) - push_grant = grant_for(self.args.private_key, "push", repository_id) + branch = f"scm-git-server-check/{int(time.time())}" + fetch_grant = fetch_grant_for(self.args.private_key, repository_id) + push_grant = grant_for(self.args.private_key, PushRule(repository=repository_id, branches=[branch])) server = self.start() url = f"{server.url}/{self.backend.repository['name']}.git" @@ -490,16 +502,15 @@ class Checks: cloned = self.expect_success( "clone with a fetch grant", self.git(fetch_grant, "clone", "--depth=1", url, str(clone)) ) - branch = f"scm-git-server-check/{int(time.time())}" self.expect_refusal( "push with a fetch grant is refused", # Refused before the provider sees it, so any local repository will do. self.git(fetch_grant, "push", url, f"HEAD:refs/heads/{branch}", cwd=self.local_repository()), - "The git grant allows fetch, not push.", + "The git grant allows fetch on this repository, not push.", ) self.expect_refusal( "a grant for another repository is refused", - self.git(grant_for(self.args.private_key, "fetch", other_repository_id), "ls-remote", url), + self.git(fetch_grant_for(self.args.private_key, other_repository_id), "ls-remote", url), "The git grant does not cover this repository.", ) server.shutdown() @@ -519,7 +530,7 @@ class Checks: missing = self.backend.repository_at(f"{self.backend.repository['name']}-does-not-exist") server = self.start(repository=missing) result = self.git( - grant_for(self.args.private_key, "fetch", repository_id_of(self.backend.provider_name, missing)), + fetch_grant_for(self.args.private_key, repository_id_of(self.backend.provider_name, missing)), "ls-remote", f"{server.url}/{missing['name']}.git", ) @@ -557,17 +568,46 @@ class Checks: (clone / "scm-git-server-check.bin").write_bytes(secrets.token_bytes(256 * 1024)) self.git(push_grant, "add", "scm-git-server-check.bin", cwd=clone).check_returncode() self.git(push_grant, "commit", "-m", "scm-platform git-server check", cwd=clone).check_returncode() + self.expect_rejection( + "chunked push to a branch the grant does not list is rejected", + self.git( + push_grant, "-c", "http.postBuffer=65536", "push", url, f"HEAD:refs/heads/{branch}-unlisted", cwd=clone + ), + "the git grant does not list this branch", + ) pushed = self.expect_success( "chunked push with a push grant", self.git(push_grant, "-c", "http.postBuffer=65536", "push", url, f"HEAD:refs/heads/{branch}", cwd=clone), ) if pushed: - self.expect_success( - f"delete the pushed branch {branch}", + self.expect_rejection( + "deleting the pushed branch with the push grant is rejected", self.git(push_grant, "push", url, f":refs/heads/{branch}", cwd=clone), + "the git grant does not allow deleting a branch", + ) + self.expect_success( + f"delete the pushed branch {branch} with the token", self.delete_directly(branch, clone) ) server.shutdown() + def delete_directly(self, branch: str, clone: Path) -> subprocess.CompletedProcess[str]: + """Delete ``branch`` on the provider with the token Sentry would use, since a push grant cannot.""" + provider = self.backend.fetch_provider(ORGANIZATION_ID, self.backend.repository) + assert isinstance(provider, GetGitRemoteProtocol) + remote = provider.get_git_remote() + token = self.backend.get_token(self.backend.repository, "push") + assert token is not None + credentials = base64.b64encode(f"{remote['basic_auth_username']}:{token.reveal()}".encode()).decode() + # Through the environment rather than the command line, which other processes can read. + env = { + **self.env, + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.extraHeader", + "GIT_CONFIG_VALUE_0": f"Authorization: Basic {credentials}", + } + command = ["git", "-c", "credential.helper=", "push", remote["url"], f":refs/heads/{branch}"] + return subprocess.run(command, cwd=clone, env=env, capture_output=True, text=True, timeout=300) + def _indent(text: str) -> str: return "".join(f" {line}\n" for line in text.splitlines()) diff --git a/src/scm/errors.py b/src/scm/errors.py index bfd674a9..d206baa8 100644 --- a/src/scm/errors.py +++ b/src/scm/errors.py @@ -5,9 +5,11 @@ "git_invalid_grant", "git_operation_not_granted", "git_provider_not_supported", + "git_push_not_granted", "git_remote_not_https", "git_remote_refused", "git_repository_not_granted", + "git_request_malformed", "git_route_not_supported", "git_token_rejected", "git_token_unavailable", @@ -53,9 +55,11 @@ "git_invalid_grant": "The git grant was missing, malformed or forged.", "git_operation_not_granted": "The git grant does not allow this operation.", "git_provider_not_supported": "The repository's provider has no git remote.", + "git_push_not_granted": "The git grant does not allow the push's ref updates.", "git_remote_not_https": "The repository's git remote is not an https URL.", "git_remote_refused": "The repository's git remote is at an address Sentry does not connect to.", "git_repository_not_granted": "The git grant does not cover this repository.", + "git_request_malformed": "The request body is not a git request the git endpoint can read.", "git_route_not_supported": "The request is not a git fetch or push.", "git_token_rejected": "The provider rejected Sentry's credentials for the repository.", "git_token_unavailable": "No provider token could be obtained for the repository.", @@ -178,6 +182,10 @@ class GitProviderNotSupported(GitSCMCodedError): code = "git_provider_not_supported" +class GitPushNotGranted(GitSCMCodedError): + code = "git_push_not_granted" + + class GitRemoteNotHttps(GitSCMCodedError): code = "git_remote_not_https" @@ -190,6 +198,10 @@ class GitRepositoryNotGranted(GitSCMCodedError): code = "git_repository_not_granted" +class GitRequestMalformed(GitSCMCodedError): + code = "git_request_malformed" + + class GitRouteNotSupported(GitSCMCodedError): code = "git_route_not_supported" diff --git a/src/scm/git/grant.py b/src/scm/git/grant.py index 7763458b..318783a5 100644 --- a/src/scm/git/grant.py +++ b/src/scm/git/grant.py @@ -37,18 +37,18 @@ ) from scm.errors import GitGrantExpired, GitGrantKeyError, GitInvalidGrant -from scm.git.types import GRANT_MAX_LENGTH, Grant +from scm.git.types import GRANT_MAX_LENGTH, Grant, GrantV2 from scm.secret import Secret GRANT_AUDIENCE = "scm-git-grant" # The only algorithm the verifier accepts, with parsed Ed25519 keys. A token that names HS256 and uses a public key, # which is no secret, as its HMAC key is refused, as is one that names ``none``. _ALGORITHM = "EdDSA" -# Claims the JWT layer owns. Every other claim must be a Grant field. +# Claims the JWT layer owns. Every other claim must be a field of the grant. _JWT_CLAIMS = frozenset({"aud", "exp", "iat"}) -def sign_grant(private_key: Secret, grant: Grant, *, now: float | None = None) -> str: +def sign_grant(private_key: Secret, grant: Grant | GrantV2, *, now: float | None = None) -> str: """Sign ``grant`` with ``private_key``, an Ed25519 private key in PEM. The ``kid`` header is derived from the key, so a signer needs no key id setting next to it. Raises @@ -111,9 +111,11 @@ def require_private_key(private_key: Secret) -> Secret: return private_key -def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = None) -> Grant: +def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = None) -> Grant | GrantV2: """Return the grant if the private key of one of ``public_keys`` signed it and it has not expired. + The grant is a ``GrantV2`` if its version is 2, and a ``Grant`` otherwise. + Verification takes a list of Ed25519 public keys in PEM, so a new key can be added before the signer switches to it and the old one removed after its grants expire. Raises GitGrantKeyError, rather than refusing the grant, if the list is empty or a key is not one, since no grant could verify against it. Every other failure is a @@ -155,8 +157,14 @@ def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = raise GitInvalidGrant(detail=f"The git grant is invalid: {e}") from None fields = {name: value for name, value in claims.items() if name not in _JWT_CLAIMS} + payload = {**fields, "expires_at": claims["exp"]} try: - grant = msgspec.convert({**fields, "expires_at": claims["exp"]}, type=Grant, strict=True) + grant: Grant | GrantV2 + if fields.get("version") == 2: + grant = msgspec.convert(payload, type=GrantV2, strict=True) + else: + # Grant's version field refuses any version but 1. + grant = msgspec.convert(payload, type=Grant, strict=True) except msgspec.ValidationError as e: # The signature matched, so the holder of the private key wrote these claims; telling them why they are # invalid gives nothing away. @@ -164,7 +172,7 @@ def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = if grant.expires_at <= now: raise GitGrantExpired(detail="The git grant has expired.") - if not grant.repositories: + if isinstance(grant, Grant) and not grant.repositories: raise GitInvalidGrant(detail="The git grant names no repositories.") return grant diff --git a/src/scm/git/push.py b/src/scm/git/push.py new file mode 100644 index 00000000..4cca9dcf --- /dev/null +++ b/src/scm/git/push.py @@ -0,0 +1,224 @@ +"""Reading a push's ref updates, so GitServer can keep a push to its rule's branches. + +A push's request body lists the ref updates as pkt-lines, then holds the pack. GitServer reads the updates before it +looks anything up, refuses the push unless the rule allows every update, and otherwise relays the updates it read +followed by the rest of the body, which it never reads. gitprotocol-pack(5) describes the format, which +receive-pack speaks in protocol versions 0 and 1 alike, the only ones it has. +""" + +import re +from collections.abc import Mapping +from typing import NamedTuple + +from scm.errors import ClientClosedRequest, GitPushNotGranted, GitRequestMalformed, RpcMalformedRequestHeaders +from scm.git.types import PushRule, Readable + +MAX_UPDATES_LENGTH = 64 * 1024 +"""The most bytes a push may send before its pack. An update takes under 100 bytes plus its ref's name, and a push +from a shallow clone also lists the clone's shallow commits, about 50 bytes each.""" + +_FLUSH = b"0000" +# A pkt-line's length, in 4 hex digits, counts the 4 digits too. +_PKT_LENGTH = re.compile(rb"[0-9a-fA-F]{4}") +_MAX_PKT_LENGTH = 65520 +_OBJECT_ID = re.compile(rb"[0-9a-f]{40}|[0-9a-f]{64}") + + +class RefUpdate(NamedTuple): + old_id: bytes + new_id: bytes + ref: bytes + + @property + def deletes(self) -> bool: + return not self.new_id.strip(b"0") + + +class PushRequest(NamedTuple): + updates: list[RefUpdate] + capabilities: frozenset[str] + """What the client asked for, from its first update.""" + head: bytes + """The bytes read from the body, to send on before the rest of it.""" + + +def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest: + """Read a push's ref updates from ``body``, leaving the pack unread. + + Raises GitRequestMalformed for a body that does not start with a list of ref updates, GitPushNotGranted for a + compressed body or a signed push, whose updates come inside a certificate, and ClientClosedRequest if reading the + body fails. + """ + encoding = next((value for name, value in headers.items() if name.lower() == "content-encoding"), "identity") + if encoding.strip().lower() not in ("", "identity"): + # git compresses fetches but never pushes. + raise GitPushNotGranted(detail="The git endpoint does not read a compressed push.") + reader = _PktReader(body) + updates: list[RefUpdate] = [] + capabilities: frozenset[str] = frozenset() + while (line := reader.read_line()) is not None: + if not updates and line.startswith(b"shallow "): + # A push from a shallow clone lists its shallow commits first, and they pass on unchanged. + if not _OBJECT_ID.fullmatch(line.removeprefix(b"shallow ")): + raise GitRequestMalformed(detail="The push lists a shallow commit that is not an object id.") + continue + if not updates and line.startswith(b"push-cert"): + raise GitPushNotGranted(detail="The git endpoint does not relay signed pushes.") + if not updates: + line, _, requested = line.partition(b"\0") + capabilities = _capabilities(requested) + updates.append(_ref_update(line)) + return PushRequest(updates=updates, capabilities=capabilities, head=reader.read_so_far()) + + +def refused_updates(rule: PushRule, request: PushRequest) -> dict[bytes, str]: + """Return why ``rule`` refuses each ref of ``request``, or nothing if it allows every update. + + A push is relayed whole or not at all, so once one update is refused, every other ref gets a reason too. + """ + if "push-options" in request.capabilities: + # Push options ask the provider to act on the push, such as GitLab opening a merge request or skipping CI. + return {update.ref: "the git endpoint does not relay push options" for update in request.updates} + branches = {f"refs/heads/{branch}".encode() for branch in rule.branches} + refused: dict[bytes, str] = {} + for update in request.updates: + if not update.ref.startswith(b"refs/heads/"): + refused[update.ref] = "the git grant allows pushing branches only" + elif update.ref not in branches: + refused[update.ref] = "the git grant does not list this branch" + elif update.deletes: + refused[update.ref] = "the git grant does not allow deleting a branch" + if refused: + for update in request.updates: + refused.setdefault(update.ref, "refused with the rest of the push") + return refused + + +def refusal_report(request: PushRequest, refused: Mapping[bytes, str]) -> bytes | None: + """Return the response body that tells git why each ref was refused, or None if the client asked for none. + + git shows each ref as ``! [remote rejected] ()``, as for a provider's own refusal. A refused + HTTP request would show only its status code. + """ + if not {"report-status", "report-status-v2"} & request.capabilities: + return None + # "unpack ok" makes git show each ref's reason rather than an unpack failure. Each "ng" line is shorter than + # the update it answers, so it fits in a pkt-line. + report = ( + _pkt(b"unpack ok\n") + + b"".join(_pkt(b"ng %s %s\n" % (update.ref, refused[update.ref].encode())) for update in request.updates) + + _FLUSH + ) + if "side-band-64k" in request.capabilities: + band_size = _MAX_PKT_LENGTH - 5 + elif "side-band" in request.capabilities: + band_size = 1000 - 5 + else: + return report + # On a side band the report travels on band 1, and the response ends with a flush of its own. + return b"".join(_pkt(b"\1" + report[i : i + band_size]) for i in range(0, len(report), band_size)) + _FLUSH + + +def bounded_body(headers: Mapping[str, str], body: Readable) -> Readable: + """Return ``body``, ending after its Content-Length if it has one, as the relay reads it. + + A caller may pass a stream that goes on past the body, such as a connection's socket, and reading the pushed + updates or a refused pack past the body's end would wait for bytes that never come. + """ + raw_length = next((value for name, value in headers.items() if name.lower() == "content-length"), None) + if raw_length is None: + return body + try: + length = int(raw_length) + except ValueError: + raise RpcMalformedRequestHeaders(detail="Could not parse Content-Length header") from None + if length < 0: + raise RpcMalformedRequestHeaders(detail="Could not parse Content-Length header") + return _BoundedBody(body, length) + + +class _BoundedBody: + def __init__(self, body: Readable, length: int) -> None: + self.body = body + self.remaining = length + + def read(self, size: int = -1, /) -> bytes: + size = self.remaining if size < 0 else min(size, self.remaining) + if size == 0: + return b"" + data = self.body.read(size) + self.remaining -= len(data) + return data + + +class ReplayedBody: + """A request body whose first bytes were read already, sending them before the rest.""" + + def __init__(self, head: bytes, rest: Readable) -> None: + self.head = head + self.rest = rest + + def read(self, size: int = -1, /) -> bytes: + if not self.head: + return self.rest.read(size) + if size < 0: + data, self.head = self.head + self.rest.read(), b"" + return data + data, self.head = self.head[:size], self.head[size:] + return data + + +class _PktReader: + def __init__(self, body: Readable) -> None: + self.body = body + self.read_bytes = bytearray() + + def read_line(self) -> bytes | None: + """Return the next pkt-line's payload without its trailing newline, or None for a flush.""" + header = self._read(4) + if not _PKT_LENGTH.fullmatch(header): + raise GitRequestMalformed(detail="The push's ref updates are not pkt-lines.") + length = int(header, 16) + if length == 0: + return None + # 1 to 3 are protocol v2's special packets, which a push never sends. + if length < 4 or length > _MAX_PKT_LENGTH: + raise GitRequestMalformed(detail="The push's ref updates are not pkt-lines.") + return self._read(length - 4).removesuffix(b"\n") + + def read_so_far(self) -> bytes: + return bytes(self.read_bytes) + + def _read(self, size: int) -> bytes: + if len(self.read_bytes) + size > MAX_UPDATES_LENGTH: + raise GitRequestMalformed(detail=f"The push's ref updates are longer than {MAX_UPDATES_LENGTH} bytes.") + data = bytearray() + while len(data) < size: + try: + chunk = self.body.read(size - len(data)) + except OSError: + raise ClientClosedRequest(detail="The client's connection failed while it sent its push.") from None + if not chunk: + raise GitRequestMalformed(detail="The push ended before its ref updates did.") + data += chunk + self.read_bytes += data + return bytes(data) + + +def _capabilities(requested: bytes) -> frozenset[str]: + try: + return frozenset(requested.decode("ascii").split()) + except UnicodeDecodeError: + raise GitRequestMalformed(detail="The push's capabilities are not ASCII.") from None + + +def _ref_update(line: bytes) -> RefUpdate: + old_id, _, rest = line.partition(b" ") + new_id, _, ref = rest.partition(b" ") + if not (_OBJECT_ID.fullmatch(old_id) and _OBJECT_ID.fullmatch(new_id) and len(old_id) == len(new_id) and ref): + raise GitRequestMalformed(detail="The push's ref updates are malformed.") + return RefUpdate(old_id=old_id, new_id=new_id, ref=ref) + + +def _pkt(payload: bytes) -> bytes: + return b"%04x" % (len(payload) + 4) + payload diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py index b65848ae..c56b2d80 100644 --- a/src/scm/git/relay.py +++ b/src/scm/git/relay.py @@ -20,7 +20,7 @@ RpcMalformedRequestHeaders, SCMCodedError, ) -from scm.git.types import GitOperation, Grant, Readable +from scm.git.types import FetchRule, GitOperation, Grant, GrantV2, Readable from scm.rpc.types import StreamResponse from scm.secret import Secret from scm.types import GitRemote, RepositoryId @@ -64,20 +64,42 @@ ) -def authorize_request(grant: Grant, repository_id: RepositoryId, method: str, route: str, query: str) -> GitOperation: +def authorize_request( + grant: Grant | GrantV2, repository_id: RepositoryId, method: str, route: str, query: str +) -> GitOperation: """Return the git operation a request belongs to, refusing the request unless the grant allows it. - A fetch grant reaches only upload-pack and a push grant only receive-pack. The repository must be listed in - the grant in the same form the request uses: a database id does not match the same repository's - ``(provider, external id)`` pair. + Fetches reach only upload-pack and pushes only receive-pack. The grant must name the repository in the same form + the request uses: a database id does not match the same repository's ``(provider, external id)`` pair. + + A ``GrantV2`` allows a request only if it has a rule for the request's repository and operation. A push rule's + branches are kept by GitServer, which reads the push's ref updates before it relays the push, so a caller that + relays a push some other way must check them itself. A fetch rule that lists branches is refused, since nothing + reads a fetch's body to keep it to them. """ operation = _ROUTES.get((method, route, query)) if operation is None: raise GitRouteNotSupported(detail=f"{method} {route} is not a git fetch or push route.") - if operation != grant.operation: - raise GitOperationNotGranted(detail=f"The git grant allows {grant.operation}, not {operation}.") - if repository_id not in grant.repositories: - raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") + if isinstance(grant, Grant): + if operation != grant.operation: + raise GitOperationNotGranted(detail=f"The git grant allows {grant.operation}, not {operation}.") + if repository_id not in grant.repositories: + raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") + return operation + + rule = grant.rule_for(repository_id, operation) + if rule is None: + other = grant.rule_for(repository_id, "push" if operation == "fetch" else "fetch") + if other is None: + raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") + raise GitOperationNotGranted( + detail=f"The git grant allows {other.operation} on this repository, not {operation}." + ) + # Delete this once GitServer keeps a fetch to its rule's branches. + if isinstance(rule, FetchRule) and rule.branches is not None: + raise GitOperationNotGranted( + detail="The git endpoint cannot keep a fetch to a rule's branches yet, so it refuses the rule." + ) return operation diff --git a/src/scm/git/server.py b/src/scm/git/server.py index e49bedf1..17c3fa21 100644 --- a/src/scm/git/server.py +++ b/src/scm/git/server.py @@ -1,10 +1,26 @@ +import contextlib from collections.abc import Callable, Mapping, Sequence from urllib.parse import urlsplit -from scm.errors import ErrorCode, GitProviderNotSupported, GitTokenRejected, GitTokenUnavailable, SCMCodedError +from scm.errors import ( + ErrorCode, + GitProviderNotSupported, + GitPushNotGranted, + GitTokenRejected, + GitTokenUnavailable, + SCMCodedError, +) from scm.git.grant import require_public_keys, verify_grant -from scm.git.relay import GitRelay, ResponseBody, authorize_request -from scm.git.types import GRANT_HEADER, GetToken, Readable +from scm.git.push import ( + PushRequest, + ReplayedBody, + bounded_body, + read_push_request, + refusal_report, + refused_updates, +) +from scm.git.relay import DEFAULT_CHUNK_SIZE, GitRelay, ResponseBody, authorize_request +from scm.git.types import GRANT_HEADER, GetToken, GrantV2, PushRule, Readable from scm.helpers import initialize_provider from scm.rpc.types import StreamResponse from scm.secret import Secret @@ -15,12 +31,14 @@ _STATUS: dict[ErrorCode, int] = { # The client's own request is malformed, or is not a git request this endpoint serves. "rpc_malformed_request_headers": 400, + "git_request_malformed": 400, "git_route_not_supported": 404, # The grant does not allow the request. "git_invalid_grant": 403, "git_grant_expired": 403, "git_operation_not_granted": 403, "git_repository_not_granted": 403, + "git_push_not_granted": 403, # The grant allows it, but there is no repository to serve. "repository_not_found": 404, "repository_inactive": 404, @@ -58,6 +76,13 @@ class GitServer: does not allow never reaches the database, the token supplier or the provider. Give it a relay whose adapter refuses internal addresses; see ``GitRelay``. + A push under a ``GrantV2`` is kept to its rule's branches. The server reads the push's ref updates, which come + before the pack, and refuses the whole push if one of them updates a branch the rule does not list, deletes a + branch, or updates any other ref, such as a tag, and refuses push options and signed pushes too. A client that + asked for a status report gets one, a 200 that git shows as a rejected ref with its reason. A push the rule + allows is relayed with the updates the server read followed by the rest of the body, unread. A push under a + ``Grant`` is relayed unread, as before rules. + The repository's provider says where its git endpoint is, through ``GetGitRemoteProtocol``. A repository whose provider has no git remote is refused. @@ -129,12 +154,27 @@ def handle( counted_body = _CountingReader(body) if body is not None else None context = {"repository_id": str(repository_id)} referrer_tag: dict[str, str] = {} + push_body: Readable | None = None + push: PushRequest | None = None + refused: dict[bytes, str] = {} try: grant = verify_grant(self.public_keys, grant_token.reveal()) context["organization_id"] = str(grant.organization_id) referrer_tag["referrer"] = context["referrer"] = grant.referrer operation = authorize_request(grant, repository_id, method, route, query) context["operation"] = operation + relayed_body: Readable | None = counted_body + rule = grant.rule_for(repository_id, operation) if isinstance(grant, GrantV2) else None + if isinstance(rule, PushRule) and method == "POST" and counted_body is not None: + push_body = bounded_body(headers, counted_body) + push = read_push_request(headers, push_body) + refused = refused_updates(rule, push) + if refused: + ref, reason = next(iter(refused.items())) + raise GitPushNotGranted( + detail=f"The git grant does not allow this push: {ref.decode(errors='replace')} ({reason})." + ) + relayed_body = ReplayedBody(push.head, push_body) provider = initialize_provider( grant.organization_id, repository_id, @@ -163,7 +203,7 @@ def handle( "read it." ) response = self.relay.forward( - remote, token, method=method, route=route, query=query, headers=headers, body=counted_body + remote, token, method=method, route=route, query=query, headers=headers, body=relayed_body ) if response.status_code == 401: # The provider rejected the token Sentry supplied. Passed on, the 401 would make git ask the client @@ -174,6 +214,11 @@ def handle( self.record_count("sentry.scm.git.request.failed", 1, {"code": e.code} | referrer_tag) if e.code != "client_closed_request": self.emit_error(e, context) + if push is not None and refused and push_body is not None: + _drain(push_body) + status_report = refusal_report(push, refused) + if status_report is not None: + return _report_response(status_report) return _error_response(e) except Exception: self.record_count("sentry.scm.git.request.failed", 1, {"code": "unhandled_exception"} | referrer_tag) @@ -279,6 +324,26 @@ def _end(self, failure: str | None) -> None: self.finish(failure, self.bytes_sent) +def _drain(body: Readable) -> None: + # git reads the response only once it has sent the whole push, and a connection closed before then shows only as + # a broken pipe, hiding the reason, so a refused push's pack is read to the end and dropped. + with contextlib.suppress(OSError, SCMCodedError): + while body.read(DEFAULT_CHUNK_SIZE): + pass + + +def _report_response(report: bytes) -> StreamResponse: + return StreamResponse( + status_code=200, + headers={ + "Content-Type": "application/x-git-receive-pack-result", + "Content-Length": str(len(report)), + "Cache-Control": "no-cache", + }, + content=iter([report]), + ) + + def _error_response(error: SCMCodedError) -> StreamResponse: # A code missing from the map comes from a failure nobody planned for, which is the server's fault. status = _STATUS.get(error.code, 500) diff --git a/src/scm/git/types.py b/src/scm/git/types.py index 66239d0d..bffb0f57 100644 --- a/src/scm/git/types.py +++ b/src/scm/git/types.py @@ -1,15 +1,17 @@ +import re from collections.abc import Callable -from typing import Literal, Protocol +from typing import ClassVar, Literal, Protocol import msgspec from scm.secret import Secret -from scm.types import Referrer, Repository, RepositoryId +from scm.types import BranchName, Referrer, Repository, RepositoryId type GitOperation = Literal["fetch", "push"] -"""What a grant lets its holder do. ``scm.git.relay`` maps each to its git routes.""" +"""What a grant or rule lets its holder do. ``scm.git.relay`` maps each to its git routes.""" GRANT_VERSION: Literal[1] = 1 +"""The version of ``Grant``, which ``GrantV2`` replaces.""" GRANT_HEADER = "X-Scm-Git-Grant" """The request header that carries a signed grant to the git endpoint.""" @@ -17,20 +19,81 @@ GRANT_MAX_LENGTH = 4096 """The longest signed grant, in characters. The grant travels in a request header, and sandbox-router's ``PUT /grant/{sandbox_id}`` refuses a longer one, so a change here must change the router too. A grant grows with -its repositories: a grant for one is under 450 characters, and the claims are base64-encoded, so each repository -named as ``(provider, path)`` adds about 4/3 of its path's length plus 20, e.g. 52 for a 25-character path. The -limit is a conservative pick rather than a measured ceiling: header limits further along the path are typically -around 8 KiB.""" +its rules: a grant with one is under 500 characters, and the claims are base64-encoded, so a fetch rule for any +object of a repository named as ``(provider, path)`` adds about 4/3 of its path's length plus 87, e.g. 120 for a +25-character path, and each branch a rule lists adds about 4/3 of its name's length plus 4. The limit is a +conservative pick rather than a measured ceiling: header limits further along the path are typically around +8 KiB.""" + + +class FetchRule( + msgspec.Struct, + frozen=True, + kw_only=True, + forbid_unknown_fields=True, + omit_defaults=True, + tag="fetch", + tag_field="operation", +): + """Lets the holder fetch from ``repository``: any of its objects, or only what its listed ``branches`` reach. + + A rule sets exactly one of the two, so a rule that leaves its branches out is refused rather than read as one + that reads everything. ``any_object`` reaches every object the provider serves, including objects no branch + reaches and, on GitHub, objects of the repository's forks, so give it only to code that runs no agent, such as a + checkout before the agent starts. + """ + + operation: ClassVar[GitOperation] = "fetch" + repository: RepositoryId + branches: list[BranchName] | None = None + """Branch names as ``git branch`` takes them, without ``refs/heads/``.""" + any_object: bool = False + + def __post_init__(self) -> None: + if self.branches is None and not self.any_object: + raise ValueError("A fetch rule must list its branches or set any_object.") + if self.branches is not None and self.any_object: + raise ValueError("A fetch rule must not both list branches and set any_object.") + if self.branches is not None: + _check_branches(self.branches) + + +class PushRule( + msgspec.Struct, + frozen=True, + kw_only=True, + forbid_unknown_fields=True, + tag="push", + tag_field="operation", +): + """Lets the holder update the listed ``branches`` of ``repository``, and no other ref: no tag, and no deletion. + + A push may move a listed branch to any commit, so a force push passes. Don't list the repository's default + branch. The git endpoint doesn't know which branch that is, so keeping it out is the signer's job, with the + provider's branch protection behind it. + """ + + operation: ClassVar[GitOperation] = "push" + repository: RepositoryId + branches: list[BranchName] + """Branch names as ``git branch`` takes them, without ``refs/heads/``.""" + + def __post_init__(self) -> None: + _check_branches(self.branches) + + +type Rule = FetchRule | PushRule class Grant(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): """Permission to run one git operation against a fixed set of an organization's repositories. - Seer signs a grant per coding job and Sentry's git endpoint verifies it on every request. Each repository is - a ``RepositoryId`` under the grant's organization, as in the RPC endpoint: Sentry's repository id or a - ``(provider, external id)`` pair. A fetch grant holds one repository today. The list leaves room for - submodules and git-based dependencies, but only from repositories Sentry has connected for the organization: - naming any other repository, such as a public dependency, would need a new kind of entry and a new version. + Deprecated: sign a ``GrantV2``, which can also keep a fetch or a push to branches. Verifiers still accept this + version, so a signer can switch once they can read both. + + A push grant lets its holder update any ref of its repositories, including tags and deletions, since GitServer + relays its pushes unread. Each repository is a ``RepositoryId`` under the grant's organization, as in the RPC + endpoint: Sentry's repository id or a ``(provider, external id)`` pair. A grant with a field this version does not know is refused rather than read without it. If a later version adds a field that restricts a grant, a verifier that ignored the field would allow more than the grant does. @@ -49,6 +112,76 @@ class Grant(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=Tru """The format version. A verifier refuses a version it does not know.""" +class GrantV2(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): + """Permission to run git operations on an organization's repositories, as a list of rules. + + Seer signs a grant for each sandbox, and for its own git requests, and Sentry's git endpoint verifies it on + every request. A sandbox holds one grant at a time, so a grant that lets a pod both fetch and push holds a rule + for each. A request runs only if a rule for its repository and operation allows it, and a grant holds at most one + rule per repository and operation. + + Each rule names its repository as a ``RepositoryId`` under the grant's organization, as in the RPC endpoint: + Sentry's repository id or a ``(provider, external id)`` pair. Several repositories leave room for submodules and + git-based dependencies, but only from repositories Sentry has connected for the organization: naming any other + repository, such as a public dependency, would need a new kind of rule. + + A grant or rule with a field this version does not know is refused rather than read without it, because a + verifier that ignored a field restricting the grant would allow more than the grant does. So a new field needs + only the verifiers to know it before the signer uses it, and a new version only when a field changes meaning. + """ + + organization_id: int + referrer: Referrer + """Who the grant is for, e.g. a coding job or Seer itself, as ``referrer`` is elsewhere in this library. + Sentry tags the git endpoint's metrics with it. Seer sets it, because git requests carry nothing else that + tells callers apart.""" + rules: list[Rule] + expires_at: int + """Unix time in seconds. Every grant expires.""" + version: Literal[2] = 2 + """The format version. A verifier refuses a version it does not know, so one that knows only ``Grant`` refuses + this one rather than reading its rules as broader than they are.""" + + def __post_init__(self) -> None: + if not self.rules: + raise ValueError("A git grant must hold at least one rule.") + seen: set[tuple[GitOperation, RepositoryId]] = set() + for rule in self.rules: + key = (rule.operation, rule.repository) + if key in seen: + raise ValueError(f"A git grant holds more than one {rule.operation} rule for {rule.repository!r}.") + seen.add(key) + + def rule_for(self, repository: RepositoryId, operation: GitOperation) -> Rule | None: + """Return the rule for ``operation`` on ``repository``, named as the rule names it, or None if there is none.""" + return next( + (rule for rule in self.rules if rule.repository == repository and rule.operation == operation), None + ) + + +# What git check-ref-format refuses in a ref name: control characters, space, ~ ^ : ? * [ \, "..", "@{" and "//". +_NOT_IN_A_REF = re.compile(r"[\x00-\x20\x7f~^:?*\[\\]|\.\.|@\{|//") + + +def _check_branches(branches: list[BranchName]) -> None: + if not branches: + raise ValueError("A rule's branch list must not be empty.") + for name in branches: + # git check-ref-format's rules for refs/heads/, with what git branch refuses on top (a leading "-" and + # "HEAD"). A name starting with refs/ is a valid branch, but it's far more likely a full ref name by mistake. + if ( + not name + or name in ("@", "HEAD") + or name.startswith(("-", "/", "refs/")) + or name.endswith(("/", ".")) + or _NOT_IN_A_REF.search(name) + or any(part.startswith(".") or part.endswith(".lock") for part in name.split("/")) + ): + raise ValueError( + f"{name!r} is not a branch name. Name a branch as git branch takes it, without refs/heads/." + ) + + type GetToken = Callable[[Repository, GitOperation], Secret | None] """Return a provider token that can run ``operation`` on ``repository``, or None if there is none. diff --git a/tests/unit/git_builders.py b/tests/unit/git_builders.py index fe1418c1..251241ab 100644 --- a/tests/unit/git_builders.py +++ b/tests/unit/git_builders.py @@ -7,7 +7,7 @@ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_private_key from scm.git.grant import generate_private_key -from scm.git.types import Grant +from scm.git.types import FetchRule, Grant, GrantV2 from scm.providers.cursor_origin.provider import CursorOriginProvider from scm.providers.github.provider import GitHubProvider from scm.providers.gitlab.provider import GitLabProvider @@ -47,8 +47,20 @@ def make_provider(organization_id: int, repository: Repository) -> Provider | No return None -def make_grant(**overrides) -> Grant: - """A fetch grant for the default repository, named both ways a grant can name it.""" +def make_grant(**overrides) -> GrantV2: + """A grant to fetch any object of the default repository, named both ways a rule can name it.""" + fields = { + "organization_id": 1, + "referrer": "seer.coding_job", + "rules": [FetchRule(repository=5, any_object=True), FetchRule(repository=("github", "123"), any_object=True)], + "expires_at": FAR_FUTURE, + } + fields.update(overrides) + return GrantV2(**fields) # type: ignore[arg-type] + + +def make_v1_grant(**overrides) -> Grant: + """A version 1 fetch grant for the default repository, named both ways a grant can name it.""" fields = { "organization_id": 1, "referrer": "seer.coding_job", @@ -80,3 +92,30 @@ def make_key_pair() -> KeyPair: # Serialized here rather than by public_key_of, so the tests check public_key_of against cryptography. public=key.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode(), ) + + +ZERO_ID = "0" * 40 +OLD_ID = "1" * 40 +NEW_ID = "2" * 40 +PACK = b"PACK\x00\x00\x00\x02" + b"pack contents" * 16 + + +def pkt(payload: bytes) -> bytes: + return b"%04x" % (len(payload) + 4) + payload + + +def make_push_body( + *updates: tuple[str, str, str], + capabilities: str = "report-status side-band-64k quiet object-format=sha1 agent=git/2.54.0", + shallow: tuple[str, ...] = (), + pack: bytes = PACK, +) -> bytes: + """A receive-pack request body as git sends it: shallow commits, ref updates (old id, new id, ref) and the pack. + + git ends no update with a newline, and puts its capabilities after a NUL on the first. + """ + lines = [pkt(f"shallow {commit}\n".encode()) for commit in shallow] + for index, (old_id, new_id, ref) in enumerate(updates): + line = f"{old_id} {new_id} {ref}".encode() + lines.append(pkt(line + b"\0 " + capabilities.encode() if index == 0 else line)) + return b"".join(lines) + b"0000" + pack diff --git a/tests/unit/test_git_grant.py b/tests/unit/test_git_grant.py index a8d07ff8..a4553969 100644 --- a/tests/unit/test_git_grant.py +++ b/tests/unit/test_git_grant.py @@ -31,10 +31,10 @@ sign_grant, verify_grant, ) -from scm.git.types import GRANT_MAX_LENGTH, Grant +from scm.git.types import GRANT_MAX_LENGTH, FetchRule, Grant, GrantV2, PushRule from scm.secret import Secret from tests.unit.error_reports import assert_not_in_frames -from tests.unit.git_builders import FAR_FUTURE, KeyPair, make_grant, make_key_pair +from tests.unit.git_builders import FAR_FUTURE, KeyPair, make_grant, make_key_pair, make_v1_grant # Before the real time: PyJWT checks iat against the real clock, so a grant must not be issued after it. NOW = 1_700_000_000 @@ -50,7 +50,7 @@ def expected_key_id(key: KeyPair) -> str: return hashlib.sha256(raw).hexdigest()[:16] -def claims_of(grant: Grant) -> dict: +def claims_of(grant: Grant | GrantV2) -> dict: claims = msgspec.to_builtins(grant) claims["exp"] = claims.pop("expires_at") return claims | {"iat": NOW, "aud": GRANT_AUDIENCE} @@ -117,9 +117,17 @@ def test_public_key_of_the_signing_key(self): # make_key_pair serializes the public key with cryptography directly, not through public_key_of. assert public_key_of(KEY.private) == KEY.public - def test_push_grant(self): - grant = make_grant(operation="push") - assert verify_grant([KEY.public], sign_grant(KEY.private, grant, now=NOW), now=NOW).operation == "push" + def test_rules_of_each_kind(self): + grant = make_grant( + rules=[ + FetchRule(repository=5, branches=["main", "seer/fix"]), + PushRule(repository=5, branches=["seer/fix"]), + FetchRule(repository=("gitlab", "gitlab.com:7"), any_object=True), + ] + ) + verified = verify_grant([KEY.public], sign_grant(KEY.private, grant, now=NOW), now=NOW) + assert verified == grant + assert [rule.operation for rule in verified.rules] == ["fetch", "push", "fetch"] def test_token_is_a_jwt_with_the_grants_claims(self): token = sign_grant(KEY.private, make_grant(), now=NOW) @@ -127,9 +135,11 @@ def test_token_is_a_jwt_with_the_grants_claims(self): assert jwt.decode(token, options={"verify_signature": False}) == { "organization_id": 1, "referrer": "seer.coding_job", - "operation": "fetch", - "repositories": [5, ["github", "123"]], - "version": 1, + "rules": [ + {"operation": "fetch", "repository": 5, "any_object": True}, + {"operation": "fetch", "repository": ["github", "123"], "any_object": True}, + ], + "version": 2, "exp": FAR_FUTURE, "iat": NOW, "aud": GRANT_AUDIENCE, @@ -265,9 +275,11 @@ def test_other_asymmetric_algorithm(self): assert_invalid(token, "alg value is not allowed") -def grant_with_repositories(count: int) -> Grant: - # Names of one length, so each repository adds the same to the grant. - return make_grant(repositories=[("github", f"getsentry/repository-{i:04d}") for i in range(count)]) +def grant_with_repositories(count: int) -> GrantV2: + # Names of one length, so each rule adds the same to the grant. + return make_grant( + rules=[FetchRule(repository=("github", f"getsentry/repository-{i:04d}"), any_object=True) for i in range(count)] + ) class TestLength: @@ -291,14 +303,14 @@ def test_longest_grant_that_fits_signs(self): except ValueError: break token, count = longer, count + 1 - # One more repository adds about 50 characters. - assert GRANT_MAX_LENGTH - 100 < len(token) <= GRANT_MAX_LENGTH + # One more rule adds about 120 characters. + assert GRANT_MAX_LENGTH - 150 < len(token) <= GRANT_MAX_LENGTH assert verify_grant([KEY.public], token, now=NOW) == grant_with_repositories(count) def test_grant_for_one_repository(self): # GRANT_MAX_LENGTH's docstring sizes grants from this. - grant = make_grant(repositories=[("github", "getsentry/scm-platform")]) - assert len(sign_grant(KEY.private, grant, now=NOW)) < 450 + grant = make_grant(rules=[FetchRule(repository=("github", "getsentry/scm-platform"), any_object=True)]) + assert len(sign_grant(KEY.private, grant, now=NOW)) < 500 def other_key_types() -> list: @@ -505,10 +517,12 @@ def test_missing(self): {"organization_id": True}, {"organization_id": "1"}, {"referrer": None}, - {"operation": "delete"}, - {"version": 2}, - {"repositories": [["github"]]}, - {"repositories": [["unknown_provider", "123"]]}, + {"version": 1}, + {"version": 3}, + {"version": "2"}, + {"rules": None}, + {"rules": [{"operation": "fetch", "repository": ["github"], "any_object": True}]}, + {"rules": [{"operation": "fetch", "repository": ["unknown_provider", "123"], "any_object": True}]}, {"exp": NOW + 600.5}, {"narrower_than_you_think": True}, ], @@ -522,5 +536,207 @@ def test_referrer_is_required(self): del claims["referrer"] assert_invalid(sign_claims(claims), "referrer") + def test_version_1_claims_labelled_version_2(self): + assert_invalid(sign_claims(claims_of(make_v1_grant()) | {"version": 2}), "payload is invalid") + + +def rule_claims(*rules: dict) -> dict: + return claims_of(make_grant()) | {"rules": list(rules)} + + +class TestRules: + """A grant's rules, checked both where a grant is built and where it is verified, whoever signed it.""" + + def test_fetch_and_push_rules_for_one_repository(self): + grant = make_grant( + rules=[FetchRule(repository=5, branches=["main"]), PushRule(repository=5, branches=["seer/fix"])] + ) + assert verify_grant([KEY.public], sign_grant(KEY.private, grant, now=NOW), now=NOW) == grant + + def test_claims_hold_only_what_a_rule_sets(self): + # Shorter, and a reader sees which fetch rules reach any object. + grant = make_grant(rules=[FetchRule(repository=5, branches=["main"]), FetchRule(repository=6, any_object=True)]) + claims = jwt.decode(sign_grant(KEY.private, grant, now=NOW), options={"verify_signature": False}) + assert claims["rules"] == [ + {"operation": "fetch", "repository": 5, "branches": ["main"]}, + {"operation": "fetch", "repository": 6, "any_object": True}, + ] + + @pytest.mark.parametrize( + "rule,error", + [ + ({"operation": "fetch", "repository": 5}, "must list its branches or set any_object"), + ({"operation": "fetch", "repository": 5, "any_object": False}, "must list its branches or set any_object"), + ({"operation": "fetch", "repository": 5, "branches": None}, "must list its branches or set any_object"), + ( + {"operation": "fetch", "repository": 5, "branches": ["main"], "any_object": True}, + "must not both list branches and set any_object", + ), + ({"operation": "fetch", "repository": 5, "branches": []}, "must not be empty"), + ({"operation": "push", "repository": 5, "branches": []}, "must not be empty"), + ({"operation": "push", "repository": 5}, "missing required field `branches`"), + ({"operation": "push", "repository": 5, "branches": None}, "Expected `array`, got `null`"), + ({"operation": "push", "repository": 5, "any_object": True}, "unknown field `any_object`"), + ({"operation": "fetch", "repository": 5, "branches": ["main"], "deepen": 50}, "unknown field `deepen`"), + ({"operation": "delete", "repository": 5, "branches": ["main"]}, "Invalid value 'delete'"), + ({"repository": 5, "any_object": True}, "missing required field `operation`"), + ({"operation": "fetch", "any_object": True}, "missing required field `repository`"), + ], + ) + def test_invalid_rule(self, rule, error): + assert_invalid(sign_claims(rule_claims(rule)), error) + + def test_rule_without_branches_or_any_object_cannot_be_built(self): + with pytest.raises(ValueError, match="must list its branches or set any_object"): + FetchRule(repository=5) + + def test_rule_with_branches_and_any_object_cannot_be_built(self): + with pytest.raises(ValueError, match="must not both list branches and set any_object"): + FetchRule(repository=5, branches=["main"], any_object=True) + + @pytest.mark.parametrize("make_rule", [FetchRule, PushRule], ids=["fetch", "push"]) + def test_rule_without_branches_listed_cannot_be_built(self, make_rule): + with pytest.raises(ValueError, match="must not be empty"): + make_rule(repository=5, branches=[]) + + def test_no_rules(self): + with pytest.raises(ValueError, match="at least one rule"): + make_grant(rules=[]) + assert_invalid(sign_claims(rule_claims()), "at least one rule") + + @pytest.mark.parametrize( + "rules,error", + [ + ( + [{"operation": "fetch", "repository": 5, "any_object": True}] * 2, + "more than one fetch rule for 5", + ), + ( + [ + {"operation": "fetch", "repository": 5, "any_object": True}, + {"operation": "fetch", "repository": 5, "branches": ["main"]}, + ], + "more than one fetch rule for 5", + ), + ( + [ + {"operation": "push", "repository": ["github", "123"], "branches": ["a"]}, + {"operation": "push", "repository": ["github", "123"], "branches": ["b"]}, + ], + "more than one push rule for ('github', '123')", + ), + ], + ) + def test_two_rules_for_one_repository_and_operation(self, rules, error): + # Otherwise which one applies would depend on the order of the rules. + assert_invalid(sign_claims(rule_claims(*rules)), error) + + def test_two_rules_for_one_repository_and_operation_cannot_be_built(self): + with pytest.raises(ValueError, match="more than one push rule for 5"): + make_grant(rules=[PushRule(repository=5, branches=["a"]), PushRule(repository=5, branches=["b"])]) + + def test_one_repository_named_both_ways(self): + # Nothing matches a database id to a provider id, so the two are separate repositories here, as in requests. + rules = [FetchRule(repository=5, any_object=True), FetchRule(repository=("github", "123"), branches=["main"])] + assert make_grant(rules=rules).rules == rules + + +INVALID_BRANCH_NAMES = [ + "", + "refs/heads/main", + "refs/tags/v1", + "-main", + "HEAD", + "@", + "a..b", + "a b", + "a\tb", + "a\x00b", + "a\x7fb", + "a~1", + "a^", + "a:b", + "a?", + "a*", + "a[b", + "a\\b", + "/main", + "main/", + "a//b", + ".main", + "a/.b", + "main.lock", + "a/b.lock/c", + "main.", + "a@{1}", +] + + +class TestBranchNames: + @pytest.mark.parametrize("name", INVALID_BRANCH_NAMES) + def test_invalid(self, name): + with pytest.raises(ValueError, match="is not a branch name"): + PushRule(repository=5, branches=["main", name]) + assert_invalid( + sign_claims(rule_claims({"operation": "push", "repository": 5, "branches": ["main", name]})), + "is not a branch name", + ) + + def test_checked_in_fetch_rules_too(self): + with pytest.raises(ValueError, match="is not a branch name"): + FetchRule(repository=5, branches=["refs/heads/main"]) + + @pytest.mark.parametrize( + "name", ["main", "seer/fix-123", "feature/a.b", "a@b", "release-1.0", "a.lock.b", "x/HEAD", "ünicode"] + ) + def test_valid(self, name): + assert PushRule(repository=5, branches=[name]).branches == [name] + + +class TestVersion1: + """Grant, which GrantV2 replaces, still signs and verifies as it did.""" + + @pytest.mark.parametrize("operation", ["fetch", "push"]) + def test_verifies_what_it_signed(self, operation): + grant = make_v1_grant(operation=operation) + verified = verify_grant([KEY.public], sign_grant(KEY.private, grant, now=NOW), now=NOW) + assert verified == grant + assert isinstance(verified, Grant) + + def test_token_is_a_jwt_with_the_grants_claims(self): + token = sign_grant(KEY.private, make_v1_grant(), now=NOW) + assert jwt.decode(token, options={"verify_signature": False}) == { + "organization_id": 1, + "referrer": "seer.coding_job", + "operation": "fetch", + "repositories": [5, ["github", "123"]], + "version": 1, + "exp": FAR_FUTURE, + "iat": NOW, + "aud": GRANT_AUDIENCE, + } + + def test_without_a_version(self): + # Read as version 1, as before there was a second one. + claims = claims_of(make_v1_grant()) + del claims["version"] + assert verify_grant([KEY.public], sign_claims(claims), now=NOW) == make_v1_grant() + + def test_expired(self): + with pytest.raises(GitGrantExpired): + verify_grant([KEY.public], sign_grant(KEY.private, make_v1_grant(expires_at=NOW), now=NOW), now=NOW) + + @pytest.mark.parametrize( + "change", + [ + {"operation": "delete"}, + {"repositories": [["github"]]}, + {"repositories": [["unknown_provider", "123"]]}, + {"rules": [{"operation": "push", "repository": 5, "branches": ["main"]}]}, + ], + ) + def test_signed_but_invalid_claims(self, change): + assert_invalid(sign_claims(claims_of(make_v1_grant()) | change), "payload is invalid") + def test_no_repositories(self): - assert_invalid(sign_grant(KEY.private, make_grant(repositories=[]), now=NOW), "names no repositories") + assert_invalid(sign_grant(KEY.private, make_v1_grant(repositories=[]), now=NOW), "names no repositories") diff --git a/tests/unit/test_git_push.py b/tests/unit/test_git_push.py new file mode 100644 index 00000000..dd0a3b26 --- /dev/null +++ b/tests/unit/test_git_push.py @@ -0,0 +1,298 @@ +import io + +import pytest + +from scm.errors import ClientClosedRequest, GitPushNotGranted, GitRequestMalformed, RpcMalformedRequestHeaders +from scm.git.push import ( + MAX_UPDATES_LENGTH, + PushRequest, + RefUpdate, + ReplayedBody, + bounded_body, + read_push_request, + refusal_report, + refused_updates, +) +from scm.git.types import PushRule +from tests.unit.git_builders import NEW_ID, OLD_ID, PACK, ZERO_ID, make_push_body, pkt + +RULE = PushRule(repository=5, branches=["seer/fix", "seer/other"]) + + +def read(body: bytes, headers: dict[str, str] | None = None) -> tuple[PushRequest, io.BytesIO]: + stream = io.BytesIO(body) + return read_push_request(headers or {}, stream), stream + + +class Trickle(io.BytesIO): + """A body that returns at most one byte per read, as a socket may.""" + + def read(self, size: int | None = -1, /) -> bytes: + return super().read(1 if size is None or size < 0 else min(size, 1)) + + +class Failing: + def read(self, size: int = -1, /) -> bytes: + raise ConnectionResetError() + + +class TestRead: + def test_updates_and_capabilities(self): + body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix"), (ZERO_ID, NEW_ID, "refs/heads/seer/other")) + request, stream = read(body) + assert request.updates == [ + RefUpdate(old_id=OLD_ID.encode(), new_id=NEW_ID.encode(), ref=b"refs/heads/seer/fix"), + RefUpdate(old_id=ZERO_ID.encode(), new_id=NEW_ID.encode(), ref=b"refs/heads/seer/other"), + ] + assert request.capabilities == { + "report-status", + "side-band-64k", + "quiet", + "object-format=sha1", + "agent=git/2.54.0", + } + + def test_leaves_the_pack_unread(self): + body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")) + request, stream = read(body) + assert request.head + stream.read() == body + assert stream.getvalue()[len(request.head) :] == PACK + + def test_probe(self): + # git sends a lone flush before a push too large to buffer, to check its credentials before it streams. + request, stream = read(b"0000") + assert request == PushRequest(updates=[], capabilities=frozenset(), head=b"0000") + + def test_shallow_commits(self): + body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix"), shallow=("3" * 40, "4" * 40)) + request, stream = read(body) + assert [update.ref for update in request.updates] == [b"refs/heads/seer/fix"] + assert request.head + stream.read() == body + + def test_sha256_object_ids(self): + old_id, new_id = "a" * 64, "b" * 64 + request, _ = read(make_push_body((old_id, new_id, "refs/heads/seer/fix"))) + assert request.updates[0].new_id == new_id.encode() + + def test_short_reads(self): + body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix"), shallow=("3" * 40,)) + request = read_push_request({}, Trickle(body)) + assert request.updates[0].ref == b"refs/heads/seer/fix" + + def test_trailing_newlines(self): + line = f"{OLD_ID} {NEW_ID} refs/heads/seer/fix".encode() + request, _ = read(pkt(line + b"\0report-status\n") + pkt(line.replace(b"fix", b"other") + b"\n") + b"0000") + assert [update.ref for update in request.updates] == [b"refs/heads/seer/fix", b"refs/heads/seer/other"] + assert request.capabilities == {"report-status"} + + def test_uppercase_pkt_length(self): + line = f"{OLD_ID} {NEW_ID} refs/heads/{'x' * 200}".encode() + request, _ = read(b"%04X" % (len(line) + 4) + line + b"0000") + assert request.updates[0].ref == f"refs/heads/{'x' * 200}".encode() + + @pytest.mark.parametrize("encoding", ["identity", "", " Identity "]) + def test_identity_encoding(self, encoding): + request, _ = read(make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")), {"Content-Encoding": encoding}) + assert len(request.updates) == 1 + + @pytest.mark.parametrize("encoding", ["gzip", "deflate"]) + def test_compressed(self, encoding): + with pytest.raises(GitPushNotGranted, match="compressed push"): + read(make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")), {"content-encoding": encoding}) + + def test_signed_push(self): + body = pkt(b"push-cert\0report-status") + pkt(b"certificate version 0.1\n") + b"0000" + with pytest.raises(GitPushNotGranted, match="signed pushes"): + read(body) + + @pytest.mark.parametrize( + "body", + [ + pytest.param(b"", id="empty"), + pytest.param(b"00", id="short-length"), + pytest.param(b"zzzz", id="length-not-hex"), + pytest.param(b"+004", id="length-with-sign"), + pytest.param(b" 0a\n", id="length-with-spaces"), + pytest.param(b"0001", id="delimiter"), + pytest.param(b"0003", id="v2-special-packet"), + pytest.param(b"fff1" + b"x" * 65517, id="pkt-line-too-long"), + pytest.param(pkt(f"{OLD_ID} {NEW_ID} refs/heads/a".encode()), id="no-flush"), + pytest.param(pkt(f"{OLD_ID} {NEW_ID}".encode()) + b"0000", id="no-ref"), + pytest.param(pkt(f"{OLD_ID} {NEW_ID} ".encode()) + b"0000", id="empty-ref"), + pytest.param(pkt(f"{OLD_ID[:-1]} {NEW_ID} refs/heads/a".encode()) + b"0000", id="short-id"), + pytest.param(pkt(f"{'A' * 40} {NEW_ID} refs/heads/a".encode()) + b"0000", id="uppercase-id"), + pytest.param(pkt(f"{OLD_ID} {'b' * 64} refs/heads/a".encode()) + b"0000", id="mixed-id-lengths"), + pytest.param(pkt(b"shallow 123\n") + b"0000", id="bad-shallow"), + pytest.param(pkt(f"{OLD_ID} {NEW_ID} refs/heads/a\0caps \xff".encode("latin-1")) + b"0000", id="caps"), + ], + ) + def test_malformed(self, body): + with pytest.raises(GitRequestMalformed): + read(body) + + def test_shallow_line_after_an_update(self): + body = make_push_body((OLD_ID, NEW_ID, "refs/heads/a")) + flush = body.index(b"0000") + with pytest.raises(GitRequestMalformed): + read(body[:flush] + pkt(f"shallow {'3' * 40}".encode()) + body[flush:]) + + def test_too_long_before_the_pack(self): + updates = [(OLD_ID, NEW_ID, f"refs/heads/branch-{i:05d}") for i in range(MAX_UPDATES_LENGTH // 90)] + with pytest.raises(GitRequestMalformed, match=f"longer than {MAX_UPDATES_LENGTH} bytes"): + read(make_push_body(*updates)) + + def test_connection_fails(self): + with pytest.raises(ClientClosedRequest): + read_push_request({}, Failing()) + + +def request_for(*updates: tuple[str, str, str], capabilities: str = "report-status side-band-64k") -> PushRequest: + return read(make_push_body(*updates, capabilities=capabilities))[0] + + +class TestRefusedUpdates: + def test_listed_branches(self): + request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), (ZERO_ID, NEW_ID, "refs/heads/seer/other")) + assert refused_updates(RULE, request) == {} + + def test_force_push_to_a_listed_branch(self): + # Nothing here knows whether the new commit descends from the old one, so the provider's protection decides. + assert refused_updates(RULE, request_for(("3" * 40, NEW_ID, "refs/heads/seer/fix"))) == {} + + def test_probe(self): + assert refused_updates(RULE, read(b"0000")[0]) == {} + + @pytest.mark.parametrize( + "ref,reason", + [ + ("refs/heads/main", "the git grant does not list this branch"), + ("refs/heads/seer/fix/deeper", "the git grant does not list this branch"), + ("refs/heads/seer", "the git grant does not list this branch"), + ("refs/heads/Seer/fix", "the git grant does not list this branch"), + ("refs/tags/seer/fix", "the git grant allows pushing branches only"), + ("refs/pull/1/head", "the git grant allows pushing branches only"), + ("refs/notes/commits", "the git grant allows pushing branches only"), + ("HEAD", "the git grant allows pushing branches only"), + ("seer/fix", "the git grant allows pushing branches only"), + ], + ) + def test_refs_the_rule_does_not_list(self, ref, reason): + assert refused_updates(RULE, request_for((OLD_ID, NEW_ID, ref))) == {ref.encode(): reason} + + def test_deleting_a_listed_branch(self): + refused = refused_updates(RULE, request_for((OLD_ID, ZERO_ID, "refs/heads/seer/fix"))) + assert refused == {b"refs/heads/seer/fix": "the git grant does not allow deleting a branch"} + + def test_one_refused_update_refuses_the_push(self): + request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), (OLD_ID, NEW_ID, "refs/heads/main")) + assert refused_updates(RULE, request) == { + b"refs/heads/main": "the git grant does not list this branch", + b"refs/heads/seer/fix": "refused with the rest of the push", + } + + def test_push_options(self): + request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), capabilities="report-status push-options") + assert refused_updates(RULE, request) == { + b"refs/heads/seer/fix": "the git endpoint does not relay push options" + } + + +def unband(response: bytes) -> bytes: + """The band 1 data of a side band response, checking that it ends with a flush.""" + data, position = b"", 0 + while response[position : position + 4] != b"0000": + length = int(response[position : position + 4], 16) + assert response[position + 4] == 1 + data += response[position + 5 : position + length] + position += length + assert position + 4 == len(response) + return data + + +class TestRefusalReport: + REFUSED = {b"refs/heads/main": "the git grant does not list this branch"} + REPORT = pkt(b"unpack ok\n") + pkt(b"ng refs/heads/main the git grant does not list this branch\n") + b"0000" + + def test_report_status(self): + request = request_for((OLD_ID, NEW_ID, "refs/heads/main"), capabilities="report-status") + assert refusal_report(request, self.REFUSED) == self.REPORT + + def test_report_status_v2(self): + request = request_for((OLD_ID, NEW_ID, "refs/heads/main"), capabilities="report-status-v2") + assert refusal_report(request, self.REFUSED) == self.REPORT + + def test_side_band_64k(self): + request = request_for((OLD_ID, NEW_ID, "refs/heads/main"), capabilities="report-status side-band-64k") + response = refusal_report(request, self.REFUSED) + assert response == pkt(b"\1" + self.REPORT) + b"0000" + + def test_side_band_splits_at_1000_bytes(self): + updates = [(OLD_ID, NEW_ID, f"refs/heads/branch-{i:03d}") for i in range(40)] + request = request_for(*updates, capabilities="report-status side-band") + refused = {update.ref: "the git grant does not list this branch" for update in request.updates} + response = refusal_report(request, refused) + assert response is not None + assert max(int(response[i : i + 4], 16) for i in range(0, len(response) - 4, 1000)) <= 1000 + assert unband(response).startswith(pkt(b"unpack ok\n")) + assert unband(response).count(b"ng refs/heads/branch-") == 40 + + def test_every_update_in_order(self): + request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), (OLD_ID, NEW_ID, "refs/heads/main")) + response = refusal_report(request, refused_updates(RULE, request)) + assert response is not None + assert unband(response) == ( + pkt(b"unpack ok\n") + + pkt(b"ng refs/heads/seer/fix refused with the rest of the push\n") + + pkt(b"ng refs/heads/main the git grant does not list this branch\n") + + b"0000" + ) + + def test_client_asked_for_no_report(self): + request = request_for((OLD_ID, NEW_ID, "refs/heads/main"), capabilities="side-band-64k quiet") + assert refusal_report(request, self.REFUSED) is None + + +class TestReplayedBody: + @pytest.mark.parametrize("size", [1, 3, 5, 64, 1024]) + def test_head_then_rest(self, size): + body = ReplayedBody(b"head", io.BytesIO(b"rest of the body")) + chunks = [] + while chunk := body.read(size): + chunks.append(chunk) + assert b"".join(chunks) == b"headrest of the body" + assert all(len(chunk) <= size for chunk in chunks) + + def test_read_all(self): + assert ReplayedBody(b"head", io.BytesIO(b"rest")).read() == b"headrest" + + def test_empty_head(self): + assert ReplayedBody(b"", io.BytesIO(b"rest")).read(64) == b"rest" + + +class TestBoundedBody: + def test_ends_at_content_length(self): + # As a connection's socket would, the stream goes on past the body. + stream = io.BytesIO(b"body" + b"next request") + body = bounded_body({"content-length": "4"}, stream) + assert body.read(64) == b"body" + assert body.read(64) == b"" + assert body.read() == b"" + assert stream.tell() == 4 + + def test_short_reads(self): + body = bounded_body({"Content-Length": "4"}, Trickle(b"body and more")) + assert b"".join(iter(lambda: body.read(64), b"")) == b"body" + + def test_without_content_length(self): + stream = io.BytesIO(b"body") + assert bounded_body({}, stream) is stream + + @pytest.mark.parametrize("length", ["x", "-1", ""]) + def test_invalid_content_length(self, length): + with pytest.raises(RpcMalformedRequestHeaders): + bounded_body({"Content-Length": length}, io.BytesIO(b"")) + + def test_truncated_updates(self): + body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")) + with pytest.raises(GitRequestMalformed, match="ended before its ref updates did"): + read_push_request({}, bounded_body({"Content-Length": "20"}, io.BytesIO(body))) diff --git a/tests/unit/test_git_relay.py b/tests/unit/test_git_relay.py index fe304ed5..47a0c431 100644 --- a/tests/unit/test_git_relay.py +++ b/tests/unit/test_git_relay.py @@ -26,10 +26,11 @@ SCMCodedError, ) from scm.git.relay import GitRelay, _UpstreamStream, authorize_request +from scm.git.types import FetchRule, PushRule from scm.secret import Secret from scm.types import GitRemote from tests.unit.error_reports import assert_not_in_frames, assert_not_reported, reported_local -from tests.unit.git_builders import make_grant +from tests.unit.git_builders import make_grant, make_v1_grant from tests.unit.git_upstream import ( ClosingAdapter, HostRefused, @@ -741,24 +742,40 @@ def handler(h: UpstreamHandler) -> None: FETCH_ROUTES = [("GET", "info/refs", "service=git-upload-pack"), ("POST", "git-upload-pack", "")] PUSH_ROUTES = [("GET", "info/refs", "service=git-receive-pack"), ("POST", "git-receive-pack", "")] +FETCH_ANY = FetchRule(repository=5, any_object=True) +PUSH_BRANCH = PushRule(repository=5, branches=["seer/fix"]) class TestAuthorizeRequest: - @pytest.mark.parametrize( - "operation,route", - [("fetch", route) for route in FETCH_ROUTES] + [("push", route) for route in PUSH_ROUTES], - ) - def test_grant_reaches_its_operations_routes(self, operation, route): - assert authorize_request(make_grant(operation=operation), 5, *route) == operation - - @pytest.mark.parametrize( - "operation,route", - [("fetch", route) for route in PUSH_ROUTES] + [("push", route) for route in FETCH_ROUTES], - ) - def test_grant_cannot_reach_the_other_operations_routes(self, operation, route): - other = "push" if operation == "fetch" else "fetch" - with pytest.raises(GitOperationNotGranted, match=f"allows {operation}, not {other}"): - authorize_request(make_grant(operation=operation), 5, *route) + @pytest.mark.parametrize("route", FETCH_ROUTES) + def test_fetch_rule_reaches_fetch_routes(self, route): + assert authorize_request(make_grant(rules=[FETCH_ANY]), 5, *route) == "fetch" + + @pytest.mark.parametrize("route", PUSH_ROUTES) + def test_push_rule_reaches_push_routes(self, route): + # GitServer keeps the push to the rule's branches. + assert authorize_request(make_grant(rules=[PUSH_BRANCH]), 5, *route) == "push" + + @pytest.mark.parametrize("route", PUSH_ROUTES) + def test_fetch_rule_cannot_reach_push_routes(self, route): + with pytest.raises(GitOperationNotGranted, match="allows fetch on this repository, not push"): + authorize_request(make_grant(rules=[FETCH_ANY]), 5, *route) + + @pytest.mark.parametrize("route", FETCH_ROUTES) + def test_push_rule_cannot_reach_fetch_routes(self, route): + with pytest.raises(GitOperationNotGranted, match="allows push on this repository, not fetch"): + authorize_request(make_grant(rules=[PUSH_BRANCH]), 5, *route) + + def test_rule_for_another_repository_does_not_apply(self): + grant = make_grant(rules=[FETCH_ANY, PushRule(repository=6, branches=["seer/fix"])]) + with pytest.raises(GitOperationNotGranted, match="allows fetch on this repository, not push"): + authorize_request(grant, 5, "POST", "git-receive-pack", "") + + @pytest.mark.parametrize("route", FETCH_ROUTES) + def test_fetch_rule_that_lists_branches_is_refused(self, route): + # Nothing reads a fetch's body, so nothing would keep the fetch to the rule's branches. + with pytest.raises(GitOperationNotGranted, match="cannot keep a fetch to a rule's branches"): + authorize_request(make_grant(rules=[FetchRule(repository=5, branches=["main"])]), 5, *route) @pytest.mark.parametrize( "method,route,query", @@ -779,23 +796,57 @@ def test_grant_cannot_reach_the_other_operations_routes(self, operation, route): ], ) def test_routes_that_are_not_git(self, method, route, query): - for operation in ("fetch", "push"): - with pytest.raises(GitRouteNotSupported, match="not a git fetch or push route"): - authorize_request(make_grant(operation=operation), 5, method, route, query) + with pytest.raises(GitRouteNotSupported, match="not a git fetch or push route"): + authorize_request(make_grant(rules=[FETCH_ANY, PUSH_BRANCH]), 5, method, route, query) def test_repository_not_in_grant(self): with pytest.raises(GitRepositoryNotGranted, match="does not cover this repository"): authorize_request(make_grant(), 6, "POST", "git-upload-pack", "") def test_repository_named_another_way_is_not_in_grant(self): - # The grant and the request name repositories the same way; matching a database id to a provider id - # would mean resolving every repository the grant lists. - grant = make_grant(repositories=[("github", "123")]) + # The rule and the request must name a repository the same way, since matching a database id to a provider + # id would mean resolving every repository the grant names. + grant = make_grant(rules=[FetchRule(repository=("github", "123"), any_object=True)]) with pytest.raises(GitRepositoryNotGranted): authorize_request(grant, 5, "POST", "git-upload-pack", "") assert authorize_request(grant, ("github", "123"), "POST", "git-upload-pack", "") == "fetch" + def test_any_repository_with_a_rule(self): + grant = make_grant( + rules=[ + FetchRule(repository=repository, any_object=True) for repository in (5, 6, ("gitlab", "gitlab.com:7")) + ] + ) + assert authorize_request(grant, 6, "POST", "git-upload-pack", "") == "fetch" + assert authorize_request(grant, ("gitlab", "gitlab.com:7"), "POST", "git-upload-pack", "") == "fetch" + + +class TestAuthorizeRequestVersion1: + @pytest.mark.parametrize( + "operation,route", + [("fetch", route) for route in FETCH_ROUTES] + [("push", route) for route in PUSH_ROUTES], + ) + def test_grant_reaches_its_operations_routes(self, operation, route): + assert authorize_request(make_v1_grant(operation=operation), 5, *route) == operation + + @pytest.mark.parametrize( + "operation,route", + [("fetch", route) for route in PUSH_ROUTES] + [("push", route) for route in FETCH_ROUTES], + ) + def test_grant_cannot_reach_the_other_operations_routes(self, operation, route): + other = "push" if operation == "fetch" else "fetch" + with pytest.raises(GitOperationNotGranted, match=f"allows {operation}, not {other}"): + authorize_request(make_v1_grant(operation=operation), 5, *route) + + def test_route_that_is_not_git(self): + with pytest.raises(GitRouteNotSupported): + authorize_request(make_v1_grant(), 5, "POST", "git-upload-archive", "") + + def test_repository_not_in_grant(self): + with pytest.raises(GitRepositoryNotGranted, match="does not cover this repository"): + authorize_request(make_v1_grant(), 6, "POST", "git-upload-pack", "") + def test_any_listed_repository(self): - grant = make_grant(repositories=[5, 6, ("gitlab", "gitlab.com:7")]) + grant = make_v1_grant(repositories=[5, 6, ("gitlab", "gitlab.com:7")]) assert authorize_request(grant, 6, "POST", "git-upload-pack", "") == "fetch" assert authorize_request(grant, ("gitlab", "gitlab.com:7"), "POST", "git-upload-pack", "") == "fetch" diff --git a/tests/unit/test_git_server.py b/tests/unit/test_git_server.py index b6dc082e..1f7bcb6c 100644 --- a/tests/unit/test_git_server.py +++ b/tests/unit/test_git_server.py @@ -20,12 +20,22 @@ from scm.git.grant import sign_grant from scm.git.relay import GitRelay, RelayResponse, ResponseBody from scm.git.server import _STATUS, GitServer -from scm.git.types import GRANT_HEADER, GitOperation, Readable +from scm.git.types import GRANT_HEADER, FetchRule, GitOperation, PushRule, Readable from scm.rpc.types import StreamResponse from scm.secret import Secret from scm.types import GitRemote, Provider, Repository, RepositoryId from tests.unit.error_reports import assert_not_in_frames, assert_not_reported, reported_local -from tests.unit.git_builders import make_grant, make_key_pair, make_provider, make_repository +from tests.unit.git_builders import ( + NEW_ID, + OLD_ID, + make_grant, + make_key_pair, + make_provider, + make_push_body, + make_repository, + make_v1_grant, + pkt, +) from tests.unit.git_upstream import ( ClosingAdapter, HostRefused, @@ -226,7 +236,7 @@ def test_route_that_is_not_git(self): def test_operation_the_grant_does_not_allow(self): recorder = Recorder() - assert_refused(push(make_server(recorder), signed_grant()), 403, "allows fetch, not push") + assert_refused(push(make_server(recorder), signed_grant()), 403, "allows fetch on this repository, not push") assert recorder.repository_lookups == [] assert recorder.token_lookups == [] assert recorder.counts == [ @@ -234,6 +244,13 @@ def test_operation_the_grant_does_not_allow(self): ] assert recorder.errors[0][1] == {"repository_id": "5", "organization_id": "1", "referrer": REFERRER} + def test_fetch_rule_that_lists_branches(self): + recorder = Recorder() + grant = signed_grant(rules=[FetchRule(repository=5, branches=["main"])]) + assert_refused(request(make_server(recorder), grant), 403, "cannot keep a fetch to a rule's branches") + assert recorder.repository_lookups == [] + assert recorder.token_lookups == [] + def test_repository_the_grant_does_not_list(self): recorder = Recorder() assert_refused(request(make_server(recorder), signed_grant(), repository_id=6), 403, "does not cover") @@ -296,10 +313,10 @@ def test_no_token(self): ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_token_unavailable"}) ] - def test_token_asked_for_the_grants_operation(self): + def test_token_asked_for_the_requests_operation(self): recorder = Recorder() - push(make_server(recorder), signed_grant(operation="push")) - assert recorder.token_lookups == [(make_repository(), "push")] + request(make_server(recorder), signed_grant()) + assert recorder.token_lookups == [(make_repository(), "fetch")] def test_forwards_with_remote_and_token(self): recorder = Recorder() @@ -618,17 +635,17 @@ def test_counts_bytes_each_way_end_to_end(self): relay = GitRelay(ca_bundle=upstream.ca_bundle) response = request( make_server(recorder, relay), - signed_grant(operation="push"), + signed_grant(), method="POST", - route="git-receive-pack", + route="git-upload-pack", query="", headers={"Content-Length": "8"}, body=io.BytesIO(b"00000000"), ) assert b"".join(response.content) == b"00000000" # The fake upstream echoes the body. assert recorder.bytes == { - ("sentry.scm.git.request_size", "push"): 8, - ("sentry.scm.git.response_size", "push"): 8, + ("sentry.scm.git.request_size", "fetch"): 8, + ("sentry.scm.git.response_size", "fetch"): 8, } def test_relays_end_to_end(self): @@ -721,3 +738,138 @@ def test_token_that_is_not_a_secret_is_refused_and_left_out(self): assert recorder.counts == [ ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "unhandled_exception"}) ] + + +PUSH_GRANT = sign_grant(KEY.private, make_grant(rules=[PushRule(repository=5, branches=["seer/fix"])])) + + +def push_body(ref: str, capabilities: str = "report-status side-band-64k") -> bytes: + return make_push_body((OLD_ID, NEW_ID, ref), capabilities=capabilities) + + +class Unreliable(io.BytesIO): + def read(self, size: int | None = -1, /) -> bytes: + raise ConnectionResetError() + + +class TestPushRules: + """A push under a push rule reaches the provider only if the rule allows every ref it updates.""" + + def send(self, server: GitServer, body: Readable, headers: dict[str, str] | None = None) -> StreamResponse: + return request( + server, PUSH_GRANT, method="POST", route="git-receive-pack", query="", headers=headers, body=body + ) + + @pytest.mark.parametrize("sized", [True, False], ids=["content-length", "chunked"]) + def test_listed_branch_end_to_end(self, sized): + body = push_body("refs/heads/seer/fix") + with running_upstream() as upstream: + recorder = Recorder( + repository=make_repository(provider_name="github_enterprise", web_base_url=upstream.url) + ) + response = self.send( + make_server(recorder, GitRelay(ca_bundle=upstream.ca_bundle)), + io.BytesIO(body), + headers={"Content-Length": str(len(body))} if sized else None, + ) + # The fake upstream echoes the body, so the provider got the updates the server read and the pack after. + assert b"".join(response.content) == body + assert recorder.token_lookups == [(recorder.repository, "push")] + assert recorder.bytes[("sentry.scm.git.request_size", "push")] == len(body) + + def test_probe(self): + recorder = Recorder() + server = make_server(recorder) + assert self.send(server, io.BytesIO(b"0000")).status_code == 200 + assert server.relay.forward.call_args.kwargs["body"].read() == b"0000" # type: ignore[attr-defined] + + def test_info_refs(self): + recorder = Recorder() + response = request(make_server(recorder), PUSH_GRANT, query="service=git-receive-pack") + assert b"".join(response.content) == b"refs" + + def test_unlisted_branch_gets_a_report(self): + recorder = Recorder() + server = make_server(recorder) + body = io.BytesIO(push_body("refs/heads/main")) + response = self.send(server, body) + assert response.status_code == 200 + assert response.headers["Content-Type"] == "application/x-git-receive-pack-result" + report = b"".join(response.content) + assert int(response.headers["Content-Length"]) == len(report) + assert report == ( + pkt( + b"\1" + + pkt(b"unpack ok\n") + + pkt(b"ng refs/heads/main the git grant does not list this branch\n") + + b"0000" + ) + + b"0000" + ) + # The pack is read to the end, so git gets to read the report rather than a broken pipe. + assert body.read() == b"" + assert recorder.repository_lookups == [] + assert recorder.token_lookups == [] + server.relay.forward.assert_not_called() # type: ignore[attr-defined] + assert recorder.counts == [ + ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_push_not_granted"}) + ] + [(error, context)] = recorder.errors + assert error.detail == ( # type: ignore[attr-defined] + "The git grant does not allow this push: refs/heads/main (the git grant does not list this branch)." + ) + assert context == {"repository_id": "5", "organization_id": "1", "referrer": REFERRER, "operation": "push"} + + @pytest.mark.parametrize( + "ref,old_id,new_id,reason", + [ + ("refs/tags/v1", OLD_ID, NEW_ID, "allows pushing branches only"), + ("refs/heads/seer/fix", OLD_ID, "0" * 40, "does not allow deleting a branch"), + ], + ) + def test_other_refused_updates(self, ref, old_id, new_id, reason): + body = make_push_body((old_id, new_id, ref), capabilities="report-status") + assert reason.encode() in b"".join(self.send(make_server(Recorder()), io.BytesIO(body)).content) + + def test_drains_no_further_than_content_length(self): + # A caller may pass a connection's stream, which goes on past the body, and reading on would wait for ever. + body = push_body("refs/heads/main") + stream = io.BytesIO(body + b"the next request") + response = self.send(make_server(Recorder()), stream, headers={"Content-Length": str(len(body))}) + assert response.status_code == 200 + assert stream.tell() == len(body) + + def test_client_that_asked_for_no_report(self): + recorder = Recorder() + body = io.BytesIO(push_body("refs/heads/main", capabilities="side-band-64k")) + assert_refused(self.send(make_server(recorder), body), 403, "does not allow this push: refs/heads/main") + assert body.read() == b"" + + def test_malformed(self): + recorder = Recorder() + assert_refused(self.send(make_server(recorder), io.BytesIO(b"garbage")), 400, "not pkt-lines") + assert recorder.repository_lookups == [] + assert recorder.counts == [ + ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_request_malformed"}) + ] + + def test_compressed(self): + response = self.send( + make_server(Recorder()), io.BytesIO(push_body("refs/heads/seer/fix")), headers={"Content-Encoding": "gzip"} + ) + assert_refused(response, 403, "compressed push") + + def test_client_closes_while_sending_its_updates(self): + recorder = Recorder() + response = self.send(make_server(recorder), Unreliable()) + assert response.status_code == 499 + assert recorder.errors == [] + + def test_version_1_push_grant_relays_unread(self): + recorder = Recorder() + server = make_server(recorder) + grant = sign_grant(KEY.private, make_v1_grant(operation="push")) + body = io.BytesIO(b"not a push the server could read") + request(server, grant, method="POST", route="git-receive-pack", query="", body=body) + assert body.tell() == 0 + assert server.relay.forward.call_args.kwargs["body"].read() == b"not a push the server could read" # type: ignore[attr-defined] From 9632fff9cc2ce512f46ecae13c89142ded4610fd Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 18:15:12 +0100 Subject: [PATCH 02/23] feat(git): Let push rules match branch patterns and allow only new branches Seer can let the pod push in two ways: add each branch to the grant before the pod pushes it, or let the pod start branches under a prefix and add each one once it exists. The second needs a rule that matches names it doesn't know yet and can't move a branch someone else made. A push rule now sets branch_patterns, full-match regular expressions, in place of branches, and create_only allows only updates whose old id is all zeros, which the provider refuses for a branch that exists. Several rules may name one repository and operation, and an update passes if any allows it, so a create_only pattern sits next to the branch the pod already made. Signing a version 1 Grant now warns, and the class is marked deprecated for type checkers. Verifying one stays quiet. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- bin/git-server | 2 +- src/scm/git/grant.py | 7 ++ src/scm/git/push.py | 45 +++++++++--- src/scm/git/relay.py | 20 +++--- src/scm/git/server.py | 17 +++-- src/scm/git/types.py | 75 ++++++++++++++------ tests/unit/test_git_grant.py | 130 ++++++++++++++++++++++++---------- tests/unit/test_git_push.py | 93 +++++++++++++++++++----- tests/unit/test_git_relay.py | 17 +++++ tests/unit/test_git_server.py | 29 +++++++- 11 files changed, 328 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index f73d5cbb..e76554dd 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The platform exposes four subsystems: - **Actions** — outbound SCM operations initiated by Sentry code. The `SourceCodeManager` class provides 70+ methods covering comments, reactions, pull requests, branches, git objects, reviews, and check runs. With more actions planned to be added as we port more use cases. - **Actions RPC** - outbound SCM commands exposed over the network. The `SourceCodeManager` is fully available over the network. This enables usage of the SCM for services outside the monolith. -- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. A `GrantV2` names the organization and a list of rules, each a repository, an operation (`FetchRule` or `PushRule`) and the branches it may touch, or `any_object` for a fetch that may read any object. `Grant`, the deprecated version 1, names one operation and its repositories. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. For a push under a push rule it first reads the ref updates, and answers a push that updates any other ref, deletes a branch or carries push options with a status report git shows as rejected refs. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it. +- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. A `GrantV2` names the organization and a list of rules, each a repository, an operation (`FetchRule` or `PushRule`) and the branches it may touch, or `any_object` for a fetch that may read any object. A push rule names its branches or full-match `branch_patterns`, and `create_only` limits it to new branches. A request passes if any rule allows it. `Grant`, the deprecated version 1, names one operation and its repositories. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. For a push under a push rule it first reads the ref updates, and answers a push that updates any other ref, deletes a branch or carries push options with a status report git shows as rejected refs. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it. - **Event Stream** — inbound webhook processing. SCM service-providers push events which are deserialized into typed, provider-neutral dataclasses (`CheckRunEvent`, `CommentEvent`, `PullRequestEvent`) which are then dispatched to registered listener functions. ### Why diff --git a/bin/git-server b/bin/git-server index f5ffdb25..77bd4f40 100755 --- a/bin/git-server +++ b/bin/git-server @@ -573,7 +573,7 @@ class Checks: self.git( push_grant, "-c", "http.postBuffer=65536", "push", url, f"HEAD:refs/heads/{branch}-unlisted", cwd=clone ), - "the git grant does not list this branch", + "the git grant does not cover this branch", ) pushed = self.expect_success( "chunked push with a push grant", diff --git a/src/scm/git/grant.py b/src/scm/git/grant.py index 318783a5..fb2c2dee 100644 --- a/src/scm/git/grant.py +++ b/src/scm/git/grant.py @@ -20,6 +20,7 @@ import hashlib import time +import warnings from collections.abc import Sequence from typing import Any @@ -55,6 +56,12 @@ def sign_grant(private_key: Secret, grant: Grant | GrantV2, *, now: float | None GitGrantKeyError if ``private_key`` is not an unencrypted Ed25519 private key in PEM, and a plain ValueError if the signed grant is longer than GRANT_MAX_LENGTH. """ + if isinstance(grant, Grant): + warnings.warn( + "Signing a version 1 git Grant is deprecated. Sign a GrantV2, whose rules can keep a push to branches.", + DeprecationWarning, + stacklevel=2, + ) key = _load_private_key(private_key) claims: dict[str, Any] = msgspec.to_builtins(grant) claims["exp"] = claims.pop("expires_at") diff --git a/src/scm/git/push.py b/src/scm/git/push.py index 4cca9dcf..4c8ea436 100644 --- a/src/scm/git/push.py +++ b/src/scm/git/push.py @@ -7,7 +7,7 @@ """ import re -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from typing import NamedTuple from scm.errors import ClientClosedRequest, GitPushNotGranted, GitRequestMalformed, RpcMalformedRequestHeaders @@ -29,6 +29,10 @@ class RefUpdate(NamedTuple): new_id: bytes ref: bytes + @property + def creates(self) -> bool: + return not self.old_id.strip(b"0") + @property def deletes(self) -> bool: return not self.new_id.strip(b"0") @@ -71,29 +75,50 @@ def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest return PushRequest(updates=updates, capabilities=capabilities, head=reader.read_so_far()) -def refused_updates(rule: PushRule, request: PushRequest) -> dict[bytes, str]: - """Return why ``rule`` refuses each ref of ``request``, or nothing if it allows every update. +def refused_updates(rules: Sequence[PushRule], request: PushRequest) -> dict[bytes, str]: + """Return why ``rules`` refuse each ref of ``request``, or nothing if every update has a rule that allows it. A push is relayed whole or not at all, so once one update is refused, every other ref gets a reason too. """ if "push-options" in request.capabilities: # Push options ask the provider to act on the push, such as GitLab opening a merge request or skipping CI. return {update.ref: "the git endpoint does not relay push options" for update in request.updates} - branches = {f"refs/heads/{branch}".encode() for branch in rule.branches} refused: dict[bytes, str] = {} for update in request.updates: - if not update.ref.startswith(b"refs/heads/"): - refused[update.ref] = "the git grant allows pushing branches only" - elif update.ref not in branches: - refused[update.ref] = "the git grant does not list this branch" - elif update.deletes: - refused[update.ref] = "the git grant does not allow deleting a branch" + refusals = [_refusal(rule, update) for rule in rules] + reasons = [refusal for refusal in refusals if refusal is not None] + if len(reasons) == len(refusals): + # The reason of the rule the update came closest to passing says the most. + refused[update.ref] = max(reasons, default=_NOT_COVERED)[1] if refused: for update in request.updates: refused.setdefault(update.ref, "refused with the rest of the push") return refused +_NOT_A_BRANCH = (0, "the git grant allows pushing branches only") +_NOT_COVERED = (1, "the git grant does not cover this branch") +_NOT_NEW = (2, "the git grant allows only creating this branch") +_DELETES = (3, "the git grant does not allow deleting a branch") + + +def _refusal(rule: PushRule, update: RefUpdate) -> tuple[int, str] | None: + """Return why ``rule`` refuses ``update``, ranked by how far the update got, or None if the rule allows it.""" + if not update.ref.startswith(b"refs/heads/"): + return _NOT_A_BRANCH + try: + branch = update.ref.removeprefix(b"refs/heads/").decode() + except UnicodeDecodeError: + return _NOT_COVERED + if not rule.covers(branch): + return _NOT_COVERED + if update.deletes: + return _DELETES + if rule.create_only and not update.creates: + return _NOT_NEW + return None + + def refusal_report(request: PushRequest, refused: Mapping[bytes, str]) -> bytes | None: """Return the response body that tells git why each ref was refused, or None if the client asked for none. diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py index c56b2d80..226c4481 100644 --- a/src/scm/git/relay.py +++ b/src/scm/git/relay.py @@ -72,10 +72,10 @@ def authorize_request( Fetches reach only upload-pack and pushes only receive-pack. The grant must name the repository in the same form the request uses: a database id does not match the same repository's ``(provider, external id)`` pair. - A ``GrantV2`` allows a request only if it has a rule for the request's repository and operation. A push rule's + A ``GrantV2`` allows a request only if it has a rule for the request's repository and operation. Push rules' branches are kept by GitServer, which reads the push's ref updates before it relays the push, so a caller that - relays a push some other way must check them itself. A fetch rule that lists branches is refused, since nothing - reads a fetch's body to keep it to them. + relays a push some other way must check them itself. A fetch needs a fetch rule for any object, since nothing + reads a fetch's body to keep it to a rule's branches. """ operation = _ROUTES.get((method, route, query)) if operation is None: @@ -87,16 +87,14 @@ def authorize_request( raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") return operation - rule = grant.rule_for(repository_id, operation) - if rule is None: - other = grant.rule_for(repository_id, "push" if operation == "fetch" else "fetch") - if other is None: + rules = grant.rules_for(repository_id, operation) + if not rules: + other: GitOperation = "push" if operation == "fetch" else "fetch" + if not grant.rules_for(repository_id, other): raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") - raise GitOperationNotGranted( - detail=f"The git grant allows {other.operation} on this repository, not {operation}." - ) + raise GitOperationNotGranted(detail=f"The git grant allows {other} on this repository, not {operation}.") # Delete this once GitServer keeps a fetch to its rule's branches. - if isinstance(rule, FetchRule) and rule.branches is not None: + if operation == "fetch" and not any(isinstance(rule, FetchRule) and rule.any_object for rule in rules): raise GitOperationNotGranted( detail="The git endpoint cannot keep a fetch to a rule's branches yet, so it refuses the rule." ) diff --git a/src/scm/git/server.py b/src/scm/git/server.py index 17c3fa21..58c6e5d1 100644 --- a/src/scm/git/server.py +++ b/src/scm/git/server.py @@ -76,9 +76,10 @@ class GitServer: does not allow never reaches the database, the token supplier or the provider. Give it a relay whose adapter refuses internal addresses; see ``GitRelay``. - A push under a ``GrantV2`` is kept to its rule's branches. The server reads the push's ref updates, which come - before the pack, and refuses the whole push if one of them updates a branch the rule does not list, deletes a - branch, or updates any other ref, such as a tag, and refuses push options and signed pushes too. A client that + A push under a ``GrantV2`` is kept to its push rules. The server reads the push's ref updates, which come before + the pack, and refuses the whole push if no rule allows one of them: a branch no rule covers, an existing branch + under a ``create_only`` rule, a deletion, or any ref that isn't a branch, such as a tag. It refuses push options + and signed pushes too. A client that asked for a status report gets one, a 200 that git shows as a rejected ref with its reason. A push the rule allows is relayed with the updates the server read followed by the rest of the body, unread. A push under a ``Grant`` is relayed unread, as before rules. @@ -164,11 +165,15 @@ def handle( operation = authorize_request(grant, repository_id, method, route, query) context["operation"] = operation relayed_body: Readable | None = counted_body - rule = grant.rule_for(repository_id, operation) if isinstance(grant, GrantV2) else None - if isinstance(rule, PushRule) and method == "POST" and counted_body is not None: + push_rules = ( + [rule for rule in grant.rules_for(repository_id, operation) if isinstance(rule, PushRule)] + if isinstance(grant, GrantV2) + else [] + ) + if push_rules and method == "POST" and counted_body is not None: push_body = bounded_body(headers, counted_body) push = read_push_request(headers, push_body) - refused = refused_updates(rule, push) + refused = refused_updates(push_rules, push) if refused: ref, reason = next(iter(refused.items())) raise GitPushNotGranted( diff --git a/src/scm/git/types.py b/src/scm/git/types.py index bffb0f57..4a37fba6 100644 --- a/src/scm/git/types.py +++ b/src/scm/git/types.py @@ -1,4 +1,5 @@ import re +import warnings from collections.abc import Callable from typing import ClassVar, Literal, Protocol @@ -63,33 +64,60 @@ class PushRule( frozen=True, kw_only=True, forbid_unknown_fields=True, + omit_defaults=True, tag="push", tag_field="operation", ): - """Lets the holder update the listed ``branches`` of ``repository``, and no other ref: no tag, and no deletion. + """Lets the holder create or update the branches of ``repository`` the rule covers, and no other ref: no tag, + and no deletion. + + The rule covers the branches it lists in ``branches``, or every branch whose whole name matches one of + ``branch_patterns``, and sets exactly one of the two. With ``create_only`` it allows only creating a covered + branch that doesn't exist yet, so a pattern can let the holder start new branches without moving any that exist. + Without it, a push may move a covered branch to any commit, so a force push passes. - A push may move a listed branch to any commit, so a force push passes. Don't list the repository's default - branch. The git endpoint doesn't know which branch that is, so keeping it out is the signer's job, with the - provider's branch protection behind it. + Don't cover the repository's default branch, by name or by pattern. The git endpoint doesn't know which branch + that is, so keeping it out is the signer's job, with the provider's branch protection behind it. """ operation: ClassVar[GitOperation] = "push" repository: RepositoryId - branches: list[BranchName] + branches: list[BranchName] | None = None """Branch names as ``git branch`` takes them, without ``refs/heads/``.""" + branch_patterns: list[str] | None = None + """Python regular expressions, each matched against a branch's whole name without ``refs/heads/``, such as + ``seer/.+``. The pushing client chooses the names they are matched against, so a pattern must not be one that + backtracks catastrophically.""" + create_only: bool = False + """Allow only creating a covered branch. git sends a new branch with an old object id of all zeros, and the + provider refuses such an update if the branch already exists.""" def __post_init__(self) -> None: - _check_branches(self.branches) + if self.branches is None and self.branch_patterns is None: + raise ValueError("A push rule must list its branches or set branch_patterns.") + if self.branches is not None and self.branch_patterns is not None: + raise ValueError("A push rule must not both list branches and set branch_patterns.") + if self.branches is not None: + _check_branches(self.branches) + if self.branch_patterns is not None: + _check_patterns(self.branch_patterns) + + def covers(self, branch: BranchName) -> bool: + """Return whether the rule covers ``branch``, a name without ``refs/heads/``.""" + if self.branches is not None: + return branch in self.branches + return any(re.fullmatch(pattern, branch) for pattern in self.branch_patterns or ()) type Rule = FetchRule | PushRule +@warnings.deprecated("Grant is version 1 of the git grant. Sign a GrantV2, whose rules can keep a push to branches.") class Grant(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): """Permission to run one git operation against a fixed set of an organization's repositories. - Deprecated: sign a ``GrantV2``, which can also keep a fetch or a push to branches. Verifiers still accept this - version, so a signer can switch once they can read both. + Deprecated: sign a ``GrantV2``, which can also keep a push to branches. Verifiers still accept this version, so a + signer can switch once they can read both. A push grant lets its holder update any ref of its repositories, including tags and deletions, since GitServer relays its pushes unread. Each repository is a ``RepositoryId`` under the grant's organization, as in the RPC @@ -117,8 +145,9 @@ class GrantV2(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=T Seer signs a grant for each sandbox, and for its own git requests, and Sentry's git endpoint verifies it on every request. A sandbox holds one grant at a time, so a grant that lets a pod both fetch and push holds a rule - for each. A request runs only if a rule for its repository and operation allows it, and a grant holds at most one - rule per repository and operation. + for each. A request runs only if a rule for its repository and operation allows it. Several rules may name the + same repository and operation, and a request runs if any of them allows it, so their order doesn't matter: a + ``create_only`` rule with a pattern can sit next to a rule listing a branch the holder created and may update. Each rule names its repository as a ``RepositoryId`` under the grant's organization, as in the RPC endpoint: Sentry's repository id or a ``(provider, external id)`` pair. Several repositories leave room for submodules and @@ -145,24 +174,28 @@ class GrantV2(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=T def __post_init__(self) -> None: if not self.rules: raise ValueError("A git grant must hold at least one rule.") - seen: set[tuple[GitOperation, RepositoryId]] = set() - for rule in self.rules: - key = (rule.operation, rule.repository) - if key in seen: - raise ValueError(f"A git grant holds more than one {rule.operation} rule for {rule.repository!r}.") - seen.add(key) - def rule_for(self, repository: RepositoryId, operation: GitOperation) -> Rule | None: - """Return the rule for ``operation`` on ``repository``, named as the rule names it, or None if there is none.""" - return next( - (rule for rule in self.rules if rule.repository == repository and rule.operation == operation), None - ) + def rules_for(self, repository: RepositoryId, operation: GitOperation) -> list[Rule]: + """Return the rules for ``operation`` on ``repository``, named as the rules name it.""" + return [rule for rule in self.rules if rule.repository == repository and rule.operation == operation] # What git check-ref-format refuses in a ref name: control characters, space, ~ ^ : ? * [ \, "..", "@{" and "//". _NOT_IN_A_REF = re.compile(r"[\x00-\x20\x7f~^:?*\[\\]|\.\.|@\{|//") +def _check_patterns(patterns: list[str]) -> None: + if not patterns: + raise ValueError("A rule's branch pattern list must not be empty.") + for pattern in patterns: + if not pattern: + raise ValueError("A branch pattern must not be empty.") + try: + re.compile(pattern) + except re.error as e: + raise ValueError(f"{pattern!r} is not a regular expression: {e}") from None + + def _check_branches(branches: list[BranchName]) -> None: if not branches: raise ValueError("A rule's branch list must not be empty.") diff --git a/tests/unit/test_git_grant.py b/tests/unit/test_git_grant.py index a4553969..49686f4a 100644 --- a/tests/unit/test_git_grant.py +++ b/tests/unit/test_git_grant.py @@ -2,6 +2,7 @@ import hashlib import hmac import time +import warnings from collections.abc import Callable from typing import Any @@ -555,11 +556,20 @@ def test_fetch_and_push_rules_for_one_repository(self): def test_claims_hold_only_what_a_rule_sets(self): # Shorter, and a reader sees which fetch rules reach any object. - grant = make_grant(rules=[FetchRule(repository=5, branches=["main"]), FetchRule(repository=6, any_object=True)]) + grant = make_grant( + rules=[ + FetchRule(repository=5, branches=["main"]), + FetchRule(repository=6, any_object=True), + PushRule(repository=5, branches=["seer/fix"]), + PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + ] + ) claims = jwt.decode(sign_grant(KEY.private, grant, now=NOW), options={"verify_signature": False}) assert claims["rules"] == [ {"operation": "fetch", "repository": 5, "branches": ["main"]}, {"operation": "fetch", "repository": 6, "any_object": True}, + {"operation": "push", "repository": 5, "branches": ["seer/fix"]}, + {"operation": "push", "repository": 5, "branch_patterns": ["seer/.+"], "create_only": True}, ] @pytest.mark.parametrize( @@ -574,9 +584,29 @@ def test_claims_hold_only_what_a_rule_sets(self): ), ({"operation": "fetch", "repository": 5, "branches": []}, "must not be empty"), ({"operation": "push", "repository": 5, "branches": []}, "must not be empty"), - ({"operation": "push", "repository": 5}, "missing required field `branches`"), - ({"operation": "push", "repository": 5, "branches": None}, "Expected `array`, got `null`"), - ({"operation": "push", "repository": 5, "any_object": True}, "unknown field `any_object`"), + ({"operation": "push", "repository": 5}, "must list its branches or set branch_patterns"), + ({"operation": "push", "repository": 5, "branches": None}, "must list its branches or set branch_patterns"), + ( + {"operation": "push", "repository": 5, "create_only": True}, + "must list its branches or set branch_patterns", + ), + ( + {"operation": "push", "repository": 5, "branches": ["a"], "branch_patterns": ["a"]}, + "must not both list branches and set branch_patterns", + ), + ({"operation": "push", "repository": 5, "branch_patterns": []}, "must not be empty"), + ({"operation": "push", "repository": 5, "branch_patterns": [""]}, "must not be empty"), + ({"operation": "push", "repository": 5, "branch_patterns": ["seer/("]}, "is not a regular expression"), + ({"operation": "push", "repository": 5, "branches": ["a"], "create_only": 1}, "Expected `bool`"), + ( + {"operation": "push", "repository": 5, "branches": ["a"], "any_object": True}, + "unknown field `any_object`", + ), + ({"operation": "fetch", "repository": 5, "any_object": True, "create_only": True}, "unknown field"), + ( + {"operation": "fetch", "repository": 5, "branch_patterns": ["seer/.+"]}, + "unknown field `branch_patterns`", + ), ({"operation": "fetch", "repository": 5, "branches": ["main"], "deepen": 50}, "unknown field `deepen`"), ({"operation": "delete", "repository": 5, "branches": ["main"]}, "Invalid value 'delete'"), ({"repository": 5, "any_object": True}, "missing required field `operation`"), @@ -604,36 +634,19 @@ def test_no_rules(self): make_grant(rules=[]) assert_invalid(sign_claims(rule_claims()), "at least one rule") - @pytest.mark.parametrize( - "rules,error", - [ - ( - [{"operation": "fetch", "repository": 5, "any_object": True}] * 2, - "more than one fetch rule for 5", - ), - ( - [ - {"operation": "fetch", "repository": 5, "any_object": True}, - {"operation": "fetch", "repository": 5, "branches": ["main"]}, - ], - "more than one fetch rule for 5", - ), - ( - [ - {"operation": "push", "repository": ["github", "123"], "branches": ["a"]}, - {"operation": "push", "repository": ["github", "123"], "branches": ["b"]}, - ], - "more than one push rule for ('github', '123')", - ), - ], - ) - def test_two_rules_for_one_repository_and_operation(self, rules, error): - # Otherwise which one applies would depend on the order of the rules. - assert_invalid(sign_claims(rule_claims(*rules)), error) - - def test_two_rules_for_one_repository_and_operation_cannot_be_built(self): - with pytest.raises(ValueError, match="more than one push rule for 5"): - make_grant(rules=[PushRule(repository=5, branches=["a"]), PushRule(repository=5, branches=["b"])]) + def test_several_rules_for_one_repository_and_operation(self): + # A request passes if any of them allows it, so their order doesn't matter. + grant = make_grant( + rules=[ + FetchRule(repository=5, any_object=True), + FetchRule(repository=5, branches=["main"]), + PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + PushRule(repository=5, branches=["seer/fix"]), + ] + ) + assert verify_grant([KEY.public], sign_grant(KEY.private, grant, now=NOW), now=NOW) == grant + assert grant.rules_for(5, "push") == grant.rules[2:] + assert grant.rules_for(("github", "123"), "push") == [] def test_one_repository_named_both_ways(self): # Nothing matches a database id to a provider id, so the two are separate repositories here, as in requests. @@ -672,6 +685,34 @@ def test_one_repository_named_both_ways(self): ] +class TestBranchPatterns: + @pytest.mark.parametrize( + "patterns,branch,covered", + [ + (["seer/.+"], "seer/fix", True), + (["seer/.+"], "seer/fix/deeper", True), + (["seer/.+"], "seer/", False), + # Matched against the whole name, not searched for in it. + (["seer/.+"], "main-seer/fix", False), + (["seer/[a-z-]+"], "seer/fix/deeper", False), + (["seer"], "seer/fix", False), + (["seer/[a-z]+", "cursor/.+"], "cursor/x", True), + (["seer/.+"], "Seer/fix", False), + ], + ) + def test_covers(self, patterns, branch, covered): + assert PushRule(repository=5, branch_patterns=patterns).covers(branch) is covered + + def test_listed_branches(self): + rule = PushRule(repository=5, branches=["seer/fix"]) + assert rule.covers("seer/fix") + assert not rule.covers("seer/fix2") + + def test_cannot_be_built_with_an_invalid_pattern(self): + with pytest.raises(ValueError, match="'seer/\\(' is not a regular expression"): + PushRule(repository=5, branch_patterns=["seer/("]) + + class TestBranchNames: @pytest.mark.parametrize("name", INVALID_BRANCH_NAMES) def test_invalid(self, name): @@ -693,18 +734,31 @@ def test_valid(self, name): assert PushRule(repository=5, branches=[name]).branches == [name] +def sign_v1(grant: Grant, **kwargs) -> str: + with pytest.deprecated_call(match="Sign a GrantV2"): + return sign_grant(KEY.private, grant, now=NOW, **kwargs) + + class TestVersion1: """Grant, which GrantV2 replaces, still signs and verifies as it did.""" @pytest.mark.parametrize("operation", ["fetch", "push"]) def test_verifies_what_it_signed(self, operation): grant = make_v1_grant(operation=operation) - verified = verify_grant([KEY.public], sign_grant(KEY.private, grant, now=NOW), now=NOW) + verified = verify_grant([KEY.public], sign_v1(grant), now=NOW) assert verified == grant assert isinstance(verified, Grant) + def test_deprecated(self): + # Type checkers and IDEs flag code that uses it, and signing one warns, but verifying one doesn't. + assert "Sign a GrantV2" in Grant.__deprecated__ # type: ignore[attr-defined] + token = sign_v1(make_v1_grant()) + with warnings.catch_warnings(): + warnings.simplefilter("error") + verify_grant([KEY.public], token, now=NOW) + def test_token_is_a_jwt_with_the_grants_claims(self): - token = sign_grant(KEY.private, make_v1_grant(), now=NOW) + token = sign_v1(make_v1_grant()) assert jwt.decode(token, options={"verify_signature": False}) == { "organization_id": 1, "referrer": "seer.coding_job", @@ -724,7 +778,7 @@ def test_without_a_version(self): def test_expired(self): with pytest.raises(GitGrantExpired): - verify_grant([KEY.public], sign_grant(KEY.private, make_v1_grant(expires_at=NOW), now=NOW), now=NOW) + verify_grant([KEY.public], sign_v1(make_v1_grant(expires_at=NOW)), now=NOW) @pytest.mark.parametrize( "change", @@ -739,4 +793,4 @@ def test_signed_but_invalid_claims(self, change): assert_invalid(sign_claims(claims_of(make_v1_grant()) | change), "payload is invalid") def test_no_repositories(self): - assert_invalid(sign_grant(KEY.private, make_v1_grant(repositories=[]), now=NOW), "names no repositories") + assert_invalid(sign_v1(make_v1_grant(repositories=[])), "names no repositories") diff --git a/tests/unit/test_git_push.py b/tests/unit/test_git_push.py index dd0a3b26..7e17eb3d 100644 --- a/tests/unit/test_git_push.py +++ b/tests/unit/test_git_push.py @@ -16,7 +16,7 @@ from scm.git.types import PushRule from tests.unit.git_builders import NEW_ID, OLD_ID, PACK, ZERO_ID, make_push_body, pkt -RULE = PushRule(repository=5, branches=["seer/fix", "seer/other"]) +RULES = [PushRule(repository=5, branches=["seer/fix", "seer/other"])] def read(body: bytes, headers: dict[str, str] | None = None) -> tuple[PushRequest, io.BytesIO]: @@ -153,22 +153,22 @@ def request_for(*updates: tuple[str, str, str], capabilities: str = "report-stat class TestRefusedUpdates: def test_listed_branches(self): request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), (ZERO_ID, NEW_ID, "refs/heads/seer/other")) - assert refused_updates(RULE, request) == {} + assert refused_updates(RULES, request) == {} def test_force_push_to_a_listed_branch(self): # Nothing here knows whether the new commit descends from the old one, so the provider's protection decides. - assert refused_updates(RULE, request_for(("3" * 40, NEW_ID, "refs/heads/seer/fix"))) == {} + assert refused_updates(RULES, request_for(("3" * 40, NEW_ID, "refs/heads/seer/fix"))) == {} def test_probe(self): - assert refused_updates(RULE, read(b"0000")[0]) == {} + assert refused_updates(RULES, read(b"0000")[0]) == {} @pytest.mark.parametrize( "ref,reason", [ - ("refs/heads/main", "the git grant does not list this branch"), - ("refs/heads/seer/fix/deeper", "the git grant does not list this branch"), - ("refs/heads/seer", "the git grant does not list this branch"), - ("refs/heads/Seer/fix", "the git grant does not list this branch"), + ("refs/heads/main", "the git grant does not cover this branch"), + ("refs/heads/seer/fix/deeper", "the git grant does not cover this branch"), + ("refs/heads/seer", "the git grant does not cover this branch"), + ("refs/heads/Seer/fix", "the git grant does not cover this branch"), ("refs/tags/seer/fix", "the git grant allows pushing branches only"), ("refs/pull/1/head", "the git grant allows pushing branches only"), ("refs/notes/commits", "the git grant allows pushing branches only"), @@ -177,22 +177,79 @@ def test_probe(self): ], ) def test_refs_the_rule_does_not_list(self, ref, reason): - assert refused_updates(RULE, request_for((OLD_ID, NEW_ID, ref))) == {ref.encode(): reason} + assert refused_updates(RULES, request_for((OLD_ID, NEW_ID, ref))) == {ref.encode(): reason} def test_deleting_a_listed_branch(self): - refused = refused_updates(RULE, request_for((OLD_ID, ZERO_ID, "refs/heads/seer/fix"))) + refused = refused_updates(RULES, request_for((OLD_ID, ZERO_ID, "refs/heads/seer/fix"))) assert refused == {b"refs/heads/seer/fix": "the git grant does not allow deleting a branch"} def test_one_refused_update_refuses_the_push(self): request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), (OLD_ID, NEW_ID, "refs/heads/main")) - assert refused_updates(RULE, request) == { - b"refs/heads/main": "the git grant does not list this branch", + assert refused_updates(RULES, request) == { + b"refs/heads/main": "the git grant does not cover this branch", b"refs/heads/seer/fix": "refused with the rest of the push", } + def test_no_rules(self): + refused = refused_updates([], request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"))) + assert refused == {b"refs/heads/seer/fix": "the git grant does not cover this branch"} + + @pytest.mark.parametrize( + "old_id,ref,reason", + [ + (ZERO_ID, "refs/heads/seer/new", None), + (OLD_ID, "refs/heads/seer/new", "the git grant allows only creating this branch"), + (ZERO_ID, "refs/heads/main", "the git grant does not cover this branch"), + (ZERO_ID, "refs/tags/seer/new", "the git grant allows pushing branches only"), + ], + ) + def test_create_only_pattern(self, old_id, ref, reason): + rules = [PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True)] + refused = refused_updates(rules, request_for((old_id, NEW_ID, ref))) + assert refused == ({} if reason is None else {ref.encode(): reason}) + + def test_create_only_listed_branch(self): + rules = [PushRule(repository=5, branches=["seer/fix"], create_only=True)] + assert refused_updates(rules, request_for((ZERO_ID, NEW_ID, "refs/heads/seer/fix"))) == {} + assert refused_updates(rules, request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"))) == { + b"refs/heads/seer/fix": "the git grant allows only creating this branch" + } + + def test_non_utf8_branch_against_a_pattern(self): + rules = [PushRule(repository=5, branch_patterns=[".+"])] + ref = b"refs/heads/\xff" + request = PushRequest( + updates=[RefUpdate(old_id=OLD_ID.encode(), new_id=NEW_ID.encode(), ref=ref)], + capabilities=frozenset(), + head=b"", + ) + assert refused_updates(rules, request) == {ref: "the git grant does not cover this branch"} + + def test_any_rule_allows_an_update(self): + # The agent may start branches under seer/ and keep pushing to the one Seer added after its first push. + rules = [ + PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + PushRule(repository=5, branches=["seer/fix"]), + ] + request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), (ZERO_ID, NEW_ID, "refs/heads/seer/next")) + assert refused_updates(rules, request) == {} + assert refused_updates(list(reversed(rules)), request) == {} + + def test_reason_of_the_rule_the_update_came_closest_to_passing(self): + rules = [ + PushRule(repository=5, branches=["other"]), + PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + ] + assert refused_updates(rules, request_for((OLD_ID, NEW_ID, "refs/heads/seer/old"))) == { + b"refs/heads/seer/old": "the git grant allows only creating this branch" + } + assert refused_updates(rules, request_for((OLD_ID, ZERO_ID, "refs/heads/seer/old"))) == { + b"refs/heads/seer/old": "the git grant does not allow deleting a branch" + } + def test_push_options(self): request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), capabilities="report-status push-options") - assert refused_updates(RULE, request) == { + assert refused_updates(RULES, request) == { b"refs/heads/seer/fix": "the git endpoint does not relay push options" } @@ -210,8 +267,8 @@ def unband(response: bytes) -> bytes: class TestRefusalReport: - REFUSED = {b"refs/heads/main": "the git grant does not list this branch"} - REPORT = pkt(b"unpack ok\n") + pkt(b"ng refs/heads/main the git grant does not list this branch\n") + b"0000" + REFUSED = {b"refs/heads/main": "the git grant does not cover this branch"} + REPORT = pkt(b"unpack ok\n") + pkt(b"ng refs/heads/main the git grant does not cover this branch\n") + b"0000" def test_report_status(self): request = request_for((OLD_ID, NEW_ID, "refs/heads/main"), capabilities="report-status") @@ -229,7 +286,7 @@ def test_side_band_64k(self): def test_side_band_splits_at_1000_bytes(self): updates = [(OLD_ID, NEW_ID, f"refs/heads/branch-{i:03d}") for i in range(40)] request = request_for(*updates, capabilities="report-status side-band") - refused = {update.ref: "the git grant does not list this branch" for update in request.updates} + refused = {update.ref: "the git grant does not cover this branch" for update in request.updates} response = refusal_report(request, refused) assert response is not None assert max(int(response[i : i + 4], 16) for i in range(0, len(response) - 4, 1000)) <= 1000 @@ -238,12 +295,12 @@ def test_side_band_splits_at_1000_bytes(self): def test_every_update_in_order(self): request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), (OLD_ID, NEW_ID, "refs/heads/main")) - response = refusal_report(request, refused_updates(RULE, request)) + response = refusal_report(request, refused_updates(RULES, request)) assert response is not None assert unband(response) == ( pkt(b"unpack ok\n") + pkt(b"ng refs/heads/seer/fix refused with the rest of the push\n") - + pkt(b"ng refs/heads/main the git grant does not list this branch\n") + + pkt(b"ng refs/heads/main the git grant does not cover this branch\n") + b"0000" ) diff --git a/tests/unit/test_git_relay.py b/tests/unit/test_git_relay.py index 47a0c431..99833a36 100644 --- a/tests/unit/test_git_relay.py +++ b/tests/unit/test_git_relay.py @@ -771,6 +771,23 @@ def test_rule_for_another_repository_does_not_apply(self): with pytest.raises(GitOperationNotGranted, match="allows fetch on this repository, not push"): authorize_request(grant, 5, "POST", "git-receive-pack", "") + def test_any_of_several_rules(self): + grant = make_grant( + rules=[ + FetchRule(repository=5, branches=["main"]), + FETCH_ANY, + PUSH_BRANCH, + PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + ] + ) + assert authorize_request(grant, 5, "POST", "git-upload-pack", "") == "fetch" + assert authorize_request(grant, 5, "POST", "git-receive-pack", "") == "push" + + def test_fetch_rules_that_all_list_branches_are_refused(self): + grant = make_grant(rules=[FetchRule(repository=5, branches=["main"]), FetchRule(repository=5, branches=["a"])]) + with pytest.raises(GitOperationNotGranted, match="cannot keep a fetch to a rule's branches"): + authorize_request(grant, 5, "POST", "git-upload-pack", "") + @pytest.mark.parametrize("route", FETCH_ROUTES) def test_fetch_rule_that_lists_branches_is_refused(self, route): # Nothing reads a fetch's body, so nothing would keep the fetch to the rule's branches. diff --git a/tests/unit/test_git_server.py b/tests/unit/test_git_server.py index 1f7bcb6c..ee7f2e34 100644 --- a/tests/unit/test_git_server.py +++ b/tests/unit/test_git_server.py @@ -801,7 +801,7 @@ def test_unlisted_branch_gets_a_report(self): pkt( b"\1" + pkt(b"unpack ok\n") - + pkt(b"ng refs/heads/main the git grant does not list this branch\n") + + pkt(b"ng refs/heads/main the git grant does not cover this branch\n") + b"0000" ) + b"0000" @@ -816,7 +816,7 @@ def test_unlisted_branch_gets_a_report(self): ] [(error, context)] = recorder.errors assert error.detail == ( # type: ignore[attr-defined] - "The git grant does not allow this push: refs/heads/main (the git grant does not list this branch)." + "The git grant does not allow this push: refs/heads/main (the git grant does not cover this branch)." ) assert context == {"repository_id": "5", "organization_id": "1", "referrer": REFERRER, "operation": "push"} @@ -839,6 +839,28 @@ def test_drains_no_further_than_content_length(self): assert response.status_code == 200 assert stream.tell() == len(body) + def test_create_only_pattern_and_listed_branch(self): + grant = sign_grant( + KEY.private, + make_grant( + rules=[ + PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + PushRule(repository=5, branches=["seer/fix"]), + ] + ), + ) + server = make_server(Recorder()) + for body in ( + make_push_body(("0" * 40, NEW_ID, "refs/heads/seer/next")), + make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")), + ): + response = request(server, grant, method="POST", route="git-receive-pack", query="", body=io.BytesIO(body)) + assert response.status_code == 200 + assert server.relay.forward.call_count == 2 # type: ignore[attr-defined] + refused = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/next"), capabilities="report-status") + response = request(server, grant, method="POST", route="git-receive-pack", query="", body=io.BytesIO(refused)) + assert b"the git grant allows only creating this branch" in b"".join(response.content) + def test_client_that_asked_for_no_report(self): recorder = Recorder() body = io.BytesIO(push_body("refs/heads/main", capabilities="side-band-64k")) @@ -868,7 +890,8 @@ def test_client_closes_while_sending_its_updates(self): def test_version_1_push_grant_relays_unread(self): recorder = Recorder() server = make_server(recorder) - grant = sign_grant(KEY.private, make_v1_grant(operation="push")) + with pytest.deprecated_call(): + grant = sign_grant(KEY.private, make_v1_grant(operation="push")) body = io.BytesIO(b"not a push the server could read") request(server, grant, method="POST", route="git-receive-pack", query="", body=body) assert body.tell() == 0 From 33cb9a9353aa96583791978067d386ad83abcacb Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 19:09:15 +0100 Subject: [PATCH 03/23] docs(git): Describe only GrantV2 in the README Grant is deprecated, so the README, which reads as the module's docs, points new callers at GrantV2 alone. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e76554dd..4cb3d5ff 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The platform exposes four subsystems: - **Actions** — outbound SCM operations initiated by Sentry code. The `SourceCodeManager` class provides 70+ methods covering comments, reactions, pull requests, branches, git objects, reviews, and check runs. With more actions planned to be added as we port more use cases. - **Actions RPC** - outbound SCM commands exposed over the network. The `SourceCodeManager` is fully available over the network. This enables usage of the SCM for services outside the monolith. -- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. A `GrantV2` names the organization and a list of rules, each a repository, an operation (`FetchRule` or `PushRule`) and the branches it may touch, or `any_object` for a fetch that may read any object. A push rule names its branches or full-match `branch_patterns`, and `create_only` limits it to new branches. A request passes if any rule allows it. `Grant`, the deprecated version 1, names one operation and its repositories. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. For a push under a push rule it first reads the ref updates, and answers a push that updates any other ref, deletes a branch or carries push options with a status report git shows as rejected refs. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it. +- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. A `GrantV2` names the organization and a list of rules, each a repository, an operation (`FetchRule` or `PushRule`) and the branches it may touch, or `any_object` for a fetch that may read any object. A push rule names its branches or full-match `branch_patterns`, and `create_only` limits it to new branches. A request passes if any rule allows it. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. For a push under a push rule it first reads the ref updates, and answers a push that updates any other ref, deletes a branch or carries push options with a status report git shows as rejected refs. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it. - **Event Stream** — inbound webhook processing. SCM service-providers push events which are deserialized into typed, provider-neutral dataclasses (`CheckRunEvent`, `CommentEvent`, `PullRequestEvent`) which are then dispatched to registered listener functions. ### Why From bdabfebdc95c45e5d20ead438efc11c0bd49a520 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 19:12:49 +0100 Subject: [PATCH 04/23] fix(git): Name the malformed body error after the body, and sign v1 quietly git_malformed_request_body matches rpc_malformed_request_body and says which part of the request was wrong. Signing a version 1 Grant no longer warns at runtime, since the class's deprecation already shows in type checkers and IDEs. Co-Authored-By: Claude Opus 5.5 --- src/scm/errors.py | 12 ++++++------ src/scm/git/grant.py | 7 ------- src/scm/git/push.py | 18 +++++++++--------- src/scm/git/server.py | 2 +- tests/unit/test_git_grant.py | 12 +++--------- tests/unit/test_git_push.py | 10 +++++----- tests/unit/test_git_server.py | 5 ++--- 7 files changed, 26 insertions(+), 40 deletions(-) diff --git a/src/scm/errors.py b/src/scm/errors.py index d206baa8..81496e2d 100644 --- a/src/scm/errors.py +++ b/src/scm/errors.py @@ -3,13 +3,13 @@ type ErrorCode = Literal[ "git_grant_expired", "git_invalid_grant", + "git_malformed_request_body", "git_operation_not_granted", "git_provider_not_supported", "git_push_not_granted", "git_remote_not_https", "git_remote_refused", "git_repository_not_granted", - "git_request_malformed", "git_route_not_supported", "git_token_rejected", "git_token_unavailable", @@ -53,13 +53,13 @@ ERROR_CODES: dict[ErrorCode, str] = { "git_grant_expired": "The git grant has expired.", "git_invalid_grant": "The git grant was missing, malformed or forged.", + "git_malformed_request_body": "The request body is not one the git endpoint can read.", "git_operation_not_granted": "The git grant does not allow this operation.", "git_provider_not_supported": "The repository's provider has no git remote.", "git_push_not_granted": "The git grant does not allow the push's ref updates.", "git_remote_not_https": "The repository's git remote is not an https URL.", "git_remote_refused": "The repository's git remote is at an address Sentry does not connect to.", "git_repository_not_granted": "The git grant does not cover this repository.", - "git_request_malformed": "The request body is not a git request the git endpoint can read.", "git_route_not_supported": "The request is not a git fetch or push.", "git_token_rejected": "The provider rejected Sentry's credentials for the repository.", "git_token_unavailable": "No provider token could be obtained for the repository.", @@ -174,6 +174,10 @@ class GitInvalidGrant(GitSCMCodedError): code = "git_invalid_grant" +class GitMalformedRequestBody(GitSCMCodedError): + code = "git_malformed_request_body" + + class GitOperationNotGranted(GitSCMCodedError): code = "git_operation_not_granted" @@ -198,10 +202,6 @@ class GitRepositoryNotGranted(GitSCMCodedError): code = "git_repository_not_granted" -class GitRequestMalformed(GitSCMCodedError): - code = "git_request_malformed" - - class GitRouteNotSupported(GitSCMCodedError): code = "git_route_not_supported" diff --git a/src/scm/git/grant.py b/src/scm/git/grant.py index fb2c2dee..318783a5 100644 --- a/src/scm/git/grant.py +++ b/src/scm/git/grant.py @@ -20,7 +20,6 @@ import hashlib import time -import warnings from collections.abc import Sequence from typing import Any @@ -56,12 +55,6 @@ def sign_grant(private_key: Secret, grant: Grant | GrantV2, *, now: float | None GitGrantKeyError if ``private_key`` is not an unencrypted Ed25519 private key in PEM, and a plain ValueError if the signed grant is longer than GRANT_MAX_LENGTH. """ - if isinstance(grant, Grant): - warnings.warn( - "Signing a version 1 git Grant is deprecated. Sign a GrantV2, whose rules can keep a push to branches.", - DeprecationWarning, - stacklevel=2, - ) key = _load_private_key(private_key) claims: dict[str, Any] = msgspec.to_builtins(grant) claims["exp"] = claims.pop("expires_at") diff --git a/src/scm/git/push.py b/src/scm/git/push.py index 4c8ea436..7b0353a6 100644 --- a/src/scm/git/push.py +++ b/src/scm/git/push.py @@ -10,7 +10,7 @@ from collections.abc import Mapping, Sequence from typing import NamedTuple -from scm.errors import ClientClosedRequest, GitPushNotGranted, GitRequestMalformed, RpcMalformedRequestHeaders +from scm.errors import ClientClosedRequest, GitMalformedRequestBody, GitPushNotGranted, RpcMalformedRequestHeaders from scm.git.types import PushRule, Readable MAX_UPDATES_LENGTH = 64 * 1024 @@ -49,7 +49,7 @@ class PushRequest(NamedTuple): def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest: """Read a push's ref updates from ``body``, leaving the pack unread. - Raises GitRequestMalformed for a body that does not start with a list of ref updates, GitPushNotGranted for a + Raises GitMalformedRequestBody for a body that does not start with a list of ref updates, GitPushNotGranted for a compressed body or a signed push, whose updates come inside a certificate, and ClientClosedRequest if reading the body fails. """ @@ -64,7 +64,7 @@ def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest if not updates and line.startswith(b"shallow "): # A push from a shallow clone lists its shallow commits first, and they pass on unchanged. if not _OBJECT_ID.fullmatch(line.removeprefix(b"shallow ")): - raise GitRequestMalformed(detail="The push lists a shallow commit that is not an object id.") + raise GitMalformedRequestBody(detail="The push lists a shallow commit that is not an object id.") continue if not updates and line.startswith(b"push-cert"): raise GitPushNotGranted(detail="The git endpoint does not relay signed pushes.") @@ -202,13 +202,13 @@ def read_line(self) -> bytes | None: """Return the next pkt-line's payload without its trailing newline, or None for a flush.""" header = self._read(4) if not _PKT_LENGTH.fullmatch(header): - raise GitRequestMalformed(detail="The push's ref updates are not pkt-lines.") + raise GitMalformedRequestBody(detail="The push's ref updates are not pkt-lines.") length = int(header, 16) if length == 0: return None # 1 to 3 are protocol v2's special packets, which a push never sends. if length < 4 or length > _MAX_PKT_LENGTH: - raise GitRequestMalformed(detail="The push's ref updates are not pkt-lines.") + raise GitMalformedRequestBody(detail="The push's ref updates are not pkt-lines.") return self._read(length - 4).removesuffix(b"\n") def read_so_far(self) -> bytes: @@ -216,7 +216,7 @@ def read_so_far(self) -> bytes: def _read(self, size: int) -> bytes: if len(self.read_bytes) + size > MAX_UPDATES_LENGTH: - raise GitRequestMalformed(detail=f"The push's ref updates are longer than {MAX_UPDATES_LENGTH} bytes.") + raise GitMalformedRequestBody(detail=f"The push's ref updates are longer than {MAX_UPDATES_LENGTH} bytes.") data = bytearray() while len(data) < size: try: @@ -224,7 +224,7 @@ def _read(self, size: int) -> bytes: except OSError: raise ClientClosedRequest(detail="The client's connection failed while it sent its push.") from None if not chunk: - raise GitRequestMalformed(detail="The push ended before its ref updates did.") + raise GitMalformedRequestBody(detail="The push ended before its ref updates did.") data += chunk self.read_bytes += data return bytes(data) @@ -234,14 +234,14 @@ def _capabilities(requested: bytes) -> frozenset[str]: try: return frozenset(requested.decode("ascii").split()) except UnicodeDecodeError: - raise GitRequestMalformed(detail="The push's capabilities are not ASCII.") from None + raise GitMalformedRequestBody(detail="The push's capabilities are not ASCII.") from None def _ref_update(line: bytes) -> RefUpdate: old_id, _, rest = line.partition(b" ") new_id, _, ref = rest.partition(b" ") if not (_OBJECT_ID.fullmatch(old_id) and _OBJECT_ID.fullmatch(new_id) and len(old_id) == len(new_id) and ref): - raise GitRequestMalformed(detail="The push's ref updates are malformed.") + raise GitMalformedRequestBody(detail="The push's ref updates are malformed.") return RefUpdate(old_id=old_id, new_id=new_id, ref=ref) diff --git a/src/scm/git/server.py b/src/scm/git/server.py index 58c6e5d1..9ec95985 100644 --- a/src/scm/git/server.py +++ b/src/scm/git/server.py @@ -31,7 +31,7 @@ _STATUS: dict[ErrorCode, int] = { # The client's own request is malformed, or is not a git request this endpoint serves. "rpc_malformed_request_headers": 400, - "git_request_malformed": 400, + "git_malformed_request_body": 400, "git_route_not_supported": 404, # The grant does not allow the request. "git_invalid_grant": 403, diff --git a/tests/unit/test_git_grant.py b/tests/unit/test_git_grant.py index 49686f4a..adc00c55 100644 --- a/tests/unit/test_git_grant.py +++ b/tests/unit/test_git_grant.py @@ -2,7 +2,6 @@ import hashlib import hmac import time -import warnings from collections.abc import Callable from typing import Any @@ -734,9 +733,8 @@ def test_valid(self, name): assert PushRule(repository=5, branches=[name]).branches == [name] -def sign_v1(grant: Grant, **kwargs) -> str: - with pytest.deprecated_call(match="Sign a GrantV2"): - return sign_grant(KEY.private, grant, now=NOW, **kwargs) +def sign_v1(grant: Grant) -> str: + return sign_grant(KEY.private, grant, now=NOW) class TestVersion1: @@ -750,12 +748,8 @@ def test_verifies_what_it_signed(self, operation): assert isinstance(verified, Grant) def test_deprecated(self): - # Type checkers and IDEs flag code that uses it, and signing one warns, but verifying one doesn't. + # Type checkers and IDEs flag code that uses it. assert "Sign a GrantV2" in Grant.__deprecated__ # type: ignore[attr-defined] - token = sign_v1(make_v1_grant()) - with warnings.catch_warnings(): - warnings.simplefilter("error") - verify_grant([KEY.public], token, now=NOW) def test_token_is_a_jwt_with_the_grants_claims(self): token = sign_v1(make_v1_grant()) diff --git a/tests/unit/test_git_push.py b/tests/unit/test_git_push.py index 7e17eb3d..c58bb47f 100644 --- a/tests/unit/test_git_push.py +++ b/tests/unit/test_git_push.py @@ -2,7 +2,7 @@ import pytest -from scm.errors import ClientClosedRequest, GitPushNotGranted, GitRequestMalformed, RpcMalformedRequestHeaders +from scm.errors import ClientClosedRequest, GitMalformedRequestBody, GitPushNotGranted, RpcMalformedRequestHeaders from scm.git.push import ( MAX_UPDATES_LENGTH, PushRequest, @@ -127,18 +127,18 @@ def test_signed_push(self): ], ) def test_malformed(self, body): - with pytest.raises(GitRequestMalformed): + with pytest.raises(GitMalformedRequestBody): read(body) def test_shallow_line_after_an_update(self): body = make_push_body((OLD_ID, NEW_ID, "refs/heads/a")) flush = body.index(b"0000") - with pytest.raises(GitRequestMalformed): + with pytest.raises(GitMalformedRequestBody): read(body[:flush] + pkt(f"shallow {'3' * 40}".encode()) + body[flush:]) def test_too_long_before_the_pack(self): updates = [(OLD_ID, NEW_ID, f"refs/heads/branch-{i:05d}") for i in range(MAX_UPDATES_LENGTH // 90)] - with pytest.raises(GitRequestMalformed, match=f"longer than {MAX_UPDATES_LENGTH} bytes"): + with pytest.raises(GitMalformedRequestBody, match=f"longer than {MAX_UPDATES_LENGTH} bytes"): read(make_push_body(*updates)) def test_connection_fails(self): @@ -351,5 +351,5 @@ def test_invalid_content_length(self, length): def test_truncated_updates(self): body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")) - with pytest.raises(GitRequestMalformed, match="ended before its ref updates did"): + with pytest.raises(GitMalformedRequestBody, match="ended before its ref updates did"): read_push_request({}, bounded_body({"Content-Length": "20"}, io.BytesIO(body))) diff --git a/tests/unit/test_git_server.py b/tests/unit/test_git_server.py index ee7f2e34..3055d1f4 100644 --- a/tests/unit/test_git_server.py +++ b/tests/unit/test_git_server.py @@ -872,7 +872,7 @@ def test_malformed(self): assert_refused(self.send(make_server(recorder), io.BytesIO(b"garbage")), 400, "not pkt-lines") assert recorder.repository_lookups == [] assert recorder.counts == [ - ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_request_malformed"}) + ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_malformed_request_body"}) ] def test_compressed(self): @@ -890,8 +890,7 @@ def test_client_closes_while_sending_its_updates(self): def test_version_1_push_grant_relays_unread(self): recorder = Recorder() server = make_server(recorder) - with pytest.deprecated_call(): - grant = sign_grant(KEY.private, make_v1_grant(operation="push")) + grant = sign_grant(KEY.private, make_v1_grant(operation="push")) body = io.BytesIO(b"not a push the server could read") request(server, grant, method="POST", route="git-receive-pack", query="", body=body) assert body.tell() == 0 From 4cd5d5f0b538a34957532c6ccae107c393dd0990 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 19:15:49 +0100 Subject: [PATCH 05/23] ref(git): Rename Grant to GrantV1 and keep Grant as an alias Next to GrantV2 the version 1 grant reads clearer with its version in the name. Grant stays an alias of it, so code that imports Grant keeps working. Co-Authored-By: Claude Opus 5.5 --- src/scm/git/grant.py | 16 ++++++++-------- src/scm/git/relay.py | 6 +++--- src/scm/git/server.py | 2 +- src/scm/git/types.py | 12 ++++++++---- tests/unit/git_builders.py | 6 +++--- tests/unit/test_git_grant.py | 14 +++++++++----- 6 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/scm/git/grant.py b/src/scm/git/grant.py index 318783a5..b188e1cf 100644 --- a/src/scm/git/grant.py +++ b/src/scm/git/grant.py @@ -37,7 +37,7 @@ ) from scm.errors import GitGrantExpired, GitGrantKeyError, GitInvalidGrant -from scm.git.types import GRANT_MAX_LENGTH, Grant, GrantV2 +from scm.git.types import GRANT_MAX_LENGTH, GrantV1, GrantV2 from scm.secret import Secret GRANT_AUDIENCE = "scm-git-grant" @@ -48,7 +48,7 @@ _JWT_CLAIMS = frozenset({"aud", "exp", "iat"}) -def sign_grant(private_key: Secret, grant: Grant | GrantV2, *, now: float | None = None) -> str: +def sign_grant(private_key: Secret, grant: GrantV1 | GrantV2, *, now: float | None = None) -> str: """Sign ``grant`` with ``private_key``, an Ed25519 private key in PEM. The ``kid`` header is derived from the key, so a signer needs no key id setting next to it. Raises @@ -111,10 +111,10 @@ def require_private_key(private_key: Secret) -> Secret: return private_key -def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = None) -> Grant | GrantV2: +def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = None) -> GrantV1 | GrantV2: """Return the grant if the private key of one of ``public_keys`` signed it and it has not expired. - The grant is a ``GrantV2`` if its version is 2, and a ``Grant`` otherwise. + The grant is a ``GrantV2`` if its version is 2, and a ``GrantV1`` otherwise. Verification takes a list of Ed25519 public keys in PEM, so a new key can be added before the signer switches to it and the old one removed after its grants expire. Raises GitGrantKeyError, rather than refusing the grant, if @@ -159,12 +159,12 @@ def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = fields = {name: value for name, value in claims.items() if name not in _JWT_CLAIMS} payload = {**fields, "expires_at": claims["exp"]} try: - grant: Grant | GrantV2 + grant: GrantV1 | GrantV2 if fields.get("version") == 2: grant = msgspec.convert(payload, type=GrantV2, strict=True) else: - # Grant's version field refuses any version but 1. - grant = msgspec.convert(payload, type=Grant, strict=True) + # GrantV1's version field refuses any version but 1. + grant = msgspec.convert(payload, type=GrantV1, strict=True) except msgspec.ValidationError as e: # The signature matched, so the holder of the private key wrote these claims; telling them why they are # invalid gives nothing away. @@ -172,7 +172,7 @@ def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = if grant.expires_at <= now: raise GitGrantExpired(detail="The git grant has expired.") - if isinstance(grant, Grant) and not grant.repositories: + if isinstance(grant, GrantV1) and not grant.repositories: raise GitInvalidGrant(detail="The git grant names no repositories.") return grant diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py index 226c4481..42b1b385 100644 --- a/src/scm/git/relay.py +++ b/src/scm/git/relay.py @@ -20,7 +20,7 @@ RpcMalformedRequestHeaders, SCMCodedError, ) -from scm.git.types import FetchRule, GitOperation, Grant, GrantV2, Readable +from scm.git.types import FetchRule, GitOperation, GrantV1, GrantV2, Readable from scm.rpc.types import StreamResponse from scm.secret import Secret from scm.types import GitRemote, RepositoryId @@ -65,7 +65,7 @@ def authorize_request( - grant: Grant | GrantV2, repository_id: RepositoryId, method: str, route: str, query: str + grant: GrantV1 | GrantV2, repository_id: RepositoryId, method: str, route: str, query: str ) -> GitOperation: """Return the git operation a request belongs to, refusing the request unless the grant allows it. @@ -80,7 +80,7 @@ def authorize_request( operation = _ROUTES.get((method, route, query)) if operation is None: raise GitRouteNotSupported(detail=f"{method} {route} is not a git fetch or push route.") - if isinstance(grant, Grant): + if isinstance(grant, GrantV1): if operation != grant.operation: raise GitOperationNotGranted(detail=f"The git grant allows {grant.operation}, not {operation}.") if repository_id not in grant.repositories: diff --git a/src/scm/git/server.py b/src/scm/git/server.py index 9ec95985..d35817c9 100644 --- a/src/scm/git/server.py +++ b/src/scm/git/server.py @@ -82,7 +82,7 @@ class GitServer: and signed pushes too. A client that asked for a status report gets one, a 200 that git shows as a rejected ref with its reason. A push the rule allows is relayed with the updates the server read followed by the rest of the body, unread. A push under a - ``Grant`` is relayed unread, as before rules. + ``GrantV1`` is relayed unread, as before rules. The repository's provider says where its git endpoint is, through ``GetGitRemoteProtocol``. A repository whose provider has no git remote is refused. diff --git a/src/scm/git/types.py b/src/scm/git/types.py index 4a37fba6..0cf9ac54 100644 --- a/src/scm/git/types.py +++ b/src/scm/git/types.py @@ -12,7 +12,7 @@ """What a grant or rule lets its holder do. ``scm.git.relay`` maps each to its git routes.""" GRANT_VERSION: Literal[1] = 1 -"""The version of ``Grant``, which ``GrantV2`` replaces.""" +"""The version of ``GrantV1``, which ``GrantV2`` replaces.""" GRANT_HEADER = "X-Scm-Git-Grant" """The request header that carries a signed grant to the git endpoint.""" @@ -112,8 +112,8 @@ def covers(self, branch: BranchName) -> bool: type Rule = FetchRule | PushRule -@warnings.deprecated("Grant is version 1 of the git grant. Sign a GrantV2, whose rules can keep a push to branches.") -class Grant(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): +@warnings.deprecated("GrantV1 is deprecated. Sign a GrantV2, whose rules can keep a push to branches.") +class GrantV1(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): """Permission to run one git operation against a fixed set of an organization's repositories. Deprecated: sign a ``GrantV2``, which can also keep a push to branches. Verifiers still accept this version, so a @@ -140,6 +140,10 @@ class Grant(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=Tru """The format version. A verifier refuses a version it does not know.""" +Grant = GrantV1 +"""The name ``GrantV1`` had before ``GrantV2``, kept so code that imports it keeps working.""" + + class GrantV2(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): """Permission to run git operations on an organization's repositories, as a list of rules. @@ -168,7 +172,7 @@ class GrantV2(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=T expires_at: int """Unix time in seconds. Every grant expires.""" version: Literal[2] = 2 - """The format version. A verifier refuses a version it does not know, so one that knows only ``Grant`` refuses + """The format version. A verifier refuses a version it does not know, so one that knows only ``GrantV1`` refuses this one rather than reading its rules as broader than they are.""" def __post_init__(self) -> None: diff --git a/tests/unit/git_builders.py b/tests/unit/git_builders.py index 251241ab..d1124bde 100644 --- a/tests/unit/git_builders.py +++ b/tests/unit/git_builders.py @@ -7,7 +7,7 @@ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_private_key from scm.git.grant import generate_private_key -from scm.git.types import FetchRule, Grant, GrantV2 +from scm.git.types import FetchRule, GrantV1, GrantV2 from scm.providers.cursor_origin.provider import CursorOriginProvider from scm.providers.github.provider import GitHubProvider from scm.providers.gitlab.provider import GitLabProvider @@ -59,7 +59,7 @@ def make_grant(**overrides) -> GrantV2: return GrantV2(**fields) # type: ignore[arg-type] -def make_v1_grant(**overrides) -> Grant: +def make_v1_grant(**overrides) -> GrantV1: """A version 1 fetch grant for the default repository, named both ways a grant can name it.""" fields = { "organization_id": 1, @@ -69,7 +69,7 @@ def make_v1_grant(**overrides) -> Grant: "expires_at": FAR_FUTURE, } fields.update(overrides) - return Grant(**fields) # type: ignore[arg-type] + return GrantV1(**fields) # type: ignore[arg-type] class KeyPair(NamedTuple): diff --git a/tests/unit/test_git_grant.py b/tests/unit/test_git_grant.py index adc00c55..6ac09a6c 100644 --- a/tests/unit/test_git_grant.py +++ b/tests/unit/test_git_grant.py @@ -31,7 +31,7 @@ sign_grant, verify_grant, ) -from scm.git.types import GRANT_MAX_LENGTH, FetchRule, Grant, GrantV2, PushRule +from scm.git.types import GRANT_MAX_LENGTH, FetchRule, Grant, GrantV1, GrantV2, PushRule from scm.secret import Secret from tests.unit.error_reports import assert_not_in_frames from tests.unit.git_builders import FAR_FUTURE, KeyPair, make_grant, make_key_pair, make_v1_grant @@ -733,7 +733,7 @@ def test_valid(self, name): assert PushRule(repository=5, branches=[name]).branches == [name] -def sign_v1(grant: Grant) -> str: +def sign_v1(grant: GrantV1) -> str: return sign_grant(KEY.private, grant, now=NOW) @@ -745,11 +745,15 @@ def test_verifies_what_it_signed(self, operation): grant = make_v1_grant(operation=operation) verified = verify_grant([KEY.public], sign_v1(grant), now=NOW) assert verified == grant - assert isinstance(verified, Grant) + assert isinstance(verified, GrantV1) def test_deprecated(self): - # Type checkers and IDEs flag code that uses it. - assert "Sign a GrantV2" in Grant.__deprecated__ # type: ignore[attr-defined] + # Type checkers and IDEs flag code that uses it, by either name. + assert "Sign a GrantV2" in GrantV1.__deprecated__ # type: ignore[attr-defined] + + def test_old_name(self): + assert Grant is GrantV1 + assert Grant(**msgspec.to_builtins(make_v1_grant())) == make_v1_grant() def test_token_is_a_jwt_with_the_grants_claims(self): token = sign_v1(make_v1_grant()) From bad38fbb4ce091862474f26de6a13bf540d8ad90 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 19:18:29 +0100 Subject: [PATCH 06/23] docs(git): Note that the Grant alias goes with GrantV1 Co-Authored-By: Claude Opus 5.5 --- src/scm/git/types.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/scm/git/types.py b/src/scm/git/types.py index 0cf9ac54..c6d74261 100644 --- a/src/scm/git/types.py +++ b/src/scm/git/types.py @@ -141,7 +141,8 @@ class GrantV1(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=T Grant = GrantV1 -"""The name ``GrantV1`` had before ``GrantV2``, kept so code that imports it keeps working.""" +"""The name ``GrantV1`` had before ``GrantV2``, kept so code that imports it keeps working. Remove it together with +``GrantV1`` rather than pointing it at ``GrantV2``.""" class GrantV2(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): From 074157e83e55fd9f2f8d9701287d6cc37d02a45c Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 19:20:23 +0100 Subject: [PATCH 07/23] ref(git): Refuse pushes outside their rules as git_operation_not_granted A push that updates a ref no rule allows is the grant not allowing that operation, so it reuses the existing code, and its detail names the ref and the reason. Co-Authored-By: Claude Opus 5.5 --- src/scm/errors.py | 6 ------ src/scm/git/push.py | 12 ++++++------ src/scm/git/server.py | 5 ++--- tests/unit/test_git_push.py | 6 +++--- tests/unit/test_git_server.py | 2 +- 5 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/scm/errors.py b/src/scm/errors.py index 81496e2d..71df8e7d 100644 --- a/src/scm/errors.py +++ b/src/scm/errors.py @@ -6,7 +6,6 @@ "git_malformed_request_body", "git_operation_not_granted", "git_provider_not_supported", - "git_push_not_granted", "git_remote_not_https", "git_remote_refused", "git_repository_not_granted", @@ -56,7 +55,6 @@ "git_malformed_request_body": "The request body is not one the git endpoint can read.", "git_operation_not_granted": "The git grant does not allow this operation.", "git_provider_not_supported": "The repository's provider has no git remote.", - "git_push_not_granted": "The git grant does not allow the push's ref updates.", "git_remote_not_https": "The repository's git remote is not an https URL.", "git_remote_refused": "The repository's git remote is at an address Sentry does not connect to.", "git_repository_not_granted": "The git grant does not cover this repository.", @@ -186,10 +184,6 @@ class GitProviderNotSupported(GitSCMCodedError): code = "git_provider_not_supported" -class GitPushNotGranted(GitSCMCodedError): - code = "git_push_not_granted" - - class GitRemoteNotHttps(GitSCMCodedError): code = "git_remote_not_https" diff --git a/src/scm/git/push.py b/src/scm/git/push.py index 7b0353a6..3f16b443 100644 --- a/src/scm/git/push.py +++ b/src/scm/git/push.py @@ -10,7 +10,7 @@ from collections.abc import Mapping, Sequence from typing import NamedTuple -from scm.errors import ClientClosedRequest, GitMalformedRequestBody, GitPushNotGranted, RpcMalformedRequestHeaders +from scm.errors import ClientClosedRequest, GitMalformedRequestBody, GitOperationNotGranted, RpcMalformedRequestHeaders from scm.git.types import PushRule, Readable MAX_UPDATES_LENGTH = 64 * 1024 @@ -49,14 +49,14 @@ class PushRequest(NamedTuple): def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest: """Read a push's ref updates from ``body``, leaving the pack unread. - Raises GitMalformedRequestBody for a body that does not start with a list of ref updates, GitPushNotGranted for a - compressed body or a signed push, whose updates come inside a certificate, and ClientClosedRequest if reading the - body fails. + Raises GitMalformedRequestBody for a body that does not start with a list of ref updates, GitOperationNotGranted + for a compressed body or a signed push, whose updates come inside a certificate, and ClientClosedRequest if + reading the body fails. """ encoding = next((value for name, value in headers.items() if name.lower() == "content-encoding"), "identity") if encoding.strip().lower() not in ("", "identity"): # git compresses fetches but never pushes. - raise GitPushNotGranted(detail="The git endpoint does not read a compressed push.") + raise GitOperationNotGranted(detail="The git endpoint does not read a compressed push.") reader = _PktReader(body) updates: list[RefUpdate] = [] capabilities: frozenset[str] = frozenset() @@ -67,7 +67,7 @@ def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest raise GitMalformedRequestBody(detail="The push lists a shallow commit that is not an object id.") continue if not updates and line.startswith(b"push-cert"): - raise GitPushNotGranted(detail="The git endpoint does not relay signed pushes.") + raise GitOperationNotGranted(detail="The git endpoint does not relay signed pushes.") if not updates: line, _, requested = line.partition(b"\0") capabilities = _capabilities(requested) diff --git a/src/scm/git/server.py b/src/scm/git/server.py index d35817c9..e861848a 100644 --- a/src/scm/git/server.py +++ b/src/scm/git/server.py @@ -4,8 +4,8 @@ from scm.errors import ( ErrorCode, + GitOperationNotGranted, GitProviderNotSupported, - GitPushNotGranted, GitTokenRejected, GitTokenUnavailable, SCMCodedError, @@ -38,7 +38,6 @@ "git_grant_expired": 403, "git_operation_not_granted": 403, "git_repository_not_granted": 403, - "git_push_not_granted": 403, # The grant allows it, but there is no repository to serve. "repository_not_found": 404, "repository_inactive": 404, @@ -176,7 +175,7 @@ def handle( refused = refused_updates(push_rules, push) if refused: ref, reason = next(iter(refused.items())) - raise GitPushNotGranted( + raise GitOperationNotGranted( detail=f"The git grant does not allow this push: {ref.decode(errors='replace')} ({reason})." ) relayed_body = ReplayedBody(push.head, push_body) diff --git a/tests/unit/test_git_push.py b/tests/unit/test_git_push.py index c58bb47f..5c461d22 100644 --- a/tests/unit/test_git_push.py +++ b/tests/unit/test_git_push.py @@ -2,7 +2,7 @@ import pytest -from scm.errors import ClientClosedRequest, GitMalformedRequestBody, GitPushNotGranted, RpcMalformedRequestHeaders +from scm.errors import ClientClosedRequest, GitMalformedRequestBody, GitOperationNotGranted, RpcMalformedRequestHeaders from scm.git.push import ( MAX_UPDATES_LENGTH, PushRequest, @@ -97,12 +97,12 @@ def test_identity_encoding(self, encoding): @pytest.mark.parametrize("encoding", ["gzip", "deflate"]) def test_compressed(self, encoding): - with pytest.raises(GitPushNotGranted, match="compressed push"): + with pytest.raises(GitOperationNotGranted, match="compressed push"): read(make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")), {"content-encoding": encoding}) def test_signed_push(self): body = pkt(b"push-cert\0report-status") + pkt(b"certificate version 0.1\n") + b"0000" - with pytest.raises(GitPushNotGranted, match="signed pushes"): + with pytest.raises(GitOperationNotGranted, match="signed pushes"): read(body) @pytest.mark.parametrize( diff --git a/tests/unit/test_git_server.py b/tests/unit/test_git_server.py index 3055d1f4..98f4a0c8 100644 --- a/tests/unit/test_git_server.py +++ b/tests/unit/test_git_server.py @@ -812,7 +812,7 @@ def test_unlisted_branch_gets_a_report(self): assert recorder.token_lookups == [] server.relay.forward.assert_not_called() # type: ignore[attr-defined] assert recorder.counts == [ - ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_push_not_granted"}) + ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_operation_not_granted"}) ] [(error, context)] = recorder.errors assert error.detail == ( # type: ignore[attr-defined] From 94b83fcfcf154e08441556d391c3b8999de34988 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 19:24:49 +0100 Subject: [PATCH 08/23] ref(git): Deprecate GRANT_VERSION It only ever held GrantV1's version, which the class's own field already holds, so GrantV1 stops reading it and the constant goes when GrantV1 does. Co-Authored-By: Claude Opus 5.5 --- src/scm/git/types.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/scm/git/types.py b/src/scm/git/types.py index c6d74261..f0698938 100644 --- a/src/scm/git/types.py +++ b/src/scm/git/types.py @@ -12,7 +12,8 @@ """What a grant or rule lets its holder do. ``scm.git.relay`` maps each to its git routes.""" GRANT_VERSION: Literal[1] = 1 -"""The version of ``GrantV1``, which ``GrantV2`` replaces.""" +"""Deprecated: the version of ``GrantV1``, which its ``version`` field already holds. Remove it together with +``GrantV1``.""" GRANT_HEADER = "X-Scm-Git-Grant" """The request header that carries a signed grant to the git endpoint.""" @@ -136,7 +137,7 @@ class GrantV1(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=T repositories: list[RepositoryId] expires_at: int """Unix time in seconds. Every grant expires.""" - version: Literal[1] = GRANT_VERSION + version: Literal[1] = 1 """The format version. A verifier refuses a version it does not know.""" From 292060f61fbeae5bf03797f7235323245292c369 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 19:40:33 +0100 Subject: [PATCH 09/23] ref(git): Name the rule types after GrantV2 and drop the public Rule alias The rules belong to the version 2 grant, so their names say so, and the union is spelled out where it's used rather than exported as a type of its own. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- bin/git-server | 10 +++--- src/scm/git/push.py | 6 ++-- src/scm/git/relay.py | 4 +-- src/scm/git/server.py | 4 +-- src/scm/git/types.py | 11 +++---- tests/unit/git_builders.py | 7 +++-- tests/unit/test_git_grant.py | 59 ++++++++++++++++++++--------------- tests/unit/test_git_push.py | 18 +++++------ tests/unit/test_git_relay.py | 23 ++++++++------ tests/unit/test_git_server.py | 10 +++--- 11 files changed, 83 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 4cb3d5ff..d0540d64 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The platform exposes four subsystems: - **Actions** — outbound SCM operations initiated by Sentry code. The `SourceCodeManager` class provides 70+ methods covering comments, reactions, pull requests, branches, git objects, reviews, and check runs. With more actions planned to be added as we port more use cases. - **Actions RPC** - outbound SCM commands exposed over the network. The `SourceCodeManager` is fully available over the network. This enables usage of the SCM for services outside the monolith. -- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. A `GrantV2` names the organization and a list of rules, each a repository, an operation (`FetchRule` or `PushRule`) and the branches it may touch, or `any_object` for a fetch that may read any object. A push rule names its branches or full-match `branch_patterns`, and `create_only` limits it to new branches. A request passes if any rule allows it. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. For a push under a push rule it first reads the ref updates, and answers a push that updates any other ref, deletes a branch or carries push options with a status report git shows as rejected refs. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it. +- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. A `GrantV2` names the organization and a list of rules, each a repository, an operation (`GrantV2FetchRule` or `GrantV2PushRule`) and the branches it may touch, or `any_object` for a fetch that may read any object. A push rule names its branches or full-match `branch_patterns`, and `create_only` limits it to new branches. A request passes if any rule allows it. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. For a push under a push rule it first reads the ref updates, and answers a push that updates any other ref, deletes a branch or carries push options with a status report git shows as rejected refs. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it. - **Event Stream** — inbound webhook processing. SCM service-providers push events which are deserialized into typed, provider-neutral dataclasses (`CheckRunEvent`, `CommentEvent`, `PullRequestEvent`) which are then dispatched to registered listener functions. ### Why diff --git a/bin/git-server b/bin/git-server index 77bd4f40..bd923038 100755 --- a/bin/git-server +++ b/bin/git-server @@ -103,7 +103,7 @@ from scm.errors import GitGrantKeyError from scm.git.grant import generate_private_key, public_key_of, sign_grant from scm.git.relay import GitRelay from scm.git.server import GitServer -from scm.git.types import GRANT_HEADER, FetchRule, GetToken, GrantV2, PushRule, Readable, Rule +from scm.git.types import GRANT_HEADER, GetToken, GrantV2, GrantV2FetchRule, GrantV2PushRule, Readable from scm.providers.github.provider import GitHubProvider from scm.providers.gitlab.provider import GitLabProvider from scm.types import GetGitRemoteProtocol, Provider, ProviderName, Repository, RepositoryId @@ -281,7 +281,7 @@ def make_git_server( ) -def grant_for(private_key: Secret, rule: Rule) -> str: +def grant_for(private_key: Secret, rule: GrantV2FetchRule | GrantV2PushRule) -> str: grant = GrantV2( organization_id=ORGANIZATION_ID, referrer=REFERRER, @@ -292,7 +292,7 @@ def grant_for(private_key: Secret, rule: Rule) -> str: def fetch_grant_for(private_key: Secret, repository_id: RepositoryId) -> str: - return grant_for(private_key, FetchRule(repository=repository_id, any_object=True)) + return grant_for(private_key, GrantV2FetchRule(repository=repository_id, any_object=True)) class GitHandler(BaseHTTPRequestHandler): @@ -415,7 +415,7 @@ def serve(backend: Backend, args: argparse.Namespace) -> None: ) url = f"{server.url}/{backend.repository['name']}.git" fetch_grant = fetch_grant_for(args.private_key, backend.repository_id) - push_grant = grant_for(args.private_key, PushRule(repository=backend.repository_id, branches=["my-branch"])) + push_grant = grant_for(args.private_key, GrantV2PushRule(repository=backend.repository_id, branches=["my-branch"])) logger.info( f"Serving {backend.provider_name} repository {backend.repository['name']} at {url}\n\n" f"Grants, valid for {GRANT_TTL // 60} minutes:\n" @@ -490,7 +490,7 @@ class Checks: other_repository_id = (self.backend.provider_name, f"{self.backend.repository['name']}-other") branch = f"scm-git-server-check/{int(time.time())}" fetch_grant = fetch_grant_for(self.args.private_key, repository_id) - push_grant = grant_for(self.args.private_key, PushRule(repository=repository_id, branches=[branch])) + push_grant = grant_for(self.args.private_key, GrantV2PushRule(repository=repository_id, branches=[branch])) server = self.start() url = f"{server.url}/{self.backend.repository['name']}.git" diff --git a/src/scm/git/push.py b/src/scm/git/push.py index 3f16b443..5ef13f9d 100644 --- a/src/scm/git/push.py +++ b/src/scm/git/push.py @@ -11,7 +11,7 @@ from typing import NamedTuple from scm.errors import ClientClosedRequest, GitMalformedRequestBody, GitOperationNotGranted, RpcMalformedRequestHeaders -from scm.git.types import PushRule, Readable +from scm.git.types import GrantV2PushRule, Readable MAX_UPDATES_LENGTH = 64 * 1024 """The most bytes a push may send before its pack. An update takes under 100 bytes plus its ref's name, and a push @@ -75,7 +75,7 @@ def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest return PushRequest(updates=updates, capabilities=capabilities, head=reader.read_so_far()) -def refused_updates(rules: Sequence[PushRule], request: PushRequest) -> dict[bytes, str]: +def refused_updates(rules: Sequence[GrantV2PushRule], request: PushRequest) -> dict[bytes, str]: """Return why ``rules`` refuse each ref of ``request``, or nothing if every update has a rule that allows it. A push is relayed whole or not at all, so once one update is refused, every other ref gets a reason too. @@ -102,7 +102,7 @@ def refused_updates(rules: Sequence[PushRule], request: PushRequest) -> dict[byt _DELETES = (3, "the git grant does not allow deleting a branch") -def _refusal(rule: PushRule, update: RefUpdate) -> tuple[int, str] | None: +def _refusal(rule: GrantV2PushRule, update: RefUpdate) -> tuple[int, str] | None: """Return why ``rule`` refuses ``update``, ranked by how far the update got, or None if the rule allows it.""" if not update.ref.startswith(b"refs/heads/"): return _NOT_A_BRANCH diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py index 42b1b385..e0a957a9 100644 --- a/src/scm/git/relay.py +++ b/src/scm/git/relay.py @@ -20,7 +20,7 @@ RpcMalformedRequestHeaders, SCMCodedError, ) -from scm.git.types import FetchRule, GitOperation, GrantV1, GrantV2, Readable +from scm.git.types import GitOperation, GrantV1, GrantV2, GrantV2FetchRule, Readable from scm.rpc.types import StreamResponse from scm.secret import Secret from scm.types import GitRemote, RepositoryId @@ -94,7 +94,7 @@ def authorize_request( raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") raise GitOperationNotGranted(detail=f"The git grant allows {other} on this repository, not {operation}.") # Delete this once GitServer keeps a fetch to its rule's branches. - if operation == "fetch" and not any(isinstance(rule, FetchRule) and rule.any_object for rule in rules): + if operation == "fetch" and not any(isinstance(rule, GrantV2FetchRule) and rule.any_object for rule in rules): raise GitOperationNotGranted( detail="The git endpoint cannot keep a fetch to a rule's branches yet, so it refuses the rule." ) diff --git a/src/scm/git/server.py b/src/scm/git/server.py index e861848a..a76b2026 100644 --- a/src/scm/git/server.py +++ b/src/scm/git/server.py @@ -20,7 +20,7 @@ refused_updates, ) from scm.git.relay import DEFAULT_CHUNK_SIZE, GitRelay, ResponseBody, authorize_request -from scm.git.types import GRANT_HEADER, GetToken, GrantV2, PushRule, Readable +from scm.git.types import GRANT_HEADER, GetToken, GrantV2, GrantV2PushRule, Readable from scm.helpers import initialize_provider from scm.rpc.types import StreamResponse from scm.secret import Secret @@ -165,7 +165,7 @@ def handle( context["operation"] = operation relayed_body: Readable | None = counted_body push_rules = ( - [rule for rule in grant.rules_for(repository_id, operation) if isinstance(rule, PushRule)] + [rule for rule in grant.rules_for(repository_id, operation) if isinstance(rule, GrantV2PushRule)] if isinstance(grant, GrantV2) else [] ) diff --git a/src/scm/git/types.py b/src/scm/git/types.py index f0698938..d4f335b9 100644 --- a/src/scm/git/types.py +++ b/src/scm/git/types.py @@ -28,7 +28,7 @@ 8 KiB.""" -class FetchRule( +class GrantV2FetchRule( msgspec.Struct, frozen=True, kw_only=True, @@ -60,7 +60,7 @@ def __post_init__(self) -> None: _check_branches(self.branches) -class PushRule( +class GrantV2PushRule( msgspec.Struct, frozen=True, kw_only=True, @@ -110,9 +110,6 @@ def covers(self, branch: BranchName) -> bool: return any(re.fullmatch(pattern, branch) for pattern in self.branch_patterns or ()) -type Rule = FetchRule | PushRule - - @warnings.deprecated("GrantV1 is deprecated. Sign a GrantV2, whose rules can keep a push to branches.") class GrantV1(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): """Permission to run one git operation against a fixed set of an organization's repositories. @@ -170,7 +167,7 @@ class GrantV2(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=T """Who the grant is for, e.g. a coding job or Seer itself, as ``referrer`` is elsewhere in this library. Sentry tags the git endpoint's metrics with it. Seer sets it, because git requests carry nothing else that tells callers apart.""" - rules: list[Rule] + rules: list[GrantV2FetchRule | GrantV2PushRule] expires_at: int """Unix time in seconds. Every grant expires.""" version: Literal[2] = 2 @@ -181,7 +178,7 @@ def __post_init__(self) -> None: if not self.rules: raise ValueError("A git grant must hold at least one rule.") - def rules_for(self, repository: RepositoryId, operation: GitOperation) -> list[Rule]: + def rules_for(self, repository: RepositoryId, operation: GitOperation) -> list[GrantV2FetchRule | GrantV2PushRule]: """Return the rules for ``operation`` on ``repository``, named as the rules name it.""" return [rule for rule in self.rules if rule.repository == repository and rule.operation == operation] diff --git a/tests/unit/git_builders.py b/tests/unit/git_builders.py index d1124bde..bf323030 100644 --- a/tests/unit/git_builders.py +++ b/tests/unit/git_builders.py @@ -7,7 +7,7 @@ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_private_key from scm.git.grant import generate_private_key -from scm.git.types import FetchRule, GrantV1, GrantV2 +from scm.git.types import GrantV1, GrantV2, GrantV2FetchRule from scm.providers.cursor_origin.provider import CursorOriginProvider from scm.providers.github.provider import GitHubProvider from scm.providers.gitlab.provider import GitLabProvider @@ -52,7 +52,10 @@ def make_grant(**overrides) -> GrantV2: fields = { "organization_id": 1, "referrer": "seer.coding_job", - "rules": [FetchRule(repository=5, any_object=True), FetchRule(repository=("github", "123"), any_object=True)], + "rules": [ + GrantV2FetchRule(repository=5, any_object=True), + GrantV2FetchRule(repository=("github", "123"), any_object=True), + ], "expires_at": FAR_FUTURE, } fields.update(overrides) diff --git a/tests/unit/test_git_grant.py b/tests/unit/test_git_grant.py index 6ac09a6c..9d83b993 100644 --- a/tests/unit/test_git_grant.py +++ b/tests/unit/test_git_grant.py @@ -31,7 +31,7 @@ sign_grant, verify_grant, ) -from scm.git.types import GRANT_MAX_LENGTH, FetchRule, Grant, GrantV1, GrantV2, PushRule +from scm.git.types import GRANT_MAX_LENGTH, Grant, GrantV1, GrantV2, GrantV2FetchRule, GrantV2PushRule from scm.secret import Secret from tests.unit.error_reports import assert_not_in_frames from tests.unit.git_builders import FAR_FUTURE, KeyPair, make_grant, make_key_pair, make_v1_grant @@ -120,9 +120,9 @@ def test_public_key_of_the_signing_key(self): def test_rules_of_each_kind(self): grant = make_grant( rules=[ - FetchRule(repository=5, branches=["main", "seer/fix"]), - PushRule(repository=5, branches=["seer/fix"]), - FetchRule(repository=("gitlab", "gitlab.com:7"), any_object=True), + GrantV2FetchRule(repository=5, branches=["main", "seer/fix"]), + GrantV2PushRule(repository=5, branches=["seer/fix"]), + GrantV2FetchRule(repository=("gitlab", "gitlab.com:7"), any_object=True), ] ) verified = verify_grant([KEY.public], sign_grant(KEY.private, grant, now=NOW), now=NOW) @@ -278,7 +278,10 @@ def test_other_asymmetric_algorithm(self): def grant_with_repositories(count: int) -> GrantV2: # Names of one length, so each rule adds the same to the grant. return make_grant( - rules=[FetchRule(repository=("github", f"getsentry/repository-{i:04d}"), any_object=True) for i in range(count)] + rules=[ + GrantV2FetchRule(repository=("github", f"getsentry/repository-{i:04d}"), any_object=True) + for i in range(count) + ] ) @@ -309,7 +312,7 @@ def test_longest_grant_that_fits_signs(self): def test_grant_for_one_repository(self): # GRANT_MAX_LENGTH's docstring sizes grants from this. - grant = make_grant(rules=[FetchRule(repository=("github", "getsentry/scm-platform"), any_object=True)]) + grant = make_grant(rules=[GrantV2FetchRule(repository=("github", "getsentry/scm-platform"), any_object=True)]) assert len(sign_grant(KEY.private, grant, now=NOW)) < 500 @@ -549,7 +552,10 @@ class TestRules: def test_fetch_and_push_rules_for_one_repository(self): grant = make_grant( - rules=[FetchRule(repository=5, branches=["main"]), PushRule(repository=5, branches=["seer/fix"])] + rules=[ + GrantV2FetchRule(repository=5, branches=["main"]), + GrantV2PushRule(repository=5, branches=["seer/fix"]), + ] ) assert verify_grant([KEY.public], sign_grant(KEY.private, grant, now=NOW), now=NOW) == grant @@ -557,10 +563,10 @@ def test_claims_hold_only_what_a_rule_sets(self): # Shorter, and a reader sees which fetch rules reach any object. grant = make_grant( rules=[ - FetchRule(repository=5, branches=["main"]), - FetchRule(repository=6, any_object=True), - PushRule(repository=5, branches=["seer/fix"]), - PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + GrantV2FetchRule(repository=5, branches=["main"]), + GrantV2FetchRule(repository=6, any_object=True), + GrantV2PushRule(repository=5, branches=["seer/fix"]), + GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), ] ) claims = jwt.decode(sign_grant(KEY.private, grant, now=NOW), options={"verify_signature": False}) @@ -617,13 +623,13 @@ def test_invalid_rule(self, rule, error): def test_rule_without_branches_or_any_object_cannot_be_built(self): with pytest.raises(ValueError, match="must list its branches or set any_object"): - FetchRule(repository=5) + GrantV2FetchRule(repository=5) def test_rule_with_branches_and_any_object_cannot_be_built(self): with pytest.raises(ValueError, match="must not both list branches and set any_object"): - FetchRule(repository=5, branches=["main"], any_object=True) + GrantV2FetchRule(repository=5, branches=["main"], any_object=True) - @pytest.mark.parametrize("make_rule", [FetchRule, PushRule], ids=["fetch", "push"]) + @pytest.mark.parametrize("make_rule", [GrantV2FetchRule, GrantV2PushRule], ids=["fetch", "push"]) def test_rule_without_branches_listed_cannot_be_built(self, make_rule): with pytest.raises(ValueError, match="must not be empty"): make_rule(repository=5, branches=[]) @@ -637,10 +643,10 @@ def test_several_rules_for_one_repository_and_operation(self): # A request passes if any of them allows it, so their order doesn't matter. grant = make_grant( rules=[ - FetchRule(repository=5, any_object=True), - FetchRule(repository=5, branches=["main"]), - PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), - PushRule(repository=5, branches=["seer/fix"]), + GrantV2FetchRule(repository=5, any_object=True), + GrantV2FetchRule(repository=5, branches=["main"]), + GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + GrantV2PushRule(repository=5, branches=["seer/fix"]), ] ) assert verify_grant([KEY.public], sign_grant(KEY.private, grant, now=NOW), now=NOW) == grant @@ -649,7 +655,10 @@ def test_several_rules_for_one_repository_and_operation(self): def test_one_repository_named_both_ways(self): # Nothing matches a database id to a provider id, so the two are separate repositories here, as in requests. - rules = [FetchRule(repository=5, any_object=True), FetchRule(repository=("github", "123"), branches=["main"])] + rules = [ + GrantV2FetchRule(repository=5, any_object=True), + GrantV2FetchRule(repository=("github", "123"), branches=["main"]), + ] assert make_grant(rules=rules).rules == rules @@ -700,23 +709,23 @@ class TestBranchPatterns: ], ) def test_covers(self, patterns, branch, covered): - assert PushRule(repository=5, branch_patterns=patterns).covers(branch) is covered + assert GrantV2PushRule(repository=5, branch_patterns=patterns).covers(branch) is covered def test_listed_branches(self): - rule = PushRule(repository=5, branches=["seer/fix"]) + rule = GrantV2PushRule(repository=5, branches=["seer/fix"]) assert rule.covers("seer/fix") assert not rule.covers("seer/fix2") def test_cannot_be_built_with_an_invalid_pattern(self): with pytest.raises(ValueError, match="'seer/\\(' is not a regular expression"): - PushRule(repository=5, branch_patterns=["seer/("]) + GrantV2PushRule(repository=5, branch_patterns=["seer/("]) class TestBranchNames: @pytest.mark.parametrize("name", INVALID_BRANCH_NAMES) def test_invalid(self, name): with pytest.raises(ValueError, match="is not a branch name"): - PushRule(repository=5, branches=["main", name]) + GrantV2PushRule(repository=5, branches=["main", name]) assert_invalid( sign_claims(rule_claims({"operation": "push", "repository": 5, "branches": ["main", name]})), "is not a branch name", @@ -724,13 +733,13 @@ def test_invalid(self, name): def test_checked_in_fetch_rules_too(self): with pytest.raises(ValueError, match="is not a branch name"): - FetchRule(repository=5, branches=["refs/heads/main"]) + GrantV2FetchRule(repository=5, branches=["refs/heads/main"]) @pytest.mark.parametrize( "name", ["main", "seer/fix-123", "feature/a.b", "a@b", "release-1.0", "a.lock.b", "x/HEAD", "ünicode"] ) def test_valid(self, name): - assert PushRule(repository=5, branches=[name]).branches == [name] + assert GrantV2PushRule(repository=5, branches=[name]).branches == [name] def sign_v1(grant: GrantV1) -> str: diff --git a/tests/unit/test_git_push.py b/tests/unit/test_git_push.py index 5c461d22..a333c98f 100644 --- a/tests/unit/test_git_push.py +++ b/tests/unit/test_git_push.py @@ -13,10 +13,10 @@ refusal_report, refused_updates, ) -from scm.git.types import PushRule +from scm.git.types import GrantV2PushRule from tests.unit.git_builders import NEW_ID, OLD_ID, PACK, ZERO_ID, make_push_body, pkt -RULES = [PushRule(repository=5, branches=["seer/fix", "seer/other"])] +RULES = [GrantV2PushRule(repository=5, branches=["seer/fix", "seer/other"])] def read(body: bytes, headers: dict[str, str] | None = None) -> tuple[PushRequest, io.BytesIO]: @@ -204,19 +204,19 @@ def test_no_rules(self): ], ) def test_create_only_pattern(self, old_id, ref, reason): - rules = [PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True)] + rules = [GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True)] refused = refused_updates(rules, request_for((old_id, NEW_ID, ref))) assert refused == ({} if reason is None else {ref.encode(): reason}) def test_create_only_listed_branch(self): - rules = [PushRule(repository=5, branches=["seer/fix"], create_only=True)] + rules = [GrantV2PushRule(repository=5, branches=["seer/fix"], create_only=True)] assert refused_updates(rules, request_for((ZERO_ID, NEW_ID, "refs/heads/seer/fix"))) == {} assert refused_updates(rules, request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"))) == { b"refs/heads/seer/fix": "the git grant allows only creating this branch" } def test_non_utf8_branch_against_a_pattern(self): - rules = [PushRule(repository=5, branch_patterns=[".+"])] + rules = [GrantV2PushRule(repository=5, branch_patterns=[".+"])] ref = b"refs/heads/\xff" request = PushRequest( updates=[RefUpdate(old_id=OLD_ID.encode(), new_id=NEW_ID.encode(), ref=ref)], @@ -228,8 +228,8 @@ def test_non_utf8_branch_against_a_pattern(self): def test_any_rule_allows_an_update(self): # The agent may start branches under seer/ and keep pushing to the one Seer added after its first push. rules = [ - PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), - PushRule(repository=5, branches=["seer/fix"]), + GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + GrantV2PushRule(repository=5, branches=["seer/fix"]), ] request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), (ZERO_ID, NEW_ID, "refs/heads/seer/next")) assert refused_updates(rules, request) == {} @@ -237,8 +237,8 @@ def test_any_rule_allows_an_update(self): def test_reason_of_the_rule_the_update_came_closest_to_passing(self): rules = [ - PushRule(repository=5, branches=["other"]), - PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + GrantV2PushRule(repository=5, branches=["other"]), + GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), ] assert refused_updates(rules, request_for((OLD_ID, NEW_ID, "refs/heads/seer/old"))) == { b"refs/heads/seer/old": "the git grant allows only creating this branch" diff --git a/tests/unit/test_git_relay.py b/tests/unit/test_git_relay.py index 99833a36..0d9208cd 100644 --- a/tests/unit/test_git_relay.py +++ b/tests/unit/test_git_relay.py @@ -26,7 +26,7 @@ SCMCodedError, ) from scm.git.relay import GitRelay, _UpstreamStream, authorize_request -from scm.git.types import FetchRule, PushRule +from scm.git.types import GrantV2FetchRule, GrantV2PushRule from scm.secret import Secret from scm.types import GitRemote from tests.unit.error_reports import assert_not_in_frames, assert_not_reported, reported_local @@ -742,8 +742,8 @@ def handler(h: UpstreamHandler) -> None: FETCH_ROUTES = [("GET", "info/refs", "service=git-upload-pack"), ("POST", "git-upload-pack", "")] PUSH_ROUTES = [("GET", "info/refs", "service=git-receive-pack"), ("POST", "git-receive-pack", "")] -FETCH_ANY = FetchRule(repository=5, any_object=True) -PUSH_BRANCH = PushRule(repository=5, branches=["seer/fix"]) +FETCH_ANY = GrantV2FetchRule(repository=5, any_object=True) +PUSH_BRANCH = GrantV2PushRule(repository=5, branches=["seer/fix"]) class TestAuthorizeRequest: @@ -767,24 +767,26 @@ def test_push_rule_cannot_reach_fetch_routes(self, route): authorize_request(make_grant(rules=[PUSH_BRANCH]), 5, *route) def test_rule_for_another_repository_does_not_apply(self): - grant = make_grant(rules=[FETCH_ANY, PushRule(repository=6, branches=["seer/fix"])]) + grant = make_grant(rules=[FETCH_ANY, GrantV2PushRule(repository=6, branches=["seer/fix"])]) with pytest.raises(GitOperationNotGranted, match="allows fetch on this repository, not push"): authorize_request(grant, 5, "POST", "git-receive-pack", "") def test_any_of_several_rules(self): grant = make_grant( rules=[ - FetchRule(repository=5, branches=["main"]), + GrantV2FetchRule(repository=5, branches=["main"]), FETCH_ANY, PUSH_BRANCH, - PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), ] ) assert authorize_request(grant, 5, "POST", "git-upload-pack", "") == "fetch" assert authorize_request(grant, 5, "POST", "git-receive-pack", "") == "push" def test_fetch_rules_that_all_list_branches_are_refused(self): - grant = make_grant(rules=[FetchRule(repository=5, branches=["main"]), FetchRule(repository=5, branches=["a"])]) + grant = make_grant( + rules=[GrantV2FetchRule(repository=5, branches=["main"]), GrantV2FetchRule(repository=5, branches=["a"])] + ) with pytest.raises(GitOperationNotGranted, match="cannot keep a fetch to a rule's branches"): authorize_request(grant, 5, "POST", "git-upload-pack", "") @@ -792,7 +794,7 @@ def test_fetch_rules_that_all_list_branches_are_refused(self): def test_fetch_rule_that_lists_branches_is_refused(self, route): # Nothing reads a fetch's body, so nothing would keep the fetch to the rule's branches. with pytest.raises(GitOperationNotGranted, match="cannot keep a fetch to a rule's branches"): - authorize_request(make_grant(rules=[FetchRule(repository=5, branches=["main"])]), 5, *route) + authorize_request(make_grant(rules=[GrantV2FetchRule(repository=5, branches=["main"])]), 5, *route) @pytest.mark.parametrize( "method,route,query", @@ -823,7 +825,7 @@ def test_repository_not_in_grant(self): def test_repository_named_another_way_is_not_in_grant(self): # The rule and the request must name a repository the same way, since matching a database id to a provider # id would mean resolving every repository the grant names. - grant = make_grant(rules=[FetchRule(repository=("github", "123"), any_object=True)]) + grant = make_grant(rules=[GrantV2FetchRule(repository=("github", "123"), any_object=True)]) with pytest.raises(GitRepositoryNotGranted): authorize_request(grant, 5, "POST", "git-upload-pack", "") assert authorize_request(grant, ("github", "123"), "POST", "git-upload-pack", "") == "fetch" @@ -831,7 +833,8 @@ def test_repository_named_another_way_is_not_in_grant(self): def test_any_repository_with_a_rule(self): grant = make_grant( rules=[ - FetchRule(repository=repository, any_object=True) for repository in (5, 6, ("gitlab", "gitlab.com:7")) + GrantV2FetchRule(repository=repository, any_object=True) + for repository in (5, 6, ("gitlab", "gitlab.com:7")) ] ) assert authorize_request(grant, 6, "POST", "git-upload-pack", "") == "fetch" diff --git a/tests/unit/test_git_server.py b/tests/unit/test_git_server.py index 98f4a0c8..7f6818c5 100644 --- a/tests/unit/test_git_server.py +++ b/tests/unit/test_git_server.py @@ -20,7 +20,7 @@ from scm.git.grant import sign_grant from scm.git.relay import GitRelay, RelayResponse, ResponseBody from scm.git.server import _STATUS, GitServer -from scm.git.types import GRANT_HEADER, FetchRule, GitOperation, PushRule, Readable +from scm.git.types import GRANT_HEADER, GitOperation, GrantV2FetchRule, GrantV2PushRule, Readable from scm.rpc.types import StreamResponse from scm.secret import Secret from scm.types import GitRemote, Provider, Repository, RepositoryId @@ -246,7 +246,7 @@ def test_operation_the_grant_does_not_allow(self): def test_fetch_rule_that_lists_branches(self): recorder = Recorder() - grant = signed_grant(rules=[FetchRule(repository=5, branches=["main"])]) + grant = signed_grant(rules=[GrantV2FetchRule(repository=5, branches=["main"])]) assert_refused(request(make_server(recorder), grant), 403, "cannot keep a fetch to a rule's branches") assert recorder.repository_lookups == [] assert recorder.token_lookups == [] @@ -740,7 +740,7 @@ def test_token_that_is_not_a_secret_is_refused_and_left_out(self): ] -PUSH_GRANT = sign_grant(KEY.private, make_grant(rules=[PushRule(repository=5, branches=["seer/fix"])])) +PUSH_GRANT = sign_grant(KEY.private, make_grant(rules=[GrantV2PushRule(repository=5, branches=["seer/fix"])])) def push_body(ref: str, capabilities: str = "report-status side-band-64k") -> bytes: @@ -844,8 +844,8 @@ def test_create_only_pattern_and_listed_branch(self): KEY.private, make_grant( rules=[ - PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), - PushRule(repository=5, branches=["seer/fix"]), + GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + GrantV2PushRule(repository=5, branches=["seer/fix"]), ] ), ) From 356d3cb48328563441bb3e00227843bc02c170b5 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 19:54:22 +0100 Subject: [PATCH 10/23] fix(git): Fail closed on every push the rules can't vouch for GitServer checked a push only when it found push rules and a body, so a push under a GrantV2 with neither would have gone through unread. It now checks every GrantV2 push, refusing one with no body, no rule, a capability git doesn't send, or data after an empty command list. Public authorize_request refuses a GrantV2 push outright, since only GitServer reads the ref updates. Signed, compressed and malformed pushes are read to the end like refused ones, and a signed push gets its reason on side band 3. A refusal's detail shows at most 200 characters of the client's ref. bin/git-server passes a body that ends at Content-Length, as GitServer expects, and checks that the provider refuses a forged create of an existing branch, which is what keeps a create_only rule from moving one. Co-Authored-By: Claude Opus 5.5 --- bin/git-server | 74 ++++++++++++++- src/scm/git/push.py | 171 ++++++++++++++++++++++------------ src/scm/git/relay.py | 20 +++- src/scm/git/server.py | 74 ++++----------- tests/unit/test_git_push.py | 137 ++++++++++++++++++++++----- tests/unit/test_git_relay.py | 16 +++- tests/unit/test_git_server.py | 19 ++-- 7 files changed, 356 insertions(+), 155 deletions(-) diff --git a/bin/git-server b/bin/git-server index bd923038..261f647c 100755 --- a/bin/git-server +++ b/bin/git-server @@ -71,8 +71,9 @@ Checks: message, which the relay passes on With --push it also commits a 256 KiB file, pushes it with a push grant for a new branch, sending the pack chunked as git does for a large push, and - checks that the grant can neither push another branch nor delete its own. - It then deletes the branch with the provider token. This writes to the + checks that the grant can neither push another branch nor delete its own, + and that the provider refuses a forged create of the existing branch under + a create_only rule. It then deletes the branch with the provider token. This writes to the repository, so point it at a test repository. git runs without the user's or the system's git config, so credential @@ -81,10 +82,12 @@ Checks: import argparse import base64 +import hashlib import importlib.machinery import importlib.util import logging import os +import re import secrets import subprocess import sys @@ -98,6 +101,8 @@ from pathlib import Path from types import ModuleType from urllib.parse import urlsplit +import requests + from scm import Secret from scm.errors import GitGrantKeyError from scm.git.grant import generate_private_key, public_key_of, sign_grant @@ -323,8 +328,7 @@ class GitHandler(BaseHTTPRequestHandler): elif self.headers.get("Transfer-Encoding", "").lower() == "chunked": body = ChunkedBody(self.rfile) else: - # The relay reads no more than Content-Length from it. - body = self.rfile + body = SizedBody(self.rfile, self.headers.get("Content-Length")) try: response = self.server.git.handle( method=self.command, @@ -371,6 +375,29 @@ class GitHTTPServer(ThreadingHTTPServer): return f"http://{host!s}:{port}" +class SizedBody: + """A request body of known length, ending where the body does, as GitServer reads it. + + Read on, the connection's stream would wait for a next request. A Content-Length that isn't a length reads as an + empty body, and the relay refuses the header itself. + """ + + def __init__(self, rfile, content_length: str | None) -> None: + self.rfile = rfile + try: + self.remaining = max(int(content_length or 0), 0) + except ValueError: + self.remaining = 0 + + def read(self, size: int = -1, /) -> bytes: + size = self.remaining if size < 0 else min(size, self.remaining) + if size <= 0: + return b"" + data = self.rfile.read(size) + self.remaining -= len(data) + return data + + class ChunkedBody: """A chunked request body, decoded as the relay reads it. @@ -580,6 +607,13 @@ class Checks: self.git(push_grant, "-c", "http.postBuffer=65536", "push", url, f"HEAD:refs/heads/{branch}", cwd=clone), ) if pushed: + tip = self.git(push_grant, "rev-parse", "HEAD", cwd=clone).stdout.strip() + response = self.forge_create(url, branch, tip) + self.check( + "the provider refuses a forged create of an existing branch under a create_only rule", + subprocess.CompletedProcess([], response.status_code, response.text, ""), + response.status_code == 200 and f"ng refs/heads/{branch}".encode() in response.content, + ) self.expect_rejection( "deleting the pushed branch with the push grant is rejected", self.git(push_grant, "push", url, f":refs/heads/{branch}", cwd=clone), @@ -590,6 +624,27 @@ class Checks: ) server.shutdown() + def forge_create(self, url: str, branch: str, tip: str) -> requests.Response: + """Push ``branch`` to ``tip`` as a new branch under a create_only rule, though it exists already. + + GitServer passes it as a create, so the provider's own refusal of a create over an existing branch is all that + keeps a create_only rule from moving one. git would send the branch's current id, so the request is built by + hand. + """ + rule = GrantV2PushRule( + repository=self.backend.repository_id, branch_patterns=[re.escape(branch)], create_only=True + ) + response = requests.post( + f"{url}/git-receive-pack", + data=forged_create_body(branch, tip), + headers={ + GRANT_HEADER: grant_for(self.args.private_key, rule), + "Content-Type": "application/x-git-receive-pack-request", + }, + timeout=60, + ) + return response + def delete_directly(self, branch: str, clone: Path) -> subprocess.CompletedProcess[str]: """Delete ``branch`` on the provider with the token Sentry would use, since a push grant cannot.""" provider = self.backend.fetch_provider(ORGANIZATION_ID, self.backend.repository) @@ -609,6 +664,17 @@ class Checks: return subprocess.run(command, cwd=clone, env=env, capture_output=True, text=True, timeout=300) +def forged_create_body(branch: str, tip: str) -> bytes: + """A receive-pack request that creates ``branch`` at ``tip``, an object the provider has, even if it exists.""" + update = f"{'0' * len(tip)} {tip} refs/heads/{branch}\0report-status".encode() + if len(tip) == 64: + update += b" object-format=sha256" + # An empty pack, since the provider has the commit already: a header for zero objects and its hash. + header = b"PACK" + (2).to_bytes(4, "big") + (0).to_bytes(4, "big") + pack = header + (hashlib.sha1 if len(tip) == 40 else hashlib.sha256)(header).digest() + return b"%04x" % (len(update) + 4) + update + b"0000" + pack + + def _indent(text: str) -> str: return "".join(f" {line}\n" for line in text.splitlines()) diff --git a/src/scm/git/push.py b/src/scm/git/push.py index 5ef13f9d..2318892c 100644 --- a/src/scm/git/push.py +++ b/src/scm/git/push.py @@ -1,27 +1,48 @@ -"""Reading a push's ref updates, so GitServer can keep a push to its rule's branches. +"""Checking a push's ref updates, so GitServer can keep a push to its grant's push rules. -A push's request body lists the ref updates as pkt-lines, then holds the pack. GitServer reads the updates before it -looks anything up, refuses the push unless the rule allows every update, and otherwise relays the updates it read -followed by the rest of the body, which it never reads. gitprotocol-pack(5) describes the format, which -receive-pack speaks in protocol versions 0 and 1 alike, the only ones it has. +A push's request body lists the ref updates as pkt-lines, then holds the pack. ``check_push`` reads the updates +before GitServer looks anything up, refuses the push unless a rule allows every update, and otherwise returns a body +that sends the updates it read followed by the rest, which it never reads. Anything it doesn't recognise refuses the +push. gitprotocol-pack(5) describes the format, which receive-pack speaks in protocol versions 0 and 1 alike, the +only ones it has. """ +import contextlib import re from collections.abc import Mapping, Sequence from typing import NamedTuple -from scm.errors import ClientClosedRequest, GitMalformedRequestBody, GitOperationNotGranted, RpcMalformedRequestHeaders +from scm.errors import GitMalformedRequestBody, GitOperationNotGranted, SCMCodedError +from scm.git.relay import DEFAULT_CHUNK_SIZE, _read from scm.git.types import GrantV2PushRule, Readable MAX_UPDATES_LENGTH = 64 * 1024 """The most bytes a push may send before its pack. An update takes under 100 bytes plus its ref's name, and a push from a shallow clone also lists the clone's shallow commits, about 50 bytes each.""" +# The capabilities git asks for in a push, apart from push options, which ask the provider to act on the push, such +# as GitLab opening a merge request or skipping CI. Any other is refused, since it could change what a push does. +_CAPABILITIES = frozenset( + {"atomic", "ofs-delta", "quiet", "report-status", "report-status-v2", "side-band", "side-band-64k"} +) +_CAPABILITIES_WITH_VALUES = frozenset({"agent", "object-format", "session-id"}) + _FLUSH = b"0000" # A pkt-line's length, in 4 hex digits, counts the 4 digits too. _PKT_LENGTH = re.compile(rb"[0-9a-fA-F]{4}") _MAX_PKT_LENGTH = 65520 _OBJECT_ID = re.compile(rb"[0-9a-f]{40}|[0-9a-f]{64}") +# The most of a ref's name a refusal's detail shows. The pushing client chooses the name, and the detail reaches +# error reports and logs. +_SHOWN_REF_LENGTH = 200 + + +class PushRefused(GitOperationNotGranted): + """A push the grant does not allow, with the response that tells git why, if the client can read one.""" + + def __init__(self, *, detail: str, report: bytes | None) -> None: + super().__init__(detail=detail) + self.report = report class RefUpdate(NamedTuple): @@ -46,12 +67,41 @@ class PushRequest(NamedTuple): """The bytes read from the body, to send on before the rest of it.""" +def check_push(rules: Sequence[GrantV2PushRule], headers: Mapping[str, str], body: Readable | None) -> Readable: + """Return the body to relay for a push that ``rules`` allow, and refuse any other. + + ``body`` must end where the request body ends. A refused push's body is read to the end first, because git reads + the answer only once it has sent the whole push, and a connection closed before then shows git only a broken + pipe. Raises PushRefused when no rule allows an update, or for a signed push, and GitMalformedRequestBody for a + body that is missing or not a list of ref updates. A push with no rules is refused like any other. + """ + if body is None: + raise GitMalformedRequestBody(detail="The push has no body.") + try: + request = read_push_request(headers, body) + if not request.updates and _read(body, 1): + # git's probe before a large push is a lone flush, and a provider reads nothing after a flush that + # updates no ref. + raise GitMalformedRequestBody(detail="The push updates no ref but goes on after its ref updates.") + refused = refused_updates(rules, request) + if refused: + ref, reason = next(iter(refused.items())) + raise PushRefused( + detail=f"The git grant does not allow this push: {_shown(ref)} ({reason}).", + report=refusal_report(request, refused), + ) + except (GitOperationNotGranted, GitMalformedRequestBody): + _drain(body) + raise + return ReplayedBody(request.head, body) + + def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest: """Read a push's ref updates from ``body``, leaving the pack unread. Raises GitMalformedRequestBody for a body that does not start with a list of ref updates, GitOperationNotGranted - for a compressed body or a signed push, whose updates come inside a certificate, and ClientClosedRequest if - reading the body fails. + for a compressed body, PushRefused for a signed push, whose updates come inside a certificate, and + ClientClosedRequest if reading the body fails. """ encoding = next((value for name, value in headers.items() if name.lower() == "content-encoding"), "identity") if encoding.strip().lower() not in ("", "identity"): @@ -66,11 +116,13 @@ def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest if not _OBJECT_ID.fullmatch(line.removeprefix(b"shallow ")): raise GitMalformedRequestBody(detail="The push lists a shallow commit that is not an object id.") continue - if not updates and line.startswith(b"push-cert"): - raise GitOperationNotGranted(detail="The git endpoint does not relay signed pushes.") + if not updates and line.startswith(b"push-cert\0"): + message = "The git endpoint does not relay signed pushes." + requested = _capabilities(line.partition(b"\0")[2]) + raise PushRefused(detail=message, report=_error_report(requested, message)) if not updates: - line, _, requested = line.partition(b"\0") - capabilities = _capabilities(requested) + line, _, requested_bytes = line.partition(b"\0") + capabilities = _capabilities(requested_bytes) updates.append(_ref_update(line)) return PushRequest(updates=updates, capabilities=capabilities, head=reader.read_so_far()) @@ -78,11 +130,13 @@ def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest def refused_updates(rules: Sequence[GrantV2PushRule], request: PushRequest) -> dict[bytes, str]: """Return why ``rules`` refuse each ref of ``request``, or nothing if every update has a rule that allows it. - A push is relayed whole or not at all, so once one update is refused, every other ref gets a reason too. + A push is relayed whole or not at all, so once one update is refused, every other ref gets a reason too. With no + rules, every update is refused. """ - if "push-options" in request.capabilities: - # Push options ask the provider to act on the push, such as GitLab opening a merge request or skipping CI. - return {update.ref: "the git endpoint does not relay push options" for update in request.updates} + unsupported = sorted(name for name in request.capabilities if not _supported(name)) + if unsupported: + reason = f"the git endpoint does not relay {_capability_name(unsupported[0])}" + return {update.ref: reason for update in request.updates} refused: dict[bytes, str] = {} for update in request.updates: refusals = [_refusal(rule, update) for rule in rules] @@ -134,46 +188,7 @@ def refusal_report(request: PushRequest, refused: Mapping[bytes, str]) -> bytes + b"".join(_pkt(b"ng %s %s\n" % (update.ref, refused[update.ref].encode())) for update in request.updates) + _FLUSH ) - if "side-band-64k" in request.capabilities: - band_size = _MAX_PKT_LENGTH - 5 - elif "side-band" in request.capabilities: - band_size = 1000 - 5 - else: - return report - # On a side band the report travels on band 1, and the response ends with a flush of its own. - return b"".join(_pkt(b"\1" + report[i : i + band_size]) for i in range(0, len(report), band_size)) + _FLUSH - - -def bounded_body(headers: Mapping[str, str], body: Readable) -> Readable: - """Return ``body``, ending after its Content-Length if it has one, as the relay reads it. - - A caller may pass a stream that goes on past the body, such as a connection's socket, and reading the pushed - updates or a refused pack past the body's end would wait for bytes that never come. - """ - raw_length = next((value for name, value in headers.items() if name.lower() == "content-length"), None) - if raw_length is None: - return body - try: - length = int(raw_length) - except ValueError: - raise RpcMalformedRequestHeaders(detail="Could not parse Content-Length header") from None - if length < 0: - raise RpcMalformedRequestHeaders(detail="Could not parse Content-Length header") - return _BoundedBody(body, length) - - -class _BoundedBody: - def __init__(self, body: Readable, length: int) -> None: - self.body = body - self.remaining = length - - def read(self, size: int = -1, /) -> bytes: - size = self.remaining if size < 0 else min(size, self.remaining) - if size == 0: - return b"" - data = self.body.read(size) - self.remaining -= len(data) - return data + return _on_side_band(request.capabilities, 1, report) or report class ReplayedBody: @@ -219,10 +234,7 @@ def _read(self, size: int) -> bytes: raise GitMalformedRequestBody(detail=f"The push's ref updates are longer than {MAX_UPDATES_LENGTH} bytes.") data = bytearray() while len(data) < size: - try: - chunk = self.body.read(size - len(data)) - except OSError: - raise ClientClosedRequest(detail="The client's connection failed while it sent its push.") from None + chunk = _read(self.body, size - len(data)) if not chunk: raise GitMalformedRequestBody(detail="The push ended before its ref updates did.") data += chunk @@ -230,6 +242,40 @@ def _read(self, size: int) -> bytes: return bytes(data) +def _drain(body: Readable) -> None: + with contextlib.suppress(OSError, SCMCodedError): + while body.read(DEFAULT_CHUNK_SIZE): + pass + + +def _supported(capability: str) -> bool: + name, has_value, _ = capability.partition("=") + return name in _CAPABILITIES_WITH_VALUES if has_value else name in _CAPABILITIES + + +def _capability_name(capability: str) -> str: + if capability == "push-options": + return "push options" + return f"the {capability.partition('=')[0]} capability" + + +def _error_report(capabilities: frozenset[str], message: str) -> bytes | None: + """Return a response that shows git ``message`` before it gives up, or None if the client can read none.""" + # Band 3 carries a message git prints as "remote: " before it stops. + return _on_side_band(capabilities, 3, f"{message}\n".encode()) + + +def _on_side_band(capabilities: frozenset[str], band: int, data: bytes) -> bytes | None: + """Return ``data`` on side band ``band``, ending with a flush, or None if the client asked for no side band.""" + if "side-band-64k" in capabilities: + band_size = _MAX_PKT_LENGTH - 5 + elif "side-band" in capabilities: + band_size = 1000 - 5 + else: + return None + return b"".join(_pkt(bytes([band]) + data[i : i + band_size]) for i in range(0, len(data), band_size)) + _FLUSH + + def _capabilities(requested: bytes) -> frozenset[str]: try: return frozenset(requested.decode("ascii").split()) @@ -245,5 +291,10 @@ def _ref_update(line: bytes) -> RefUpdate: return RefUpdate(old_id=old_id, new_id=new_id, ref=ref) +def _shown(ref: bytes) -> str: + name = ref.decode(errors="replace") + return name if len(name) <= _SHOWN_REF_LENGTH else f"{name[:_SHOWN_REF_LENGTH]}..." + + def _pkt(payload: bytes) -> bytes: return b"%04x" % (len(payload) + 4) + payload diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py index e0a957a9..6c3002b6 100644 --- a/src/scm/git/relay.py +++ b/src/scm/git/relay.py @@ -72,11 +72,23 @@ def authorize_request( Fetches reach only upload-pack and pushes only receive-pack. The grant must name the repository in the same form the request uses: a database id does not match the same repository's ``(provider, external id)`` pair. - A ``GrantV2`` allows a request only if it has a rule for the request's repository and operation. Push rules' - branches are kept by GitServer, which reads the push's ref updates before it relays the push, so a caller that - relays a push some other way must check them itself. A fetch needs a fetch rule for any object, since nothing - reads a fetch's body to keep it to a rule's branches. + A ``GrantV2`` allows a request only if it has a rule for the request's repository and operation. A fetch needs a + fetch rule for any object, since nothing reads a fetch's body to keep it to a rule's branches. A push under a + ``GrantV2`` is refused here, because only its ref updates show which branches it touches: relay it through + GitServer, which reads them before it relays the push. """ + operation = _authorize(grant, repository_id, method, route, query) + if isinstance(grant, GrantV2) and operation == "push" and method == "POST": + raise GitOperationNotGranted( + detail="A push under a git grant with rules goes through GitServer, which checks its ref updates." + ) + return operation + + +def _authorize( + grant: GrantV1 | GrantV2, repository_id: RepositoryId, method: str, route: str, query: str +) -> GitOperation: + """``authorize_request`` for GitServer, which checks a push's ref updates against the grant's push rules itself.""" operation = _ROUTES.get((method, route, query)) if operation is None: raise GitRouteNotSupported(detail=f"{method} {route} is not a git fetch or push route.") diff --git a/src/scm/git/server.py b/src/scm/git/server.py index a76b2026..28a807c8 100644 --- a/src/scm/git/server.py +++ b/src/scm/git/server.py @@ -1,25 +1,16 @@ -import contextlib from collections.abc import Callable, Mapping, Sequence from urllib.parse import urlsplit from scm.errors import ( ErrorCode, - GitOperationNotGranted, GitProviderNotSupported, GitTokenRejected, GitTokenUnavailable, SCMCodedError, ) from scm.git.grant import require_public_keys, verify_grant -from scm.git.push import ( - PushRequest, - ReplayedBody, - bounded_body, - read_push_request, - refusal_report, - refused_updates, -) -from scm.git.relay import DEFAULT_CHUNK_SIZE, GitRelay, ResponseBody, authorize_request +from scm.git.push import PushRefused, check_push +from scm.git.relay import GitRelay, ResponseBody, _authorize from scm.git.types import GRANT_HEADER, GetToken, GrantV2, GrantV2PushRule, Readable from scm.helpers import initialize_provider from scm.rpc.types import StreamResponse @@ -75,13 +66,12 @@ class GitServer: does not allow never reaches the database, the token supplier or the provider. Give it a relay whose adapter refuses internal addresses; see ``GitRelay``. - A push under a ``GrantV2`` is kept to its push rules. The server reads the push's ref updates, which come before - the pack, and refuses the whole push if no rule allows one of them: a branch no rule covers, an existing branch - under a ``create_only`` rule, a deletion, or any ref that isn't a branch, such as a tag. It refuses push options - and signed pushes too. A client that - asked for a status report gets one, a 200 that git shows as a rejected ref with its reason. A push the rule - allows is relayed with the updates the server read followed by the rest of the body, unread. A push under a - ``GrantV1`` is relayed unread, as before rules. + A push under a ``GrantV2`` is kept to its push rules by ``scm.git.push.check_push``, which reads the push's ref + updates before anything is looked up and refuses the whole push unless a rule allows every update. Anything it + doesn't recognise, such as push options, a signed push or a capability git doesn't send, refuses the push too. A + client that asked for a status report gets one, a 200 that git shows as a rejected ref with its reason. A push + the rules allow is relayed with the updates the server read followed by the rest of the body, unread. A push + under a ``GrantV1`` is relayed unread, as before rules. The repository's provider says where its git endpoint is, through ``GetGitRemoteProtocol``. A repository whose provider has no git remote is refused. @@ -143,10 +133,10 @@ def handle( """Answer one git request. ``route`` is the path below ``.git/`` and ``query`` the raw query string. ``body`` is read only - while the request is sent upstream, so pass the unread request stream. It must yield the body already - decoded from its transfer encoding, ending where the body ends. A Django request under WSGI reads a chunked - body as empty, and git sends a push over about 1 MiB chunked, so a Django view needs to pass a stream that - has the whole body. + while the request is sent upstream, apart from a push's ref updates, so pass the unread request stream. It + must yield the body already decoded from its transfer encoding, ending where the body ends. A Django request + under WSGI reads a chunked body as empty, and git sends a push over about 1 MiB chunked, so a Django view + needs to pass a stream that has the whole body. """ # Rebound without the grant, so it shows as [Filtered] in the frames of a reported error rather than in # ``headers``, here and in the relay, which never sends it upstream anyway. @@ -154,31 +144,18 @@ def handle( counted_body = _CountingReader(body) if body is not None else None context = {"repository_id": str(repository_id)} referrer_tag: dict[str, str] = {} - push_body: Readable | None = None - push: PushRequest | None = None - refused: dict[bytes, str] = {} try: grant = verify_grant(self.public_keys, grant_token.reveal()) context["organization_id"] = str(grant.organization_id) referrer_tag["referrer"] = context["referrer"] = grant.referrer - operation = authorize_request(grant, repository_id, method, route, query) + operation = _authorize(grant, repository_id, method, route, query) context["operation"] = operation relayed_body: Readable | None = counted_body - push_rules = ( - [rule for rule in grant.rules_for(repository_id, operation) if isinstance(rule, GrantV2PushRule)] - if isinstance(grant, GrantV2) - else [] - ) - if push_rules and method == "POST" and counted_body is not None: - push_body = bounded_body(headers, counted_body) - push = read_push_request(headers, push_body) - refused = refused_updates(push_rules, push) - if refused: - ref, reason = next(iter(refused.items())) - raise GitOperationNotGranted( - detail=f"The git grant does not allow this push: {ref.decode(errors='replace')} ({reason})." - ) - relayed_body = ReplayedBody(push.head, push_body) + if isinstance(grant, GrantV2) and operation == "push" and method == "POST": + push_rules = [ + rule for rule in grant.rules_for(repository_id, "push") if isinstance(rule, GrantV2PushRule) + ] + relayed_body = check_push(push_rules, headers, counted_body) provider = initialize_provider( grant.organization_id, repository_id, @@ -218,11 +195,8 @@ def handle( self.record_count("sentry.scm.git.request.failed", 1, {"code": e.code} | referrer_tag) if e.code != "client_closed_request": self.emit_error(e, context) - if push is not None and refused and push_body is not None: - _drain(push_body) - status_report = refusal_report(push, refused) - if status_report is not None: - return _report_response(status_report) + if isinstance(e, PushRefused) and e.report is not None: + return _report_response(e.report) return _error_response(e) except Exception: self.record_count("sentry.scm.git.request.failed", 1, {"code": "unhandled_exception"} | referrer_tag) @@ -328,14 +302,6 @@ def _end(self, failure: str | None) -> None: self.finish(failure, self.bytes_sent) -def _drain(body: Readable) -> None: - # git reads the response only once it has sent the whole push, and a connection closed before then shows only as - # a broken pipe, hiding the reason, so a refused push's pack is read to the end and dropped. - with contextlib.suppress(OSError, SCMCodedError): - while body.read(DEFAULT_CHUNK_SIZE): - pass - - def _report_response(report: bytes) -> StreamResponse: return StreamResponse( status_code=200, diff --git a/tests/unit/test_git_push.py b/tests/unit/test_git_push.py index a333c98f..5079cb59 100644 --- a/tests/unit/test_git_push.py +++ b/tests/unit/test_git_push.py @@ -2,13 +2,14 @@ import pytest -from scm.errors import ClientClosedRequest, GitMalformedRequestBody, GitOperationNotGranted, RpcMalformedRequestHeaders +from scm.errors import ClientClosedRequest, GitMalformedRequestBody, GitOperationNotGranted from scm.git.push import ( MAX_UPDATES_LENGTH, + PushRefused, PushRequest, RefUpdate, ReplayedBody, - bounded_body, + check_push, read_push_request, refusal_report, refused_updates, @@ -247,6 +248,33 @@ def test_reason_of_the_rule_the_update_came_closest_to_passing(self): b"refs/heads/seer/old": "the git grant does not allow deleting a branch" } + @pytest.mark.parametrize( + "capabilities", + [ + "report-status side-band-64k quiet object-format=sha1 agent=git/2.54.0", + "report-status-v2 side-band atomic ofs-delta session-id=abc", + "", + ], + ) + def test_capabilities_git_sends(self, capabilities): + assert ( + refused_updates(RULES, request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), capabilities=capabilities)) + == {} + ) + + @pytest.mark.parametrize( + "capability,reason", + [ + ("future-capability", "the git endpoint does not relay the future-capability capability"), + ("quiet=1", "the git endpoint does not relay the quiet capability"), + ("agent", "the git endpoint does not relay the agent capability"), + ], + ) + def test_capability_git_does_not_send(self, capability, reason): + # Anything unknown could change what the push does, so it refuses the push. + request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), capabilities=f"report-status {capability}") + assert refused_updates(RULES, request) == {b"refs/heads/seer/fix": reason} + def test_push_options(self): request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), capabilities="report-status push-options") assert refused_updates(RULES, request) == { @@ -326,30 +354,93 @@ def test_empty_head(self): assert ReplayedBody(b"", io.BytesIO(b"rest")).read(64) == b"rest" -class TestBoundedBody: - def test_ends_at_content_length(self): - # As a connection's socket would, the stream goes on past the body. - stream = io.BytesIO(b"body" + b"next request") - body = bounded_body({"content-length": "4"}, stream) - assert body.read(64) == b"body" - assert body.read(64) == b"" - assert body.read() == b"" - assert stream.tell() == 4 +class Recording(io.BytesIO): + """A body that tells how far it was read.""" - def test_short_reads(self): - body = bounded_body({"Content-Length": "4"}, Trickle(b"body and more")) - assert b"".join(iter(lambda: body.read(64), b"")) == b"body" - def test_without_content_length(self): - stream = io.BytesIO(b"body") - assert bounded_body({}, stream) is stream +class TestCheckPush: + def check(self, body: bytes, rules=RULES, headers: dict[str, str] | None = None) -> tuple[bytes, Recording]: + stream = Recording(body) + relayed = check_push(rules, headers or {}, stream) + return relayed.read(), stream - @pytest.mark.parametrize("length", ["x", "-1", ""]) - def test_invalid_content_length(self, length): - with pytest.raises(RpcMalformedRequestHeaders): - bounded_body({"Content-Length": length}, io.BytesIO(b"")) + def test_allowed_push_relays_the_whole_body(self): + body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")) + relayed, _ = self.check(body) + assert relayed == body + + def test_probe(self): + relayed, _ = self.check(b"0000") + assert relayed == b"0000" + + def test_no_updates_with_more_after_them(self): + # A provider reads nothing after a flush that updates no ref, so whatever follows has no business there. + with pytest.raises(GitMalformedRequestBody, match="updates no ref but goes on"): + self.check(b"0000" + PACK) + + def test_refused_push_is_read_to_the_end(self): + stream = Recording(make_push_body((OLD_ID, NEW_ID, "refs/heads/main"), capabilities="report-status")) + with pytest.raises(PushRefused) as exc_info: + check_push(RULES, {}, stream) + assert stream.read() == b"" + assert exc_info.value.code == "git_operation_not_granted" + assert exc_info.value.detail == ( + "The git grant does not allow this push: refs/heads/main (the git grant does not cover this branch)." + ) + assert exc_info.value.report == ( + pkt(b"unpack ok\n") + pkt(b"ng refs/heads/main the git grant does not cover this branch\n") + b"0000" + ) + + def test_no_rules_refuse_every_update(self): + with pytest.raises(PushRefused, match="does not cover this branch"): + self.check(make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")), rules=[]) - def test_truncated_updates(self): + def test_no_body(self): + with pytest.raises(GitMalformedRequestBody, match="has no body"): + check_push(RULES, {}, None) + + @pytest.mark.parametrize( + "body,headers,error", + [ + (b"garbage and then some", {}, GitMalformedRequestBody), + ( + make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")), + {"Content-Encoding": "gzip"}, + GitOperationNotGranted, + ), + (pkt(b"push-cert\0report-status") + b"0000" + PACK, {}, PushRefused), + ], + ids=["malformed", "compressed", "signed"], + ) + def test_other_refusals_are_read_to_the_end(self, body, headers, error): + stream = Recording(body) + with pytest.raises(error): + check_push(RULES, headers, stream) + assert stream.read() == b"" + + def test_signed_push_on_a_side_band(self): + # git prints band 3 as "remote: " before it stops, where a 403 would show only its status. + body = pkt(b"push-cert\0report-status side-band-64k") + pkt(b"certificate version 0.1\n") + b"0000" + with pytest.raises(PushRefused) as exc_info: + self.check(body) + assert exc_info.value.report == pkt(b"\3The git endpoint does not relay signed pushes.\n") + b"0000" + + def test_signed_push_without_a_side_band(self): + with pytest.raises(PushRefused) as exc_info: + self.check(pkt(b"push-cert\0report-status") + b"0000") + assert exc_info.value.report is None + + def test_body_that_ends_early(self): body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")) with pytest.raises(GitMalformedRequestBody, match="ended before its ref updates did"): - read_push_request({}, bounded_body({"Content-Length": "20"}, io.BytesIO(body))) + self.check(body[:20]) + + def test_long_ref_is_cut_short_in_the_detail(self): + ref = "refs/heads/" + "x" * 1000 + with pytest.raises(PushRefused) as exc_info: + self.check(make_push_body((OLD_ID, NEW_ID, ref), capabilities="report-status")) + assert exc_info.value.detail == ( + f"The git grant does not allow this push: {ref[:200]}... (the git grant does not cover this branch)." + ) + # The report names the whole ref, since git matches it to the ref it pushed. + assert ref.encode() in (exc_info.value.report or b"") diff --git a/tests/unit/test_git_relay.py b/tests/unit/test_git_relay.py index 0d9208cd..57c39c95 100644 --- a/tests/unit/test_git_relay.py +++ b/tests/unit/test_git_relay.py @@ -25,7 +25,7 @@ ResourceServiceUnavailable, SCMCodedError, ) -from scm.git.relay import GitRelay, _UpstreamStream, authorize_request +from scm.git.relay import GitRelay, _authorize, _UpstreamStream, authorize_request from scm.git.types import GrantV2FetchRule, GrantV2PushRule from scm.secret import Secret from scm.types import GitRemote @@ -752,9 +752,17 @@ def test_fetch_rule_reaches_fetch_routes(self, route): assert authorize_request(make_grant(rules=[FETCH_ANY]), 5, *route) == "fetch" @pytest.mark.parametrize("route", PUSH_ROUTES) - def test_push_rule_reaches_push_routes(self, route): + def test_push_rule_reaches_push_routes_through_gitserver(self, route): # GitServer keeps the push to the rule's branches. - assert authorize_request(make_grant(rules=[PUSH_BRANCH]), 5, *route) == "push" + assert _authorize(make_grant(rules=[PUSH_BRANCH]), 5, *route) == "push" + + def test_push_rule_lists_refs(self): + assert authorize_request(make_grant(rules=[PUSH_BRANCH]), 5, *PUSH_ROUTES[0]) == "push" + + def test_push_under_rules_is_refused_outside_gitserver(self): + # Only the push's ref updates show which branches it touches, and this doesn't read them. + with pytest.raises(GitOperationNotGranted, match="goes through GitServer"): + authorize_request(make_grant(rules=[PUSH_BRANCH]), 5, *PUSH_ROUTES[1]) @pytest.mark.parametrize("route", PUSH_ROUTES) def test_fetch_rule_cannot_reach_push_routes(self, route): @@ -781,7 +789,7 @@ def test_any_of_several_rules(self): ] ) assert authorize_request(grant, 5, "POST", "git-upload-pack", "") == "fetch" - assert authorize_request(grant, 5, "POST", "git-receive-pack", "") == "push" + assert _authorize(grant, 5, "POST", "git-receive-pack", "") == "push" def test_fetch_rules_that_all_list_branches_are_refused(self): grant = make_grant( diff --git a/tests/unit/test_git_server.py b/tests/unit/test_git_server.py index 7f6818c5..d09a6ccc 100644 --- a/tests/unit/test_git_server.py +++ b/tests/unit/test_git_server.py @@ -831,13 +831,20 @@ def test_other_refused_updates(self, ref, old_id, new_id, reason): body = make_push_body((old_id, new_id, ref), capabilities="report-status") assert reason.encode() in b"".join(self.send(make_server(Recorder()), io.BytesIO(body)).content) - def test_drains_no_further_than_content_length(self): - # A caller may pass a connection's stream, which goes on past the body, and reading on would wait for ever. - body = push_body("refs/heads/main") - stream = io.BytesIO(body + b"the next request") - response = self.send(make_server(Recorder()), stream, headers={"Content-Length": str(len(body))}) + def test_no_body(self): + # Refused rather than relayed unchecked. + recorder = Recorder() + server = make_server(recorder) + response = request(server, PUSH_GRANT, method="POST", route="git-receive-pack", query="", body=None) + assert_refused(response, 400, "The push has no body.") + assert recorder.repository_lookups == [] + server.relay.forward.assert_not_called() # type: ignore[attr-defined] + + def test_signed_push_on_a_side_band(self): + body = pkt(b"push-cert\0report-status side-band-64k") + pkt(b"certificate version 0.1\n") + b"0000" + response = self.send(make_server(Recorder()), io.BytesIO(body)) assert response.status_code == 200 - assert stream.tell() == len(body) + assert b"".join(response.content) == pkt(b"\3The git endpoint does not relay signed pushes.\n") + b"0000" def test_create_only_pattern_and_listed_branch(self): grant = sign_grant( From 0e2f08abc56a2710a004a1eae9996a51e63a9a80 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 20:38:02 +0100 Subject: [PATCH 11/23] fix(git): Refuse any push line git wouldn't send receive-pack reads capabilities after a NUL on every ref update, so one on a later update would have slipped past the allowlist. A push is now refused unless it looks exactly as git sends it: capabilities on the first update only, branch names git takes, object ids of the push's object format, and each ref updated once. Co-Authored-By: Claude Opus 5.5 --- src/scm/git/push.py | 44 ++++++++++++++++++++------ src/scm/git/types.py | 24 +++++++++------ tests/unit/test_git_push.py | 61 ++++++++++++++++++++++++++++++------- 3 files changed, 99 insertions(+), 30 deletions(-) diff --git a/src/scm/git/push.py b/src/scm/git/push.py index 2318892c..18e29f92 100644 --- a/src/scm/git/push.py +++ b/src/scm/git/push.py @@ -14,7 +14,7 @@ from scm.errors import GitMalformedRequestBody, GitOperationNotGranted, SCMCodedError from scm.git.relay import DEFAULT_CHUNK_SIZE, _read -from scm.git.types import GrantV2PushRule, Readable +from scm.git.types import GrantV2PushRule, Readable, _is_branch_name MAX_UPDATES_LENGTH = 64 * 1024 """The most bytes a push may send before its pack. An update takes under 100 bytes plus its ref's name, and a push @@ -26,6 +26,8 @@ {"atomic", "ofs-delta", "quiet", "report-status", "report-status-v2", "side-band", "side-band-64k"} ) _CAPABILITIES_WITH_VALUES = frozenset({"agent", "object-format", "session-id"}) +# The object formats git names in object-format=, and the length of an object id in each, in hex. +_OBJECT_ID_LENGTHS = {"sha1": 40, "sha256": 64} _FLUSH = b"0000" # A pkt-line's length, in 4 hex digits, counts the 4 digits too. @@ -108,22 +110,40 @@ def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest # git compresses fetches but never pushes. raise GitOperationNotGranted(detail="The git endpoint does not read a compressed push.") reader = _PktReader(body) + shallow: list[bytes] = [] updates: list[RefUpdate] = [] capabilities: frozenset[str] = frozenset() + # Each line must be what git sends, in the order it sends them. receive-pack reads more than that, such as + # capabilities on any update and shallow commits between updates, and anything this lets through it relays. while (line := reader.read_line()) is not None: if not updates and line.startswith(b"shallow "): # A push from a shallow clone lists its shallow commits first, and they pass on unchanged. - if not _OBJECT_ID.fullmatch(line.removeprefix(b"shallow ")): - raise GitMalformedRequestBody(detail="The push lists a shallow commit that is not an object id.") + shallow.append(line.removeprefix(b"shallow ")) continue if not updates and line.startswith(b"push-cert\0"): message = "The git endpoint does not relay signed pushes." requested = _capabilities(line.partition(b"\0")[2]) raise PushRefused(detail=message, report=_error_report(requested, message)) + line, separator, requested_bytes = line.partition(b"\0") + if separator and updates: + raise GitMalformedRequestBody(detail="Only the push's first ref update may carry capabilities.") if not updates: - line, _, requested_bytes = line.partition(b"\0") capabilities = _capabilities(requested_bytes) updates.append(_ref_update(line)) + + formats = [name.partition("=")[2] for name in capabilities if name.startswith("object-format=")] + if len(formats) > 1: + raise GitMalformedRequestBody(detail="The push names more than one object format.") + # Without object-format=, git means SHA-1. + id_length = _OBJECT_ID_LENGTHS.get(formats[0] if formats else "sha1") + if id_length is None: + raise GitMalformedRequestBody(detail="The push names an object format git doesn't have.") + if any(not _OBJECT_ID.fullmatch(commit) or len(commit) != id_length for commit in shallow): + raise GitMalformedRequestBody(detail="The push lists a shallow commit that is not an object id.") + if any(len(update.old_id) != id_length for update in updates): + raise GitMalformedRequestBody(detail="The push's object ids don't match its object format.") + if len({update.ref for update in updates}) < len(updates): + raise GitMalformedRequestBody(detail="The push updates a ref more than once.") return PushRequest(updates=updates, capabilities=capabilities, head=reader.read_so_far()) @@ -160,11 +180,8 @@ def _refusal(rule: GrantV2PushRule, update: RefUpdate) -> tuple[int, str] | None """Return why ``rule`` refuses ``update``, ranked by how far the update got, or None if the rule allows it.""" if not update.ref.startswith(b"refs/heads/"): return _NOT_A_BRANCH - try: - branch = update.ref.removeprefix(b"refs/heads/").decode() - except UnicodeDecodeError: - return _NOT_COVERED - if not rule.covers(branch): + # Reading the push checked that the branch's name is one git takes, so it decodes. + if not rule.covers(update.ref.removeprefix(b"refs/heads/").decode()): return _NOT_COVERED if update.deletes: return _DELETES @@ -288,6 +305,15 @@ def _ref_update(line: bytes) -> RefUpdate: new_id, _, ref = rest.partition(b" ") if not (_OBJECT_ID.fullmatch(old_id) and _OBJECT_ID.fullmatch(new_id) and len(old_id) == len(new_id) and ref): raise GitMalformedRequestBody(detail="The push's ref updates are malformed.") + if ref.startswith(b"refs/heads/"): + # A rule checks the branch the push names, and a name git wouldn't take might not be the branch the + # provider updates. + try: + branch = ref.removeprefix(b"refs/heads/").decode() + except UnicodeDecodeError: + branch = "" + if not _is_branch_name(branch): + raise GitMalformedRequestBody(detail="The push updates a branch whose name git wouldn't take.") return RefUpdate(old_id=old_id, new_id=new_id, ref=ref) diff --git a/src/scm/git/types.py b/src/scm/git/types.py index d4f335b9..947c2d11 100644 --- a/src/scm/git/types.py +++ b/src/scm/git/types.py @@ -203,21 +203,25 @@ def _check_branches(branches: list[BranchName]) -> None: if not branches: raise ValueError("A rule's branch list must not be empty.") for name in branches: - # git check-ref-format's rules for refs/heads/, with what git branch refuses on top (a leading "-" and - # "HEAD"). A name starting with refs/ is a valid branch, but it's far more likely a full ref name by mistake. - if ( - not name - or name in ("@", "HEAD") - or name.startswith(("-", "/", "refs/")) - or name.endswith(("/", ".")) - or _NOT_IN_A_REF.search(name) - or any(part.startswith(".") or part.endswith(".lock") for part in name.split("/")) - ): + if not _is_branch_name(name): raise ValueError( f"{name!r} is not a branch name. Name a branch as git branch takes it, without refs/heads/." ) +def _is_branch_name(name: str) -> bool: + # git check-ref-format's rules for refs/heads/, with what git branch refuses on top (a leading "-" and + # "HEAD"). A name starting with refs/ is a valid branch, but it's far more likely a full ref name by mistake. + return not ( + not name + or name in ("@", "HEAD") + or name.startswith(("-", "/", "refs/")) + or name.endswith(("/", ".")) + or _NOT_IN_A_REF.search(name) + or any(part.startswith(".") or part.endswith(".lock") for part in name.split("/")) + ) + + type GetToken = Callable[[Repository, GitOperation], Secret | None] """Return a provider token that can run ``operation`` on ``repository``, or None if there is none. diff --git a/tests/unit/test_git_push.py b/tests/unit/test_git_push.py index 5079cb59..ed75c403 100644 --- a/tests/unit/test_git_push.py +++ b/tests/unit/test_git_push.py @@ -72,9 +72,58 @@ def test_shallow_commits(self): def test_sha256_object_ids(self): old_id, new_id = "a" * 64, "b" * 64 - request, _ = read(make_push_body((old_id, new_id, "refs/heads/seer/fix"))) + body = make_push_body( + (old_id, new_id, "refs/heads/seer/fix"), capabilities="report-status object-format=sha256" + ) + request, _ = read(body) assert request.updates[0].new_id == new_id.encode() + @pytest.mark.parametrize( + "capabilities,ids,shallow", + [ + ("report-status object-format=sha256", ("1" * 40, "2" * 40), ()), + ("report-status object-format=sha1", ("a" * 64, "b" * 64), ()), + ("report-status", ("a" * 64, "b" * 64), ()), + ("report-status", (OLD_ID, NEW_ID), ("3" * 64,)), + ("report-status object-format=md5", (OLD_ID, NEW_ID), ()), + ("report-status object-format=sha1 object-format=sha256", (OLD_ID, NEW_ID), ()), + ], + ids=["sha256-with-sha1-ids", "sha1-with-sha256-ids", "default-with-sha256-ids", "shallow-length", "md5", "two"], + ) + def test_object_ids_that_dont_match_the_object_format(self, capabilities, ids, shallow): + body = make_push_body((*ids, "refs/heads/seer/fix"), capabilities=capabilities, shallow=shallow) + with pytest.raises(GitMalformedRequestBody): + read(body) + + def test_capabilities_on_a_later_update(self): + # receive-pack reads capabilities after a NUL on any update, so one there would slip past the allowlist. + first = f"{OLD_ID} {NEW_ID} refs/heads/seer/fix".encode() + later = f"{OLD_ID} {NEW_ID} refs/heads/seer/other".encode() + body = pkt(first + b"\0report-status") + pkt(later + b"\0push-options") + b"0000" + with pytest.raises(GitMalformedRequestBody, match="first ref update may carry capabilities"): + read(body) + + @pytest.mark.parametrize( + "ref", + [ + b"refs/heads/", + b"refs/heads/a b", + b"refs/heads/a..b", + b"refs/heads/a\x01", + b"refs/heads/x.lock", + b"refs/heads/\xff", + ], + ) + def test_branch_name_git_would_not_take(self, ref): + line = f"{OLD_ID} {NEW_ID} ".encode() + ref + with pytest.raises(GitMalformedRequestBody, match="name git wouldn't take"): + read(pkt(line + b"\0report-status") + b"0000") + + def test_same_ref_twice(self): + body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix"), ("2" * 40, "3" * 40, "refs/heads/seer/fix")) + with pytest.raises(GitMalformedRequestBody, match="more than once"): + read(body) + def test_short_reads(self): body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix"), shallow=("3" * 40,)) request = read_push_request({}, Trickle(body)) @@ -216,16 +265,6 @@ def test_create_only_listed_branch(self): b"refs/heads/seer/fix": "the git grant allows only creating this branch" } - def test_non_utf8_branch_against_a_pattern(self): - rules = [GrantV2PushRule(repository=5, branch_patterns=[".+"])] - ref = b"refs/heads/\xff" - request = PushRequest( - updates=[RefUpdate(old_id=OLD_ID.encode(), new_id=NEW_ID.encode(), ref=ref)], - capabilities=frozenset(), - head=b"", - ) - assert refused_updates(rules, request) == {ref: "the git grant does not cover this branch"} - def test_any_rule_allows_an_update(self): # The agent may start branches under seer/ and keep pushing to the one Seer added after its first push. rules = [ From de3eb2595add08153268e4877ebcab66a2c18c15 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 20:53:32 +0100 Subject: [PATCH 12/23] test(git): Add failing tests for pushes that slip past the push check An adversarial review of the GrantV2 push path. Each test fails while the check lets its input through: capability values git wouldn't send, a refusal report and detail that grow with a client-chosen capability name, control characters from non-branch refs in the refusal detail, a long branch name that pins a worker in a rule's pattern, and push headers that reach the provider unchecked. Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_git_push_adversarial.py | 137 ++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/unit/test_git_push_adversarial.py diff --git a/tests/unit/test_git_push_adversarial.py b/tests/unit/test_git_push_adversarial.py new file mode 100644 index 00000000..6d03aae9 --- /dev/null +++ b/tests/unit/test_git_push_adversarial.py @@ -0,0 +1,137 @@ +"""Pushes built to slip past the push check. Each test fails while the check lets its push through.""" + +import io +import time + +import pytest + +from scm.errors import GitMalformedRequestBody, GitOperationNotGranted +from scm.git.push import MAX_UPDATES_LENGTH, PushRefused, check_push +from scm.git.relay import GitRelay +from scm.git.types import GrantV2PushRule +from tests.unit.git_builders import NEW_ID, OLD_ID, make_push_body, make_repository, pkt +from tests.unit.git_upstream import running_upstream +from tests.unit.test_git_server import PUSH_GRANT, Recorder, make_server, request + +RULES = [GrantV2PushRule(repository=5, branches=["seer/fix"])] + + +def first_update(capabilities: bytes, ref: bytes = b"refs/heads/seer/fix") -> bytes: + return pkt(f"{OLD_ID} {NEW_ID} ".encode() + ref + b"\0" + capabilities) + + +class TestCapabilityValues: + @pytest.mark.parametrize( + "capabilities", + [ + b"report-status agent=git/2.54.0\0push-options", + b"report-status session-id=x\0push-options", + b"report-status agent=git/2.54.0\x01push-options", + b"report-status session-id=\x1b[2Jx", + ], + ids=["nul-in-agent", "nul-in-session-id", "control-in-agent", "escape-in-session-id"], + ) + def test_value_git_would_not_send_is_refused(self, capabilities): + # git sanitizes agent= to printable characters other than space and ends the line at its first NUL, but the + # allowlist checks only a value's name, so a NUL or control byte rides along in an allowed capability. git's + # receive-pack stops reading at the NUL. A receive-pack that reads the whole pkt-line would see push-options. + body = first_update(capabilities) + b"0000PACK" + with pytest.raises((GitOperationNotGranted, GitMalformedRequestBody)): + check_push(RULES, {}, io.BytesIO(body)) + + +def long_capability_push() -> bytes: + """A push whose one unsupported capability takes half the update budget, followed by as many updates as fit.""" + lines = [first_update(b"report-status " + b"x" * (MAX_UPDATES_LENGTH // 2), ref=b"r0")] + index = 1 + while sum(map(len, lines)) + len(line := pkt(f"{OLD_ID} {NEW_ID} r{index}".encode())) + 4 <= MAX_UPDATES_LENGTH: + lines.append(line) + index += 1 + return b"".join(lines) + b"0000" + + +class TestRefusalSize: + def test_report_does_not_repeat_a_long_capability_name_per_update(self): + # Each update's "ng" line carries the reason, and the reason quotes the capability whole. 64 KiB in makes + # about 12 MB out, built in memory and streamed back. + body = long_capability_push() + with pytest.raises(PushRefused) as exc_info: + check_push(RULES, {}, io.BytesIO(body)) + assert exc_info.value.report is not None + assert len(exc_info.value.report) <= 2 * len(body) + + def test_detail_cuts_a_long_capability_name_short(self): + # _SHOWN_REF_LENGTH keeps a client-chosen ref short in the detail, which reaches logs and Sentry issues. The + # capability's name is client-chosen too and goes in whole. + with pytest.raises(PushRefused) as exc_info: + check_push(RULES, {}, io.BytesIO(long_capability_push())) + assert len(exc_info.value.detail) <= 1000 + + +class TestRefusalDetail: + @pytest.mark.parametrize( + "ref", + [b"refs/tags/x\nERROR forged log line", b"refs/tags/x\x1b[2J", b"refs/pull/1\rhead"], + ids=["newline", "escape", "carriage-return"], + ) + def test_detail_holds_no_control_characters(self, ref): + # Only refs under refs/heads/ are checked against git's ref name rules, so any other ref reaches the detail + # with whatever bytes the client chose. git refuses such a name as a "funny refname" and never sends one. + body = first_update(b"report-status", ref=ref) + b"0000" + with pytest.raises((GitOperationNotGranted, GitMalformedRequestBody)) as exc_info: + check_push(RULES, {}, io.BytesIO(body)) + detail = exc_info.value.detail + assert not any(ord(character) < 0x20 or ord(character) == 0x7F for character in detail), detail + + +class TestPatternCost: + def test_long_branch_name_does_not_pin_the_worker(self): + # A rule's pattern runs against a branch name of up to 64 KiB. An ordinary-looking pattern with three + # wildcards is cubic in the name's length: 1,500 characters take seconds, and 60,000 would take days. + rules = [GrantV2PushRule(repository=5, branch_patterns=["seer/.+-.+-.+-[0-9]+"])] + body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/" + "-" * 1500 + "x")) + started = time.perf_counter() + with pytest.raises((GitOperationNotGranted, GitMalformedRequestBody)): + check_push(rules, {}, io.BytesIO(body)) + assert time.perf_counter() - started < 0.25 + + +class TestHeaders: + def test_second_content_encoding_header_is_not_sent_upstream_unchecked(self): + # The check reads the first Content-Encoding it finds, and the relay's CaseInsensitiveDict keeps the last, so + # a mapping that holds both spellings has the body read as plain and sent upstream marked gzip. Sentry's + # caller passes dict(request.headers), which can't hold both. + body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")) + with running_upstream() as upstream: + recorder = Recorder( + repository=make_repository(provider_name="github_enterprise", web_base_url=upstream.url) + ) + request( + make_server(recorder, GitRelay(ca_bundle=upstream.ca_bundle)), + PUSH_GRANT, + method="POST", + route="git-receive-pack", + query="", + headers={"Content-Encoding": "identity", "content-encoding": "gzip"}, + body=io.BytesIO(body), + ) + assert [r.headers.get("Content-Encoding") for r in upstream.requests] in ([], ["identity"]) + + def test_git_protocol_git_does_not_send_on_a_push(self): + # git sends no Git-Protocol header on a push, or version=1, since push has no protocol v2. Any other value + # goes to the provider unchecked. + body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")) + with running_upstream() as upstream: + recorder = Recorder( + repository=make_repository(provider_name="github_enterprise", web_base_url=upstream.url) + ) + request( + make_server(recorder, GitRelay(ca_bundle=upstream.ca_bundle)), + PUSH_GRANT, + method="POST", + route="git-receive-pack", + query="", + headers={"Git-Protocol": "version=2:future-key=value"}, + body=io.BytesIO(body), + ) + assert all(r.headers.get("Git-Protocol") in (None, "version=0", "version=1") for r in upstream.requests) From c3ae614ce067b447629604a45e26deb17762630b Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 20:55:11 +0100 Subject: [PATCH 13/23] ref(git): Check version 1 and version 2 grants in separate functions Each version's rules read on their own, and the version 1 checks stay as they were before rules. Co-Authored-By: Claude Opus 5.5 --- src/scm/git/relay.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py index 6c3002b6..eb502588 100644 --- a/src/scm/git/relay.py +++ b/src/scm/git/relay.py @@ -93,12 +93,20 @@ def _authorize( if operation is None: raise GitRouteNotSupported(detail=f"{method} {route} is not a git fetch or push route.") if isinstance(grant, GrantV1): - if operation != grant.operation: - raise GitOperationNotGranted(detail=f"The git grant allows {grant.operation}, not {operation}.") - if repository_id not in grant.repositories: - raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") - return operation + _authorize_v1(grant, repository_id, operation) + else: + _authorize_v2(grant, repository_id, operation) + return operation + +def _authorize_v1(grant: GrantV1, repository_id: RepositoryId, operation: GitOperation) -> None: + if operation != grant.operation: + raise GitOperationNotGranted(detail=f"The git grant allows {grant.operation}, not {operation}.") + if repository_id not in grant.repositories: + raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") + + +def _authorize_v2(grant: GrantV2, repository_id: RepositoryId, operation: GitOperation) -> None: rules = grant.rules_for(repository_id, operation) if not rules: other: GitOperation = "push" if operation == "fetch" else "fetch" @@ -110,7 +118,6 @@ def _authorize( raise GitOperationNotGranted( detail="The git endpoint cannot keep a fetch to a rule's branches yet, so it refuses the rule." ) - return operation class ResponseBody(Iterator[bytes], Protocol): From a3fbc3ce946ca0272c6eb46c4555a0925f8dda51 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 22:03:22 +0100 Subject: [PATCH 14/23] fix(git): Refuse push refs, capabilities and headers git wouldn't send A rule's pattern ran on a branch name of up to 64 KiB, so a backtracking pattern could pin a worker. Capability values, refs outside refs/heads/ and push headers went unchecked, so control bytes reached logs and values git never sends reached the provider. A push is refused unless every ref is a name git takes, with branch names at most 255 bytes, its capabilities are printable ASCII, and its Content-Encoding, Content-Type, Accept and Git-Protocol values, under any spelling, are ones git sends. An unknown capability's name is cut short in the reason each refused ref repeats. Co-Authored-By: Claude Opus 5.5 --- bin/git-server | 2 + src/scm/git/push.py | 81 +++++++++++++++++-------- src/scm/git/types.py | 13 ++-- tests/unit/test_git_push.py | 18 ++++-- tests/unit/test_git_push_adversarial.py | 7 ++- 5 files changed, 87 insertions(+), 34 deletions(-) diff --git a/bin/git-server b/bin/git-server index 261f647c..5bef719a 100755 --- a/bin/git-server +++ b/bin/git-server @@ -640,6 +640,8 @@ class Checks: headers={ GRANT_HEADER: grant_for(self.args.private_key, rule), "Content-Type": "application/x-git-receive-pack-request", + # As git sends it. requests would otherwise send its own, which GitServer refuses on a push. + "Accept": "application/x-git-receive-pack-result", }, timeout=60, ) diff --git a/src/scm/git/push.py b/src/scm/git/push.py index 18e29f92..508df25d 100644 --- a/src/scm/git/push.py +++ b/src/scm/git/push.py @@ -12,9 +12,9 @@ from collections.abc import Mapping, Sequence from typing import NamedTuple -from scm.errors import GitMalformedRequestBody, GitOperationNotGranted, SCMCodedError +from scm.errors import GitMalformedRequestBody, GitOperationNotGranted, RpcMalformedRequestHeaders, SCMCodedError from scm.git.relay import DEFAULT_CHUNK_SIZE, _read -from scm.git.types import GrantV2PushRule, Readable, _is_branch_name +from scm.git.types import GrantV2PushRule, Readable, _follows_ref_format, _is_branch_name MAX_UPDATES_LENGTH = 64 * 1024 """The most bytes a push may send before its pack. An update takes under 100 bytes plus its ref's name, and a push @@ -28,6 +28,19 @@ _CAPABILITIES_WITH_VALUES = frozenset({"agent", "object-format", "session-id"}) # The object formats git names in object-format=, and the length of an object id in each, in hex. _OBJECT_ID_LENGTHS = {"sha1": 40, "sha256": 64} +# What git puts in a capability list: printable ASCII, the capabilities split by spaces. +_CAPABILITY_LIST = re.compile(rb"[\x20-\x7e]*") +# The headers the relay passes on that git sends with a push, each with the only values git sends. Any other value +# refuses the push rather than reach the provider. Content-Encoding is checked apart, to say why. +_HEADER_VALUES = { + "accept": frozenset({"application/x-git-receive-pack-result"}), + "content-type": frozenset({"application/x-git-receive-pack-request"}), + # A push has no protocol version 2, so git asks for version 1 at most. + "git-protocol": frozenset({"version=0", "version=1"}), +} +# The longest branch name a push may update, in bytes. A rule's pattern runs on the name the client chose, and one +# that backtracks takes time that grows with the name's length. +_MAX_BRANCH_LENGTH = 255 _FLUSH = b"0000" # A pkt-line's length, in 4 hex digits, counts the 4 digits too. @@ -37,6 +50,7 @@ # The most of a ref's name a refusal's detail shows. The pushing client chooses the name, and the detail reaches # error reports and logs. _SHOWN_REF_LENGTH = 200 +_SHOWN_CAPABILITY_LENGTH = 40 class PushRefused(GitOperationNotGranted): @@ -92,7 +106,7 @@ def check_push(rules: Sequence[GrantV2PushRule], headers: Mapping[str, str], bod detail=f"The git grant does not allow this push: {_shown(ref)} ({reason}).", report=refusal_report(request, refused), ) - except (GitOperationNotGranted, GitMalformedRequestBody): + except (GitOperationNotGranted, GitMalformedRequestBody, RpcMalformedRequestHeaders): _drain(body) raise return ReplayedBody(request.head, body) @@ -102,13 +116,11 @@ def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest """Read a push's ref updates from ``body``, leaving the pack unread. Raises GitMalformedRequestBody for a body that does not start with a list of ref updates, GitOperationNotGranted - for a compressed body, PushRefused for a signed push, whose updates come inside a certificate, and - ClientClosedRequest if reading the body fails. + for a compressed body, RpcMalformedRequestHeaders for any other header value git doesn't send with a push, + PushRefused for a signed push, whose updates come inside a certificate, and ClientClosedRequest if reading the + body fails. """ - encoding = next((value for name, value in headers.items() if name.lower() == "content-encoding"), "identity") - if encoding.strip().lower() not in ("", "identity"): - # git compresses fetches but never pushes. - raise GitOperationNotGranted(detail="The git endpoint does not read a compressed push.") + _check_headers(headers) reader = _PktReader(body) shallow: list[bytes] = [] updates: list[RefUpdate] = [] @@ -273,7 +285,20 @@ def _supported(capability: str) -> bool: def _capability_name(capability: str) -> str: if capability == "push-options": return "push options" - return f"the {capability.partition('=')[0]} capability" + # The client chose the name, and the reason goes in every refused ref's line of the report. + return f"the {_cut(capability.partition('=')[0], _SHOWN_CAPABILITY_LENGTH)} capability" + + +def _check_headers(headers: Mapping[str, str]) -> None: + # Every value counts, since a mapping can hold one header under two spellings and the relay passes on one of them. + for name, value in headers.items(): + lowered = name.lower() + if lowered == "content-encoding" and value.strip().lower() not in ("", "identity"): + # git compresses fetches but never pushes. + raise GitOperationNotGranted(detail="The git endpoint does not read a compressed push.") + allowed = _HEADER_VALUES.get(lowered) + if allowed is not None and value.strip() not in allowed: + raise RpcMalformedRequestHeaders(detail=f"The push's {lowered} header holds a value git doesn't send.") def _error_report(capabilities: frozenset[str], message: str) -> bytes | None: @@ -294,10 +319,9 @@ def _on_side_band(capabilities: frozenset[str], band: int, data: bytes) -> bytes def _capabilities(requested: bytes) -> frozenset[str]: - try: - return frozenset(requested.decode("ascii").split()) - except UnicodeDecodeError: - raise GitMalformedRequestBody(detail="The push's capabilities are not ASCII.") from None + if not _CAPABILITY_LIST.fullmatch(requested): + raise GitMalformedRequestBody(detail="The push's capabilities hold characters git doesn't send.") + return frozenset(requested.decode("ascii").split(" ")) - {""} def _ref_update(line: bytes) -> RefUpdate: @@ -305,21 +329,30 @@ def _ref_update(line: bytes) -> RefUpdate: new_id, _, ref = rest.partition(b" ") if not (_OBJECT_ID.fullmatch(old_id) and _OBJECT_ID.fullmatch(new_id) and len(old_id) == len(new_id) and ref): raise GitMalformedRequestBody(detail="The push's ref updates are malformed.") - if ref.startswith(b"refs/heads/"): - # A rule checks the branch the push names, and a name git wouldn't take might not be the branch the - # provider updates. - try: - branch = ref.removeprefix(b"refs/heads/").decode() - except UnicodeDecodeError: - branch = "" - if not _is_branch_name(branch): + # A rule checks the ref the push names, and a name git wouldn't take might not be the ref the provider updates. + # It also goes into the refusal's detail, which reaches logs. + try: + name = ref.decode() + except UnicodeDecodeError: + name = "" + if not (name.startswith("refs/") and _follows_ref_format(name)): + raise GitMalformedRequestBody(detail="The push updates a ref whose name git wouldn't take.") + if name.startswith("refs/heads/"): + if not _is_branch_name(name.removeprefix("refs/heads/")): raise GitMalformedRequestBody(detail="The push updates a branch whose name git wouldn't take.") + if len(ref.removeprefix(b"refs/heads/")) > _MAX_BRANCH_LENGTH: + raise GitMalformedRequestBody( + detail=f"The push updates a branch whose name is longer than {_MAX_BRANCH_LENGTH} bytes." + ) return RefUpdate(old_id=old_id, new_id=new_id, ref=ref) def _shown(ref: bytes) -> str: - name = ref.decode(errors="replace") - return name if len(name) <= _SHOWN_REF_LENGTH else f"{name[:_SHOWN_REF_LENGTH]}..." + return _cut(ref.decode(errors="replace"), _SHOWN_REF_LENGTH) + + +def _cut(text: str, length: int) -> str: + return text if len(text) <= length else f"{text[:length]}..." def _pkt(payload: bytes) -> bytes: diff --git a/src/scm/git/types.py b/src/scm/git/types.py index 947c2d11..f9d9dc1b 100644 --- a/src/scm/git/types.py +++ b/src/scm/git/types.py @@ -210,12 +210,17 @@ def _check_branches(branches: list[BranchName]) -> None: def _is_branch_name(name: str) -> bool: - # git check-ref-format's rules for refs/heads/, with what git branch refuses on top (a leading "-" and - # "HEAD"). A name starting with refs/ is a valid branch, but it's far more likely a full ref name by mistake. + # What git branch refuses on top of git check-ref-format's rules: a leading "-" and "HEAD". A name starting with + # refs/ is a valid branch, but it's far more likely a full ref name by mistake. + return _follows_ref_format(name) and name != "HEAD" and not name.startswith(("-", "refs/")) + + +def _follows_ref_format(name: str) -> bool: + """Return whether git check-ref-format takes ``name``, a ref name or the part of one below refs/heads/.""" return not ( not name - or name in ("@", "HEAD") - or name.startswith(("-", "/", "refs/")) + or name == "@" + or name.startswith("/") or name.endswith(("/", ".")) or _NOT_IN_A_REF.search(name) or any(part.startswith(".") or part.endswith(".lock") for part in name.split("/")) diff --git a/tests/unit/test_git_push.py b/tests/unit/test_git_push.py index ed75c403..95ce985a 100644 --- a/tests/unit/test_git_push.py +++ b/tests/unit/test_git_push.py @@ -222,8 +222,6 @@ def test_probe(self): ("refs/tags/seer/fix", "the git grant allows pushing branches only"), ("refs/pull/1/head", "the git grant allows pushing branches only"), ("refs/notes/commits", "the git grant allows pushing branches only"), - ("HEAD", "the git grant allows pushing branches only"), - ("seer/fix", "the git grant allows pushing branches only"), ], ) def test_refs_the_rule_does_not_list(self, ref, reason): @@ -475,11 +473,23 @@ def test_body_that_ends_early(self): self.check(body[:20]) def test_long_ref_is_cut_short_in_the_detail(self): - ref = "refs/heads/" + "x" * 1000 + ref = "refs/tags/" + "x" * 1000 with pytest.raises(PushRefused) as exc_info: self.check(make_push_body((OLD_ID, NEW_ID, ref), capabilities="report-status")) assert exc_info.value.detail == ( - f"The git grant does not allow this push: {ref[:200]}... (the git grant does not cover this branch)." + f"The git grant does not allow this push: {ref[:200]}... (the git grant allows pushing branches only)." ) # The report names the whole ref, since git matches it to the ref it pushed. assert ref.encode() in (exc_info.value.report or b"") + + @pytest.mark.parametrize("ref", ["HEAD", "seer/fix", "refs/heads/" + "x" * 256], ids=["head", "bare", "too-long"]) + def test_ref_git_would_not_send(self, ref): + # git sends refs under refs/, and a branch name past the cap would make a rule's pattern slow. + with pytest.raises(GitMalformedRequestBody): + self.check(make_push_body((OLD_ID, NEW_ID, ref))) + + def test_longest_branch_name(self): + ref = "refs/heads/seer/" + "x" * 250 + rules = [GrantV2PushRule(repository=5, branch_patterns=["seer/x+"])] + relayed, _ = self.check(make_push_body((OLD_ID, NEW_ID, ref)), rules=rules) + assert ref.encode() in relayed diff --git a/tests/unit/test_git_push_adversarial.py b/tests/unit/test_git_push_adversarial.py index 6d03aae9..8ffb10ca 100644 --- a/tests/unit/test_git_push_adversarial.py +++ b/tests/unit/test_git_push_adversarial.py @@ -42,9 +42,12 @@ def test_value_git_would_not_send_is_refused(self, capabilities): def long_capability_push() -> bytes: """A push whose one unsupported capability takes half the update budget, followed by as many updates as fit.""" - lines = [first_update(b"report-status " + b"x" * (MAX_UPDATES_LENGTH // 2), ref=b"r0")] + lines = [first_update(b"report-status " + b"x" * (MAX_UPDATES_LENGTH // 2), ref=b"refs/heads/r0")] index = 1 - while sum(map(len, lines)) + len(line := pkt(f"{OLD_ID} {NEW_ID} r{index}".encode())) + 4 <= MAX_UPDATES_LENGTH: + while ( + sum(map(len, lines)) + len(line := pkt(f"{OLD_ID} {NEW_ID} refs/heads/r{index}".encode())) + 4 + <= MAX_UPDATES_LENGTH + ): lines.append(line) index += 1 return b"".join(lines) + b"0000" From e82d8ccaf19dabe25427ed1f4c7d5238f6b27e5d Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 22:43:12 +0100 Subject: [PATCH 15/23] fix(git): Take only what git sends in a push, in every request and line A push from a shallow clone of many branches was refused, because its shallow commits counted against the ref updates' budget, so they now have a budget of their own. Header values were compared after Python's strip, which removes more than HTTP does, and the push's first request wasn't checked at all. Each request of a push now passes on only the header values git sends with it, compared as sent. Refs must be printable, which also keeps invisible and right-to-left characters out of branch names, and routes in error details are escaped and cut short. Capabilities, Git-Protocol values, pkt-line lengths and line endings git never uses in a push are refused, and draining a refused push stops at 256 MiB. Co-Authored-By: Claude Opus 5.5 --- src/scm/git/push.py | 116 ++++++++----- src/scm/git/relay.py | 14 +- src/scm/git/server.py | 5 +- tests/unit/test_git_push.py | 87 +++++++--- tests/unit/test_git_push_adversarial_2.py | 190 ++++++++++++++++++++++ tests/unit/test_git_server.py | 2 +- 6 files changed, 349 insertions(+), 65 deletions(-) create mode 100644 tests/unit/test_git_push_adversarial_2.py diff --git a/src/scm/git/push.py b/src/scm/git/push.py index 508df25d..f9817530 100644 --- a/src/scm/git/push.py +++ b/src/scm/git/push.py @@ -13,44 +13,60 @@ from typing import NamedTuple from scm.errors import GitMalformedRequestBody, GitOperationNotGranted, RpcMalformedRequestHeaders, SCMCodedError -from scm.git.relay import DEFAULT_CHUNK_SIZE, _read +from scm.git.relay import DEFAULT_CHUNK_SIZE, _printable, _read from scm.git.types import GrantV2PushRule, Readable, _follows_ref_format, _is_branch_name MAX_UPDATES_LENGTH = 64 * 1024 -"""The most bytes a push may send before its pack. An update takes under 100 bytes plus its ref's name, and a push -from a shallow clone also lists the clone's shallow commits, about 50 bytes each.""" +"""The most bytes a push's ref updates may take. An update takes under 100 bytes plus its ref's name.""" + +MAX_SHALLOW_LENGTH = 4 * 1024 * 1024 +"""The most bytes a push may take to list its shallow commits, which git sends before the updates of every push from +a shallow clone: about 79,000 SHA-1 commits at 53 bytes each. A clone of many branches at a small depth has many.""" # The capabilities git asks for in a push, apart from push options, which ask the provider to act on the push, such # as GitLab opening a merge request or skipping CI. Any other is refused, since it could change what a push does. -_CAPABILITIES = frozenset( - {"atomic", "ofs-delta", "quiet", "report-status", "report-status-v2", "side-band", "side-band-64k"} -) +_CAPABILITIES = frozenset({"atomic", "quiet", "report-status", "report-status-v2", "side-band-64k"}) _CAPABILITIES_WITH_VALUES = frozenset({"agent", "object-format", "session-id"}) # The object formats git names in object-format=, and the length of an object id in each, in hex. _OBJECT_ID_LENGTHS = {"sha1": 40, "sha256": 64} # What git puts in a capability list: printable ASCII, the capabilities split by spaces. _CAPABILITY_LIST = re.compile(rb"[\x20-\x7e]*") -# The headers the relay passes on that git sends with a push, each with the only values git sends. Any other value -# refuses the push rather than reach the provider. Content-Encoding is checked apart, to say why. +# The headers the relay passes on, with the only values git sends with each request of a push. git asks for no +# protocol version on a push, or for version 1, since a push has no version 2, and never compresses one. _HEADER_VALUES = { - "accept": frozenset({"application/x-git-receive-pack-result"}), - "content-type": frozenset({"application/x-git-receive-pack-request"}), - # A push has no protocol version 2, so git asks for version 1 at most. - "git-protocol": frozenset({"version=0", "version=1"}), + "GET": { + "accept": frozenset({"*/*"}), + "content-encoding": frozenset(), + "content-type": frozenset(), + "git-protocol": frozenset({"version=1"}), + }, + "POST": { + "accept": frozenset({"application/x-git-receive-pack-result"}), + "content-encoding": frozenset(), + "content-type": frozenset({"application/x-git-receive-pack-request"}), + "git-protocol": frozenset({"version=1"}), + }, } +# The other headers the relay passes on, whose values vary with git's version and build. They may hold printable +# ASCII without surrounding space, which is what the relay sends. +_FREE_HEADERS = frozenset({"accept-encoding", "user-agent"}) +_FREE_HEADER_VALUE = re.compile(r"[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?") # The longest branch name a push may update, in bytes. A rule's pattern runs on the name the client chose, and one # that backtracks takes time that grows with the name's length. _MAX_BRANCH_LENGTH = 255 _FLUSH = b"0000" -# A pkt-line's length, in 4 hex digits, counts the 4 digits too. -_PKT_LENGTH = re.compile(rb"[0-9a-fA-F]{4}") +# A pkt-line's length, in 4 lowercase hex digits as git writes them, counts the 4 digits too. +_PKT_LENGTH = re.compile(rb"[0-9a-f]{4}") _MAX_PKT_LENGTH = 65520 _OBJECT_ID = re.compile(rb"[0-9a-f]{40}|[0-9a-f]{64}") # The most of a ref's name a refusal's detail shows. The pushing client chooses the name, and the detail reaches # error reports and logs. _SHOWN_REF_LENGTH = 200 _SHOWN_CAPABILITY_LENGTH = 40 +# The most of a refused push's body read to its end. A provider takes no push this large, so a client that sends more +# only holds a worker, and git then sees the connection close instead of the reason. +_MAX_DRAIN_LENGTH = 256 * 1024 * 1024 class PushRefused(GitOperationNotGranted): @@ -120,8 +136,8 @@ def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest PushRefused for a signed push, whose updates come inside a certificate, and ClientClosedRequest if reading the body fails. """ - _check_headers(headers) - reader = _PktReader(body) + check_push_headers("POST", headers) + reader = _PktReader(body, limit=MAX_SHALLOW_LENGTH) shallow: list[bytes] = [] updates: list[RefUpdate] = [] capabilities: frozenset[str] = frozenset() @@ -129,13 +145,19 @@ def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest # capabilities on any update and shallow commits between updates, and anything this lets through it relays. while (line := reader.read_line()) is not None: if not updates and line.startswith(b"shallow "): - # A push from a shallow clone lists its shallow commits first, and they pass on unchanged. - shallow.append(line.removeprefix(b"shallow ")) + # A push from a shallow clone lists its shallow commits first, each on a line of its own, and they pass + # on unchanged. + if not line.endswith(b"\n"): + raise GitMalformedRequestBody(detail="The push lists a shallow commit that is not an object id.") + shallow.append(line.removeprefix(b"shallow ").removesuffix(b"\n")) continue if not updates and line.startswith(b"push-cert\0"): message = "The git endpoint does not relay signed pushes." requested = _capabilities(line.partition(b"\0")[2]) raise PushRefused(detail=message, report=_error_report(requested, message)) + if not updates: + # The updates' own budget starts here, apart from the shallow commits'. + reader.limit_to(reader.line_start + MAX_UPDATES_LENGTH) line, separator, requested_bytes = line.partition(b"\0") if separator and updates: raise GitMalformedRequestBody(detail="Only the push's first ref update may carry capabilities.") @@ -238,12 +260,16 @@ def read(self, size: int = -1, /) -> bytes: class _PktReader: - def __init__(self, body: Readable) -> None: + def __init__(self, body: Readable, *, limit: int) -> None: self.body = body + self.limit = limit self.read_bytes = bytearray() + self.line_start = 0 + """Where the line read last starts, counted from the start of the body.""" def read_line(self) -> bytes | None: - """Return the next pkt-line's payload without its trailing newline, or None for a flush.""" + """Return the next pkt-line's payload as sent, or None for a flush.""" + self.line_start = len(self.read_bytes) header = self._read(4) if not _PKT_LENGTH.fullmatch(header): raise GitMalformedRequestBody(detail="The push's ref updates are not pkt-lines.") @@ -253,14 +279,22 @@ def read_line(self) -> bytes | None: # 1 to 3 are protocol v2's special packets, which a push never sends. if length < 4 or length > _MAX_PKT_LENGTH: raise GitMalformedRequestBody(detail="The push's ref updates are not pkt-lines.") - return self._read(length - 4).removesuffix(b"\n") + return self._read(length - 4) + + def limit_to(self, limit: int) -> None: + """Lower how far into the body the reader may read, refusing a body already read past it.""" + self.limit = min(self.limit, limit) + self._check(0) def read_so_far(self) -> bytes: return bytes(self.read_bytes) + def _check(self, size: int) -> None: + if len(self.read_bytes) + size > self.limit: + raise GitMalformedRequestBody(detail="The push's ref updates or shallow commits are too long.") + def _read(self, size: int) -> bytes: - if len(self.read_bytes) + size > MAX_UPDATES_LENGTH: - raise GitMalformedRequestBody(detail=f"The push's ref updates are longer than {MAX_UPDATES_LENGTH} bytes.") + self._check(size) data = bytearray() while len(data) < size: chunk = _read(self.body, size - len(data)) @@ -272,9 +306,10 @@ def _read(self, size: int) -> bytes: def _drain(body: Readable) -> None: + drained = 0 with contextlib.suppress(OSError, SCMCodedError): - while body.read(DEFAULT_CHUNK_SIZE): - pass + while drained < _MAX_DRAIN_LENGTH and (chunk := body.read(DEFAULT_CHUNK_SIZE)): + drained += len(chunk) def _supported(capability: str) -> bool: @@ -286,18 +321,22 @@ def _capability_name(capability: str) -> str: if capability == "push-options": return "push options" # The client chose the name, and the reason goes in every refused ref's line of the report. - return f"the {_cut(capability.partition('=')[0], _SHOWN_CAPABILITY_LENGTH)} capability" + return f"the {_printable(capability.partition('=')[0], _SHOWN_CAPABILITY_LENGTH)} capability" -def _check_headers(headers: Mapping[str, str]) -> None: - # Every value counts, since a mapping can hold one header under two spellings and the relay passes on one of them. +def check_push_headers(method: str, headers: Mapping[str, str]) -> None: + """Refuse a push's request unless each header the relay passes on holds a value git sends with it. + + Every value counts, since a mapping can hold one header under two spellings and the relay passes on one of them. + Raises RpcMalformedRequestHeaders. + """ + allowed_values = _HEADER_VALUES[method] for name, value in headers.items(): lowered = name.lower() - if lowered == "content-encoding" and value.strip().lower() not in ("", "identity"): - # git compresses fetches but never pushes. - raise GitOperationNotGranted(detail="The git endpoint does not read a compressed push.") - allowed = _HEADER_VALUES.get(lowered) - if allowed is not None and value.strip() not in allowed: + allowed = allowed_values.get(lowered) + if (allowed is not None and value not in allowed) or ( + lowered in _FREE_HEADERS and not _FREE_HEADER_VALUE.fullmatch(value) + ): raise RpcMalformedRequestHeaders(detail=f"The push's {lowered} header holds a value git doesn't send.") @@ -330,12 +369,13 @@ def _ref_update(line: bytes) -> RefUpdate: if not (_OBJECT_ID.fullmatch(old_id) and _OBJECT_ID.fullmatch(new_id) and len(old_id) == len(new_id) and ref): raise GitMalformedRequestBody(detail="The push's ref updates are malformed.") # A rule checks the ref the push names, and a name git wouldn't take might not be the ref the provider updates. - # It also goes into the refusal's detail, which reaches logs. + # It also goes into the refusal's detail, which reaches logs, so it must be printable too, which keeps out + # invisible and right-to-left characters that make one branch name look like another. try: name = ref.decode() except UnicodeDecodeError: name = "" - if not (name.startswith("refs/") and _follows_ref_format(name)): + if not (name.startswith("refs/") and name.isprintable() and _follows_ref_format(name)): raise GitMalformedRequestBody(detail="The push updates a ref whose name git wouldn't take.") if name.startswith("refs/heads/"): if not _is_branch_name(name.removeprefix("refs/heads/")): @@ -348,11 +388,7 @@ def _ref_update(line: bytes) -> RefUpdate: def _shown(ref: bytes) -> str: - return _cut(ref.decode(errors="replace"), _SHOWN_REF_LENGTH) - - -def _cut(text: str, length: int) -> str: - return text if len(text) <= length else f"{text[:length]}..." + return _printable(ref.decode(errors="replace"), _SHOWN_REF_LENGTH) def _pkt(payload: bytes) -> bytes: diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py index eb502588..7b33fb8e 100644 --- a/src/scm/git/relay.py +++ b/src/scm/git/relay.py @@ -91,7 +91,9 @@ def _authorize( """``authorize_request`` for GitServer, which checks a push's ref updates against the grant's push rules itself.""" operation = _ROUTES.get((method, route, query)) if operation is None: - raise GitRouteNotSupported(detail=f"{method} {route} is not a git fetch or push route.") + raise GitRouteNotSupported( + detail=f"{_printable(method, 20)} {_printable(route, 200)} is not a git fetch or push route." + ) if isinstance(grant, GrantV1): _authorize_v1(grant, repository_id, operation) else: @@ -120,6 +122,12 @@ def _authorize_v2(grant: GrantV2, repository_id: RepositoryId, operation: GitOpe ) +def _printable(text: str, length: int) -> str: + """Return ``text`` for an error's detail, which reaches logs: escaped where it isn't printable, and cut short.""" + escaped = "".join(character if character.isprintable() else ascii(character)[1:-1] for character in text) + return escaped if len(escaped) <= length else f"{escaped[:length]}..." + + class ResponseBody(Iterator[bytes], Protocol): """The provider's response body. Closing it before it ends drops the upstream connection.""" @@ -205,7 +213,9 @@ def forward( raise GitRemoteNotHttps() # The token never goes to anything but a git route, whatever the caller checked. if (method, route, query) not in _ROUTES: - raise GitRouteNotSupported(detail=f"{method} {route} is not a git fetch or push route.") + raise GitRouteNotSupported( + detail=f"{_printable(method, 20)} {_printable(route, 200)} is not a git fetch or push route." + ) if not f"{remote['basic_auth_username']}:{token.reveal()}".isascii(): # requests would fail to encode the credentials with a UnicodeEncodeError that holds them. No provider # issues such a token, so the caller has a bug; the message leaves the token out. diff --git a/src/scm/git/server.py b/src/scm/git/server.py index 28a807c8..2d59a6ec 100644 --- a/src/scm/git/server.py +++ b/src/scm/git/server.py @@ -9,7 +9,7 @@ SCMCodedError, ) from scm.git.grant import require_public_keys, verify_grant -from scm.git.push import PushRefused, check_push +from scm.git.push import PushRefused, check_push, check_push_headers from scm.git.relay import GitRelay, ResponseBody, _authorize from scm.git.types import GRANT_HEADER, GetToken, GrantV2, GrantV2PushRule, Readable from scm.helpers import initialize_provider @@ -156,6 +156,9 @@ def handle( rule for rule in grant.rules_for(repository_id, "push") if isinstance(rule, GrantV2PushRule) ] relayed_body = check_push(push_rules, headers, counted_body) + elif isinstance(grant, GrantV2) and operation == "push": + # The push's first request, which lists the refs, goes to the provider with the same headers. + check_push_headers(method, headers) provider = initialize_provider( grant.organization_id, repository_id, diff --git a/tests/unit/test_git_push.py b/tests/unit/test_git_push.py index 95ce985a..340fb74d 100644 --- a/tests/unit/test_git_push.py +++ b/tests/unit/test_git_push.py @@ -2,7 +2,7 @@ import pytest -from scm.errors import ClientClosedRequest, GitMalformedRequestBody, GitOperationNotGranted +from scm.errors import ClientClosedRequest, GitMalformedRequestBody, GitOperationNotGranted, RpcMalformedRequestHeaders from scm.git.push import ( MAX_UPDATES_LENGTH, PushRefused, @@ -10,6 +10,7 @@ RefUpdate, ReplayedBody, check_push, + check_push_headers, read_push_request, refusal_report, refused_updates, @@ -129,27 +130,68 @@ def test_short_reads(self): request = read_push_request({}, Trickle(body)) assert request.updates[0].ref == b"refs/heads/seer/fix" - def test_trailing_newlines(self): - line = f"{OLD_ID} {NEW_ID} refs/heads/seer/fix".encode() - request, _ = read(pkt(line + b"\0report-status\n") + pkt(line.replace(b"fix", b"other") + b"\n") + b"0000") - assert [update.ref for update in request.updates] == [b"refs/heads/seer/fix", b"refs/heads/seer/other"] - assert request.capabilities == {"report-status"} + @pytest.mark.parametrize( + "lines", + [ + [b"\0report-status\n", b""], + [b"\0report-status", b"\n"], + ], + ids=["first", "later"], + ) + def test_update_ending_in_a_newline(self, lines): + # git ends no update with a newline, though receive-pack would drop one. + first = f"{OLD_ID} {NEW_ID} refs/heads/seer/fix".encode() + later = f"{OLD_ID} {NEW_ID} refs/heads/seer/other".encode() + with pytest.raises(GitMalformedRequestBody): + read(pkt(first + lines[0]) + pkt(later + lines[1]) + b"0000") + + def test_shallow_commit_without_its_newline(self): + body = pkt(f"shallow {'3' * 40}".encode()) + make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")) + with pytest.raises(GitMalformedRequestBody, match="shallow commit"): + read(body) def test_uppercase_pkt_length(self): - line = f"{OLD_ID} {NEW_ID} refs/heads/{'x' * 200}".encode() - request, _ = read(b"%04X" % (len(line) + 4) + line + b"0000") - assert request.updates[0].ref == f"refs/heads/{'x' * 200}".encode() - - @pytest.mark.parametrize("encoding", ["identity", "", " Identity "]) - def test_identity_encoding(self, encoding): - request, _ = read(make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")), {"Content-Encoding": encoding}) - assert len(request.updates) == 1 - - @pytest.mark.parametrize("encoding", ["gzip", "deflate"]) - def test_compressed(self, encoding): - with pytest.raises(GitOperationNotGranted, match="compressed push"): + # git writes the length in lowercase hex. 0x00ea is a length with a letter in it. + line = f"{OLD_ID} {NEW_ID} refs/heads/{'x' * (0xEA - 4 - 93)}".encode() + assert len(line) + 4 == 0xEA + with pytest.raises(GitMalformedRequestBody, match="not pkt-lines"): + read(b"%04X" % (len(line) + 4) + line + b"0000") + + @pytest.mark.parametrize("encoding", ["identity", "", "gzip", "deflate"]) + def test_content_encoding(self, encoding): + # git sends no Content-Encoding with a push, and compresses only fetches. + with pytest.raises(RpcMalformedRequestHeaders, match="content-encoding"): read(make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")), {"content-encoding": encoding}) + @pytest.mark.parametrize( + "method,headers", + [ + ("POST", {"Accept": "application/x-git-receive-pack-result", "Git-Protocol": "version=1"}), + ("POST", {"Content-Type": "application/x-git-receive-pack-request"}), + ("POST", {"User-Agent": "git/2.54.0", "Accept-Encoding": "deflate, gzip"}), + ("GET", {"Accept": "*/*", "Git-Protocol": "version=1", "Pragma": "no-cache"}), + ], + ) + def test_headers_git_sends(self, method, headers): + check_push_headers(method, headers) + + @pytest.mark.parametrize( + "method,headers", + [ + ("POST", {"Git-Protocol": "version=0"}), + ("POST", {"Git-Protocol": "version=2"}), + ("POST", {"Accept": "*/*"}), + ("GET", {"Accept": "application/x-git-receive-pack-result"}), + ("GET", {"Content-Type": "application/x-git-receive-pack-request"}), + ("POST", {"User-Agent": " git/2.54.0"}), + ("POST", {"Accept-Encoding": "gzip\x7f"}), + ("POST", {"User-Agent": ""}), + ], + ) + def test_headers_git_does_not_send(self, method, headers): + with pytest.raises(RpcMalformedRequestHeaders): + check_push_headers(method, headers) + def test_signed_push(self): body = pkt(b"push-cert\0report-status") + pkt(b"certificate version 0.1\n") + b"0000" with pytest.raises(GitOperationNotGranted, match="signed pushes"): @@ -188,7 +230,7 @@ def test_shallow_line_after_an_update(self): def test_too_long_before_the_pack(self): updates = [(OLD_ID, NEW_ID, f"refs/heads/branch-{i:05d}") for i in range(MAX_UPDATES_LENGTH // 90)] - with pytest.raises(GitMalformedRequestBody, match=f"longer than {MAX_UPDATES_LENGTH} bytes"): + with pytest.raises(GitMalformedRequestBody, match="too long"): read(make_push_body(*updates)) def test_connection_fails(self): @@ -289,7 +331,7 @@ def test_reason_of_the_rule_the_update_came_closest_to_passing(self): "capabilities", [ "report-status side-band-64k quiet object-format=sha1 agent=git/2.54.0", - "report-status-v2 side-band atomic ofs-delta session-id=abc", + "report-status-v2 side-band-64k atomic session-id=abc", "", ], ) @@ -304,6 +346,9 @@ def test_capabilities_git_sends(self, capabilities): [ ("future-capability", "the git endpoint does not relay the future-capability capability"), ("quiet=1", "the git endpoint does not relay the quiet capability"), + # git asks for neither in a push. + ("side-band", "the git endpoint does not relay the side-band capability"), + ("ofs-delta", "the git endpoint does not relay the ofs-delta capability"), ("agent", "the git endpoint does not relay the agent capability"), ], ) @@ -443,7 +488,7 @@ def test_no_body(self): ( make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")), {"Content-Encoding": "gzip"}, - GitOperationNotGranted, + RpcMalformedRequestHeaders, ), (pkt(b"push-cert\0report-status") + b"0000" + PACK, {}, PushRefused), ], diff --git a/tests/unit/test_git_push_adversarial_2.py b/tests/unit/test_git_push_adversarial_2.py new file mode 100644 index 00000000..43e42ba0 --- /dev/null +++ b/tests/unit/test_git_push_adversarial_2.py @@ -0,0 +1,190 @@ +"""A second round of pushes built to slip past the push check, against a3fbc3c. Each test fails while the check lets +its input through.""" + +import io + +import pytest + +from scm.errors import GitMalformedRequestBody, GitOperationNotGranted, RpcMalformedRequestHeaders +from scm.git.push import check_push +from scm.git.relay import DEFAULT_CHUNK_SIZE, GitRelay +from scm.git.types import GrantV2PushRule +from tests.unit.git_builders import NEW_ID, OLD_ID, make_push_body, make_repository, pkt +from tests.unit.git_upstream import running_upstream +from tests.unit.test_git_server import PUSH_GRANT, Recorder, make_server, request + +RULES = [GrantV2PushRule(repository=5, branches=["seer/fix"])] +ALLOWED_PUSH = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")) +GIT_PROTOCOL_VALUES = (None, "version=0", "version=1") + + +def send_push(upstream, headers: dict[str, str]) -> tuple[Recorder, int]: + recorder = Recorder(repository=make_repository(provider_name="github_enterprise", web_base_url=upstream.url)) + response = request( + make_server(recorder, GitRelay(ca_bundle=upstream.ca_bundle)), + PUSH_GRANT, + method="POST", + route="git-receive-pack", + query="", + headers=headers, + body=io.BytesIO(ALLOWED_PUSH), + ) + b"".join(response.content) + return recorder, response.status_code + + +class TestHeaderValuesAsRelayed: + """_check_headers compares ``value.strip()`` with git's values, and the relay sends ``value`` on as it came. + + str.strip() removes more than the spaces and tabs an HTTP server trims: U+00A0 and U+0085, which reach a WSGI + application as the header bytes 0xA0 and 0x85, and the controls 0x0B, 0x0C and 0x1C to 0x1F. So a value git never + sends passes the check and reaches the provider. + """ + + @pytest.mark.parametrize( + "name,value", + [ + ("Git-Protocol", "version=1\xa0"), + ("Git-Protocol", "version=1\x85"), + ("Git-Protocol", "version=1\x0c"), + ("Git-Protocol", "version=1\x1f"), + ("Accept", "application/x-git-receive-pack-result\xa0"), + ("Content-Type", "application/x-git-receive-pack-request\x85"), + ("Content-Encoding", "identity\xa0"), + ("Content-Encoding", "\x85"), + ], + ) + def test_value_padded_with_what_only_strip_removes_is_refused(self, name, value): + with pytest.raises((RpcMalformedRequestHeaders, GitOperationNotGranted)): + check_push(RULES, {name: value}, io.BytesIO(ALLOWED_PUSH)) + + @pytest.mark.parametrize("value", ["version=1\xa0", "version=1\x85", "version=1\x0c"]) + def test_padded_git_protocol_never_reaches_the_provider(self, value): + with running_upstream() as upstream: + send_push(upstream, {"Git-Protocol": value}) + assert all(r.headers.get("Git-Protocol") in GIT_PROTOCOL_VALUES for r in upstream.requests) + + @pytest.mark.parametrize( + "headers", + [{"Git-Protocol": "\xa0version=1"}, {"Accept": "\x85application/x-git-receive-pack-result"}], + ids=["git-protocol", "accept"], + ) + def test_value_padded_in_front_is_refused_as_the_clients_fault(self, headers): + # It passes the check, then requests refuses to send a header value that starts with whitespace. That + # surfaces as resource_bad_gateway, a 502 that says the provider failed and that a client retries, after the + # repository was looked up and a token fetched for a request the check should have refused. + with running_upstream() as upstream: + recorder, status = send_push(upstream, headers) + assert recorder.error_codes == ["rpc_malformed_request_headers"] + assert status == 400 + assert recorder.token_lookups == [] + + +class TestRefusalDetailUnicode: + @pytest.mark.parametrize( + "ref", + [ + "refs/tags/x\u2028ERROR-forged-log-line", + "refs/tags/x\u2029y", + "refs/tags/x\u0085ERROR-forged-log-line", + "refs/tags/x\u009b2Jy", + "refs/heads/main\u202egnp.exe", + "refs/heads/seer/fix\u200b", + ], + ids=["line-separator", "paragraph-separator", "next-line", "c1-csi", "right-to-left-override", "zero-width"], + ) + def test_detail_holds_only_printable_characters(self, ref): + # _NOT_IN_A_REF keeps ASCII control characters out of a ref, as git does, but git takes any byte from 0x80 + # up. So a ref that decodes to a C1 control, a Unicode line or paragraph separator (str.splitlines() splits + # on U+0085, U+2028 and U+2029) or a bidirectional override passes as a name and lands in the refusal's + # detail, which goes to logs and to a Sentry issue's title. + body = pkt(f"{OLD_ID} {NEW_ID} {ref}".encode() + b"\0report-status") + b"0000" + with pytest.raises((GitOperationNotGranted, GitMalformedRequestBody)) as exc_info: + check_push(RULES, {}, io.BytesIO(body)) + assert exc_info.value.detail.isprintable(), ascii(exc_info.value.detail) + + def test_route_in_the_detail_is_printable_and_cut_short(self): + # The route is the URL path below .git/, percent-decoded by the caller's web server, and + # GitRouteNotSupported quotes it whole. Sentry's URL pattern takes any character but a newline there, so an + # escape or a carriage return and as many bytes as a URL holds reach the same logs a ref's name is kept + # short and clean for. + recorder = Recorder() + route = "git-receive-pack\x1b[2J\rERROR forged log line " + "x" * 5000 + response = request(make_server(recorder), PUSH_GRANT, method="POST", route=route, query="") + assert response.status_code == 404 + detail = recorder.errors[0][0].detail # type: ignore[attr-defined] + assert detail.isprintable(), ascii(detail[:80]) + assert len(detail) <= 1000 + + +class TestShallowClone: + def test_push_from_a_clone_with_many_shallow_commits(self): + # git lists every shallow commit of the clone ahead of the updates of every push it makes, 53 bytes each + # with SHA-1. MAX_UPDATES_LENGTH counts them, so a push of one branch from a clone with more than about + # 1,230 shallow commits is refused as malformed. `git clone --depth=1 --no-single-branch` of a repository + # with 1,400 branches makes such a clone, and its push through GitServer fails with "RPC failed; HTTP 400". + shallow = tuple(f"{index:040x}" for index in range(1, 1401)) + body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix"), shallow=shallow) + assert check_push(RULES, {}, io.BytesIO(body)).read() == body + + +class TestRefAdvertisementOfAPush: + @pytest.mark.parametrize( + "name,value,sent_by_git", + [ + ("Git-Protocol", "version=2:future-key=value", GIT_PROTOCOL_VALUES), + ("Content-Encoding", "gzip", (None,)), + ], + ids=["git-protocol", "content-encoding"], + ) + def test_header_value_git_does_not_send_stays_behind(self, name, value, sent_by_git): + # check_push runs on the POST only. A push's first request, GET info/refs?service=git-receive-pack, is + # authorized by the same push rule and relayed with whatever the client put in the headers the POST checks. + # git asks for no protocol version there, or for version 1, and sends no Content-Encoding on a GET. + with running_upstream() as upstream: + recorder = Recorder( + repository=make_repository(provider_name="github_enterprise", web_base_url=upstream.url) + ) + response = request( + make_server(recorder, GitRelay(ca_bundle=upstream.ca_bundle)), + PUSH_GRANT, + query="service=git-receive-pack", + headers={name: value}, + ) + b"".join(response.content) + assert all(r.headers.get(name) in sent_by_git for r in upstream.requests) + + +class Endless: + """A push whose body never ends, as a chunked request body a caller streams can be.""" + + CHUNK = bytes(DEFAULT_CHUNK_SIZE) + + def __init__(self, head: bytes, give_up_after: int) -> None: + self.head = head + self.give_up_after = give_up_after + self.supplied = 0 + + def read(self, size: int = -1, /) -> bytes: + if self.head: + data, self.head = self.head[:size], self.head[size:] + return data + if self.supplied >= self.give_up_after: + return b"" + data = self.CHUNK[:size] + self.supplied += len(data) + return data + + +class TestDrain: + def test_refused_push_is_not_read_without_bound(self): + # A refused push is read to its end so that git reads the answer. Nothing bounds that read: a client that + # holds any push grant sends four bytes that are no pkt-line and then streams for as long as it likes, and + # the worker reads along with no provider on the other side to cut it off. 8 GiB is past any provider's + # push limit (GitHub's is 2 GiB), so nothing a provider would have taken needs reading that far. Sentry's + # caller buffers the body before it calls GitServer, so this bites only a caller that streams. + give_up_after = 8 * 1024**3 + body = Endless(b"zzzz", give_up_after) + with pytest.raises(GitMalformedRequestBody): + check_push(RULES, {}, body) + assert body.supplied < give_up_after, "the refused push was read for 8 GiB and would have been read on" diff --git a/tests/unit/test_git_server.py b/tests/unit/test_git_server.py index d09a6ccc..8f2ab711 100644 --- a/tests/unit/test_git_server.py +++ b/tests/unit/test_git_server.py @@ -886,7 +886,7 @@ def test_compressed(self): response = self.send( make_server(Recorder()), io.BytesIO(push_body("refs/heads/seer/fix")), headers={"Content-Encoding": "gzip"} ) - assert_refused(response, 403, "compressed push") + assert_refused(response, 400, "content-encoding header holds a value git doesn't send") def test_client_closes_while_sending_its_updates(self): recorder = Recorder() From 810c280604c3f18e9158837ddc0aa0a81f08bc2c Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 9 Nov 2026 22:49:36 +0100 Subject: [PATCH 16/23] ref(git): Simplify the push parser loop and route checks One branch per kind of line in the push parser, one helper for the route lookup the relay and the authorization share, and the push gate in GitServer written once. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- bin/git-server | 7 ++--- src/scm/git/push.py | 71 ++++++++++++++++++++----------------------- src/scm/git/relay.py | 18 ++++++----- src/scm/git/server.py | 25 ++++++--------- 4 files changed, 56 insertions(+), 65 deletions(-) diff --git a/bin/git-server b/bin/git-server index 5bef719a..7bee6b35 100755 --- a/bin/git-server +++ b/bin/git-server @@ -73,8 +73,8 @@ Checks: a new branch, sending the pack chunked as git does for a large push, and checks that the grant can neither push another branch nor delete its own, and that the provider refuses a forged create of the existing branch under - a create_only rule. It then deletes the branch with the provider token. This writes to the - repository, so point it at a test repository. + a create_only rule. It then deletes the branch with the provider token. + This writes to the repository, so point it at a test repository. git runs without the user's or the system's git config, so credential helpers and URL rewrites cannot hide a failure. @@ -634,7 +634,7 @@ class Checks: rule = GrantV2PushRule( repository=self.backend.repository_id, branch_patterns=[re.escape(branch)], create_only=True ) - response = requests.post( + return requests.post( f"{url}/git-receive-pack", data=forged_create_body(branch, tip), headers={ @@ -645,7 +645,6 @@ class Checks: }, timeout=60, ) - return response def delete_directly(self, branch: str, clone: Path) -> subprocess.CompletedProcess[str]: """Delete ``branch`` on the provider with the token Sentry would use, since a push grant cannot.""" diff --git a/src/scm/git/push.py b/src/scm/git/push.py index f9817530..63b1ae4d 100644 --- a/src/scm/git/push.py +++ b/src/scm/git/push.py @@ -131,8 +131,8 @@ def check_push(rules: Sequence[GrantV2PushRule], headers: Mapping[str, str], bod def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest: """Read a push's ref updates from ``body``, leaving the pack unread. - Raises GitMalformedRequestBody for a body that does not start with a list of ref updates, GitOperationNotGranted - for a compressed body, RpcMalformedRequestHeaders for any other header value git doesn't send with a push, + Raises GitMalformedRequestBody for a body that does not start with a list of ref updates, + RpcMalformedRequestHeaders for a header value git doesn't send with a push, such as any Content-Encoding, PushRefused for a signed push, whose updates come inside a certificate, and ClientClosedRequest if reading the body fails. """ @@ -144,26 +144,27 @@ def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest # Each line must be what git sends, in the order it sends them. receive-pack reads more than that, such as # capabilities on any update and shallow commits between updates, and anything this lets through it relays. while (line := reader.read_line()) is not None: - if not updates and line.startswith(b"shallow "): + if updates: + if b"\0" in line: + raise GitMalformedRequestBody(detail="Only the push's first ref update may carry capabilities.") + updates.append(_ref_update(line)) + elif line.startswith(b"shallow "): # A push from a shallow clone lists its shallow commits first, each on a line of its own, and they pass # on unchanged. if not line.endswith(b"\n"): raise GitMalformedRequestBody(detail="The push lists a shallow commit that is not an object id.") shallow.append(line.removeprefix(b"shallow ").removesuffix(b"\n")) - continue - if not updates and line.startswith(b"push-cert\0"): + elif line.startswith(b"push-cert\0"): message = "The git endpoint does not relay signed pushes." requested = _capabilities(line.partition(b"\0")[2]) - raise PushRefused(detail=message, report=_error_report(requested, message)) - if not updates: + # Band 3 carries a message git prints as "remote: " before it stops. + raise PushRefused(detail=message, report=_on_side_band(requested, 3, f"{message}\n".encode())) + else: # The updates' own budget starts here, apart from the shallow commits'. reader.limit_to(reader.line_start + MAX_UPDATES_LENGTH) - line, separator, requested_bytes = line.partition(b"\0") - if separator and updates: - raise GitMalformedRequestBody(detail="Only the push's first ref update may carry capabilities.") - if not updates: + update, _, requested_bytes = line.partition(b"\0") capabilities = _capabilities(requested_bytes) - updates.append(_ref_update(line)) + updates.append(_ref_update(update)) formats = [name.partition("=")[2] for name in capabilities if name.startswith("object-format=")] if len(formats) > 1: @@ -181,15 +182,31 @@ def read_push_request(headers: Mapping[str, str], body: Readable) -> PushRequest return PushRequest(updates=updates, capabilities=capabilities, head=reader.read_so_far()) +def check_push_headers(method: str, headers: Mapping[str, str]) -> None: + """Refuse a push's request unless each header the relay passes on holds a value git sends with it. + + Every value counts, since a mapping can hold one header under two spellings and the relay passes on one of them. + Raises RpcMalformedRequestHeaders. + """ + allowed_values = _HEADER_VALUES[method] + for name, value in headers.items(): + lowered = name.lower() + allowed = allowed_values.get(lowered) + if (allowed is not None and value not in allowed) or ( + lowered in _FREE_HEADERS and not _FREE_HEADER_VALUE.fullmatch(value) + ): + raise RpcMalformedRequestHeaders(detail=f"The push's {lowered} header holds a value git doesn't send.") + + def refused_updates(rules: Sequence[GrantV2PushRule], request: PushRequest) -> dict[bytes, str]: """Return why ``rules`` refuse each ref of ``request``, or nothing if every update has a rule that allows it. A push is relayed whole or not at all, so once one update is refused, every other ref gets a reason too. With no rules, every update is refused. """ - unsupported = sorted(name for name in request.capabilities if not _supported(name)) - if unsupported: - reason = f"the git endpoint does not relay {_capability_name(unsupported[0])}" + unsupported = min((name for name in request.capabilities if not _supported(name)), default=None) + if unsupported is not None: + reason = f"the git endpoint does not relay {_capability_name(unsupported)}" return {update.ref: reason for update in request.updates} refused: dict[bytes, str] = {} for update in request.updates: @@ -277,7 +294,7 @@ def read_line(self) -> bytes | None: if length == 0: return None # 1 to 3 are protocol v2's special packets, which a push never sends. - if length < 4 or length > _MAX_PKT_LENGTH: + if not 4 <= length <= _MAX_PKT_LENGTH: raise GitMalformedRequestBody(detail="The push's ref updates are not pkt-lines.") return self._read(length - 4) @@ -324,28 +341,6 @@ def _capability_name(capability: str) -> str: return f"the {_printable(capability.partition('=')[0], _SHOWN_CAPABILITY_LENGTH)} capability" -def check_push_headers(method: str, headers: Mapping[str, str]) -> None: - """Refuse a push's request unless each header the relay passes on holds a value git sends with it. - - Every value counts, since a mapping can hold one header under two spellings and the relay passes on one of them. - Raises RpcMalformedRequestHeaders. - """ - allowed_values = _HEADER_VALUES[method] - for name, value in headers.items(): - lowered = name.lower() - allowed = allowed_values.get(lowered) - if (allowed is not None and value not in allowed) or ( - lowered in _FREE_HEADERS and not _FREE_HEADER_VALUE.fullmatch(value) - ): - raise RpcMalformedRequestHeaders(detail=f"The push's {lowered} header holds a value git doesn't send.") - - -def _error_report(capabilities: frozenset[str], message: str) -> bytes | None: - """Return a response that shows git ``message`` before it gives up, or None if the client can read none.""" - # Band 3 carries a message git prints as "remote: " before it stops. - return _on_side_band(capabilities, 3, f"{message}\n".encode()) - - def _on_side_band(capabilities: frozenset[str], band: int, data: bytes) -> bytes | None: """Return ``data`` on side band ``band``, ending with a flush, or None if the client asked for no side band.""" if "side-band-64k" in capabilities: diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py index 7b33fb8e..17ba3737 100644 --- a/src/scm/git/relay.py +++ b/src/scm/git/relay.py @@ -89,15 +89,20 @@ def _authorize( grant: GrantV1 | GrantV2, repository_id: RepositoryId, method: str, route: str, query: str ) -> GitOperation: """``authorize_request`` for GitServer, which checks a push's ref updates against the grant's push rules itself.""" + operation = _route_operation(method, route, query) + if isinstance(grant, GrantV1): + _authorize_v1(grant, repository_id, operation) + else: + _authorize_v2(grant, repository_id, operation) + return operation + + +def _route_operation(method: str, route: str, query: str) -> GitOperation: operation = _ROUTES.get((method, route, query)) if operation is None: raise GitRouteNotSupported( detail=f"{_printable(method, 20)} {_printable(route, 200)} is not a git fetch or push route." ) - if isinstance(grant, GrantV1): - _authorize_v1(grant, repository_id, operation) - else: - _authorize_v2(grant, repository_id, operation) return operation @@ -212,10 +217,7 @@ def forward( # The token would cross the network in the clear, e.g. to a self-managed instance served over http. raise GitRemoteNotHttps() # The token never goes to anything but a git route, whatever the caller checked. - if (method, route, query) not in _ROUTES: - raise GitRouteNotSupported( - detail=f"{_printable(method, 20)} {_printable(route, 200)} is not a git fetch or push route." - ) + _route_operation(method, route, query) if not f"{remote['basic_auth_username']}:{token.reveal()}".isascii(): # requests would fail to encode the credentials with a UnicodeEncodeError that holds them. No provider # issues such a token, so the caller has a bug; the message leaves the token out. diff --git a/src/scm/git/server.py b/src/scm/git/server.py index 2d59a6ec..699e0508 100644 --- a/src/scm/git/server.py +++ b/src/scm/git/server.py @@ -1,13 +1,7 @@ from collections.abc import Callable, Mapping, Sequence from urllib.parse import urlsplit -from scm.errors import ( - ErrorCode, - GitProviderNotSupported, - GitTokenRejected, - GitTokenUnavailable, - SCMCodedError, -) +from scm.errors import ErrorCode, GitProviderNotSupported, GitTokenRejected, GitTokenUnavailable, SCMCodedError from scm.git.grant import require_public_keys, verify_grant from scm.git.push import PushRefused, check_push, check_push_headers from scm.git.relay import GitRelay, ResponseBody, _authorize @@ -151,14 +145,15 @@ def handle( operation = _authorize(grant, repository_id, method, route, query) context["operation"] = operation relayed_body: Readable | None = counted_body - if isinstance(grant, GrantV2) and operation == "push" and method == "POST": - push_rules = [ - rule for rule in grant.rules_for(repository_id, "push") if isinstance(rule, GrantV2PushRule) - ] - relayed_body = check_push(push_rules, headers, counted_body) - elif isinstance(grant, GrantV2) and operation == "push": - # The push's first request, which lists the refs, goes to the provider with the same headers. - check_push_headers(method, headers) + if isinstance(grant, GrantV2) and operation == "push": + if method == "POST": + push_rules = [ + rule for rule in grant.rules_for(repository_id, "push") if isinstance(rule, GrantV2PushRule) + ] + relayed_body = check_push(push_rules, headers, counted_body) + else: + # The push's first request, which lists the refs, goes to the provider with the same headers. + check_push_headers(method, headers) provider = initialize_provider( grant.organization_id, repository_id, From f7db0e66acf770188b98dfb177d772d984c13b16 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 10 Nov 2026 08:45:08 +0100 Subject: [PATCH 17/23] feat(git): Let fetch and push rules name tags Fetch and push rules now share their ref fields: branches or branch_patterns, and tags or tag_patterns. A pattern covers only its own kind of ref, so a broad pattern still can't reach other refs. covers() takes a full ref name, and refs lists the rule's named refs. GitServer lets a push create or move a covered tag, with create_only and the refusal of deletions applying as for branches. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- bin/git-server | 81 ++++++++------ src/scm/git/push.py | 60 +++++------ src/scm/git/relay.py | 8 +- src/scm/git/types.py | 188 ++++++++++++++++++--------------- tests/unit/test_git_grant.py | 193 ++++++++++++++++++++++++---------- tests/unit/test_git_push.py | 59 +++++++++-- tests/unit/test_git_relay.py | 13 ++- tests/unit/test_git_server.py | 3 +- 9 files changed, 386 insertions(+), 221 deletions(-) diff --git a/README.md b/README.md index d0540d64..edadbfb8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The platform exposes four subsystems: - **Actions** — outbound SCM operations initiated by Sentry code. The `SourceCodeManager` class provides 70+ methods covering comments, reactions, pull requests, branches, git objects, reviews, and check runs. With more actions planned to be added as we port more use cases. - **Actions RPC** - outbound SCM commands exposed over the network. The `SourceCodeManager` is fully available over the network. This enables usage of the SCM for services outside the monolith. -- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. A `GrantV2` names the organization and a list of rules, each a repository, an operation (`GrantV2FetchRule` or `GrantV2PushRule`) and the branches it may touch, or `any_object` for a fetch that may read any object. A push rule names its branches or full-match `branch_patterns`, and `create_only` limits it to new branches. A request passes if any rule allows it. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. For a push under a push rule it first reads the ref updates, and answers a push that updates any other ref, deletes a branch or carries push options with a status report git shows as rejected refs. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it. +- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. A `GrantV2` names the organization and a list of rules, each a repository, an operation (`GrantV2FetchRule` or `GrantV2PushRule`) and the branches and tags it may touch, or `any_object` for a fetch that may read any object. Both kinds of rule name branches by `branches` or full-match `branch_patterns`, and tags by `tags` or `tag_patterns`, and a push rule's `create_only` limits it to new refs. A request passes if any rule allows it. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. For a push under a push rule it first reads the ref updates, and answers a push that updates any other ref, deletes one or carries push options with a status report git shows as rejected refs. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it. - **Event Stream** — inbound webhook processing. SCM service-providers push events which are deserialized into typed, provider-neutral dataclasses (`CheckRunEvent`, `CommentEvent`, `PullRequestEvent`) which are then dispatched to registered listener functions. ### Why diff --git a/bin/git-server b/bin/git-server index 7bee6b35..1a075b60 100755 --- a/bin/git-server +++ b/bin/git-server @@ -70,11 +70,12 @@ Checks: - a repository the token cannot see fails with the provider's own message, which the relay passes on With --push it also commits a 256 KiB file, pushes it with a push grant for - a new branch, sending the pack chunked as git does for a large push, and - checks that the grant can neither push another branch nor delete its own, - and that the provider refuses a forged create of the existing branch under - a create_only rule. It then deletes the branch with the provider token. - This writes to the repository, so point it at a test repository. + a new branch, sending the pack chunked as git does for a large push, then + tags the commit under the same grant. It checks that the grant can neither + push another branch or tag nor delete its own, and that the provider + refuses a forged create of the existing branch and tag under a create_only + rule. It then deletes the branch and tag with the provider token. This + writes to the repository, so point it at a test repository. git runs without the user's or the system's git config, so credential helpers and URL rewrites cannot hide a failure. @@ -517,7 +518,9 @@ class Checks: other_repository_id = (self.backend.provider_name, f"{self.backend.repository['name']}-other") branch = f"scm-git-server-check/{int(time.time())}" fetch_grant = fetch_grant_for(self.args.private_key, repository_id) - push_grant = grant_for(self.args.private_key, GrantV2PushRule(repository=repository_id, branches=[branch])) + push_grant = grant_for( + self.args.private_key, GrantV2PushRule(repository=repository_id, branches=[branch], tags=[f"{branch}-tag"]) + ) server = self.start() url = f"{server.url}/{self.backend.repository['name']}.git" @@ -608,35 +611,49 @@ class Checks: ) if pushed: tip = self.git(push_grant, "rev-parse", "HEAD", cwd=clone).stdout.strip() - response = self.forge_create(url, branch, tip) - self.check( - "the provider refuses a forged create of an existing branch under a create_only rule", - subprocess.CompletedProcess([], response.status_code, response.text, ""), - response.status_code == 200 and f"ng refs/heads/{branch}".encode() in response.content, - ) + self.check_pushed_ref(url, push_grant, clone, f"refs/heads/{branch}", tip) + tag = f"refs/tags/{branch}-tag" self.expect_rejection( - "deleting the pushed branch with the push grant is rejected", - self.git(push_grant, "push", url, f":refs/heads/{branch}", cwd=clone), - "the git grant does not allow deleting a branch", - ) - self.expect_success( - f"delete the pushed branch {branch} with the token", self.delete_directly(branch, clone) + "push to a tag the grant does not list is rejected", + self.git(push_grant, "push", url, f"HEAD:{tag}-unlisted", cwd=clone), + "the git grant does not cover this tag", ) + if self.expect_success( + "push a tag with the push grant", self.git(push_grant, "push", url, f"HEAD:{tag}", cwd=clone) + ): + self.check_pushed_ref(url, push_grant, clone, tag, tip) server.shutdown() - def forge_create(self, url: str, branch: str, tip: str) -> requests.Response: - """Push ``branch`` to ``tip`` as a new branch under a create_only rule, though it exists already. + def check_pushed_ref(self, url: str, push_grant: str, clone: Path, ref: str, tip: str) -> None: + """Check that ``ref``, which the push grant created at ``tip``, can be neither forged over nor deleted, then + delete it with the token.""" + kind = "branch" if ref.startswith("refs/heads/") else "tag" + response = self.forge_create(url, ref, tip) + self.check( + f"the provider refuses a forged create of an existing {kind} under a create_only rule", + subprocess.CompletedProcess([], response.status_code, response.text, ""), + response.status_code == 200 and f"ng {ref}".encode() in response.content, + ) + self.expect_rejection( + f"deleting the pushed {kind} with the push grant is rejected", + self.git(push_grant, "push", url, f":{ref}", cwd=clone), + f"the git grant does not allow deleting a {kind}", + ) + self.expect_success(f"delete the pushed {kind} {ref} with the token", self.delete_directly(ref, clone)) - GitServer passes it as a create, so the provider's own refusal of a create over an existing branch is all that - keeps a create_only rule from moving one. git would send the branch's current id, so the request is built by + def forge_create(self, url: str, ref: str, tip: str) -> requests.Response: + """Push ``ref`` to ``tip`` as a new ref under a create_only rule, though it exists already. + + GitServer passes it as a create, so the provider's own refusal of a create over an existing ref is all that + keeps a create_only rule from moving one. git would send the ref's current id, so the request is built by hand. """ - rule = GrantV2PushRule( - repository=self.backend.repository_id, branch_patterns=[re.escape(branch)], create_only=True - ) + prefix, _, name = ref.partition("/")[2].partition("/") + patterns = {"branch_patterns" if prefix == "heads" else "tag_patterns": [re.escape(name)]} + rule = GrantV2PushRule(repository=self.backend.repository_id, create_only=True, **patterns) return requests.post( f"{url}/git-receive-pack", - data=forged_create_body(branch, tip), + data=forged_create_body(ref, tip), headers={ GRANT_HEADER: grant_for(self.args.private_key, rule), "Content-Type": "application/x-git-receive-pack-request", @@ -646,8 +663,8 @@ class Checks: timeout=60, ) - def delete_directly(self, branch: str, clone: Path) -> subprocess.CompletedProcess[str]: - """Delete ``branch`` on the provider with the token Sentry would use, since a push grant cannot.""" + def delete_directly(self, ref: str, clone: Path) -> subprocess.CompletedProcess[str]: + """Delete ``ref`` on the provider with the token Sentry would use, since a push grant cannot.""" provider = self.backend.fetch_provider(ORGANIZATION_ID, self.backend.repository) assert isinstance(provider, GetGitRemoteProtocol) remote = provider.get_git_remote() @@ -661,13 +678,13 @@ class Checks: "GIT_CONFIG_KEY_0": "http.extraHeader", "GIT_CONFIG_VALUE_0": f"Authorization: Basic {credentials}", } - command = ["git", "-c", "credential.helper=", "push", remote["url"], f":refs/heads/{branch}"] + command = ["git", "-c", "credential.helper=", "push", remote["url"], f":{ref}"] return subprocess.run(command, cwd=clone, env=env, capture_output=True, text=True, timeout=300) -def forged_create_body(branch: str, tip: str) -> bytes: - """A receive-pack request that creates ``branch`` at ``tip``, an object the provider has, even if it exists.""" - update = f"{'0' * len(tip)} {tip} refs/heads/{branch}\0report-status".encode() +def forged_create_body(ref: str, tip: str) -> bytes: + """A receive-pack request that creates ``ref`` at ``tip``, an object the provider has, even if it exists.""" + update = f"{'0' * len(tip)} {tip} {ref}\0report-status".encode() if len(tip) == 64: update += b" object-format=sha256" # An empty pack, since the provider has the commit already: a header for zero objects and its hash. diff --git a/src/scm/git/push.py b/src/scm/git/push.py index 63b1ae4d..99151383 100644 --- a/src/scm/git/push.py +++ b/src/scm/git/push.py @@ -14,7 +14,7 @@ from scm.errors import GitMalformedRequestBody, GitOperationNotGranted, RpcMalformedRequestHeaders, SCMCodedError from scm.git.relay import DEFAULT_CHUNK_SIZE, _printable, _read -from scm.git.types import GrantV2PushRule, Readable, _follows_ref_format, _is_branch_name +from scm.git.types import GrantV2PushRule, Readable, _follows_ref_format, _is_short_name MAX_UPDATES_LENGTH = 64 * 1024 """The most bytes a push's ref updates may take. An update takes under 100 bytes plus its ref's name.""" @@ -51,9 +51,11 @@ # ASCII without surrounding space, which is what the relay sends. _FREE_HEADERS = frozenset({"accept-encoding", "user-agent"}) _FREE_HEADER_VALUE = re.compile(r"[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?") -# The longest branch name a push may update, in bytes. A rule's pattern runs on the name the client chose, and one -# that backtracks takes time that grows with the name's length. -_MAX_BRANCH_LENGTH = 255 +# The longest branch or tag name a push may update, in bytes. A rule's pattern runs on the name the client chose, and +# one that backtracks takes time that grows with the name's length. +_MAX_NAME_LENGTH = 255 +# The refs a push rule can cover, by the prefix of their full names. +_KINDS = {"refs/heads/": "branch", "refs/tags/": "tag"} _FLUSH = b"0000" # A pkt-line's length, in 4 lowercase hex digits as git writes them, counts the 4 digits too. @@ -210,41 +212,38 @@ def refused_updates(rules: Sequence[GrantV2PushRule], request: PushRequest) -> d return {update.ref: reason for update in request.updates} refused: dict[bytes, str] = {} for update in request.updates: - refusals = [_refusal(rule, update) for rule in rules] + refusals = [_refusal(rule, update) for rule in rules] if rules else [_refusal(None, update)] reasons = [refusal for refusal in refusals if refusal is not None] if len(reasons) == len(refusals): # The reason of the rule the update came closest to passing says the most. - refused[update.ref] = max(reasons, default=_NOT_COVERED)[1] + refused[update.ref] = max(reasons)[1] if refused: for update in request.updates: refused.setdefault(update.ref, "refused with the rest of the push") return refused -_NOT_A_BRANCH = (0, "the git grant allows pushing branches only") -_NOT_COVERED = (1, "the git grant does not cover this branch") -_NOT_NEW = (2, "the git grant allows only creating this branch") -_DELETES = (3, "the git grant does not allow deleting a branch") - - -def _refusal(rule: GrantV2PushRule, update: RefUpdate) -> tuple[int, str] | None: - """Return why ``rule`` refuses ``update``, ranked by how far the update got, or None if the rule allows it.""" - if not update.ref.startswith(b"refs/heads/"): - return _NOT_A_BRANCH - # Reading the push checked that the branch's name is one git takes, so it decodes. - if not rule.covers(update.ref.removeprefix(b"refs/heads/").decode()): - return _NOT_COVERED +def _refusal(rule: GrantV2PushRule | None, update: RefUpdate) -> tuple[int, str] | None: + """Return why ``rule``, or a grant with no rule, refuses ``update``, ranked by how far the update got, or None if + the rule allows it.""" + # Reading the push checked that the ref's name is one git takes, so it decodes. + ref = update.ref.decode() + kind = next((kind for prefix, kind in _KINDS.items() if ref.startswith(prefix)), None) + if kind is None: + return (0, "the git grant allows pushing branches and tags only") + if rule is None or not rule.covers(ref): + return (1, f"the git grant does not cover this {kind}") if update.deletes: - return _DELETES + return (3, f"the git grant does not allow deleting a {kind}") if rule.create_only and not update.creates: - return _NOT_NEW + return (2, f"the git grant allows only creating this {kind}") return None def refusal_report(request: PushRequest, refused: Mapping[bytes, str]) -> bytes | None: """Return the response body that tells git why each ref was refused, or None if the client asked for none. - git shows each ref as ``! [remote rejected] ()``, as for a provider's own refusal. A refused + git shows each ref as ``! [remote rejected] ()``, as for a provider's own refusal. A refused HTTP request would show only its status code. """ if not {"report-status", "report-status-v2"} & request.capabilities: @@ -365,20 +364,21 @@ def _ref_update(line: bytes) -> RefUpdate: raise GitMalformedRequestBody(detail="The push's ref updates are malformed.") # A rule checks the ref the push names, and a name git wouldn't take might not be the ref the provider updates. # It also goes into the refusal's detail, which reaches logs, so it must be printable too, which keeps out - # invisible and right-to-left characters that make one branch name look like another. + # invisible and right-to-left characters that make one ref name look like another. try: name = ref.decode() except UnicodeDecodeError: name = "" if not (name.startswith("refs/") and name.isprintable() and _follows_ref_format(name)): raise GitMalformedRequestBody(detail="The push updates a ref whose name git wouldn't take.") - if name.startswith("refs/heads/"): - if not _is_branch_name(name.removeprefix("refs/heads/")): - raise GitMalformedRequestBody(detail="The push updates a branch whose name git wouldn't take.") - if len(ref.removeprefix(b"refs/heads/")) > _MAX_BRANCH_LENGTH: - raise GitMalformedRequestBody( - detail=f"The push updates a branch whose name is longer than {_MAX_BRANCH_LENGTH} bytes." - ) + for prefix, kind in _KINDS.items(): + if name.startswith(prefix): + if not _is_short_name(name.removeprefix(prefix)): + raise GitMalformedRequestBody(detail=f"The push updates a {kind} whose name git wouldn't take.") + if len(ref) - len(prefix) > _MAX_NAME_LENGTH: + raise GitMalformedRequestBody( + detail=f"The push updates a {kind} whose name is longer than {_MAX_NAME_LENGTH} bytes." + ) return RefUpdate(old_id=old_id, new_id=new_id, ref=ref) diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py index 17ba3737..0d8ece47 100644 --- a/src/scm/git/relay.py +++ b/src/scm/git/relay.py @@ -73,8 +73,8 @@ def authorize_request( the request uses: a database id does not match the same repository's ``(provider, external id)`` pair. A ``GrantV2`` allows a request only if it has a rule for the request's repository and operation. A fetch needs a - fetch rule for any object, since nothing reads a fetch's body to keep it to a rule's branches. A push under a - ``GrantV2`` is refused here, because only its ref updates show which branches it touches: relay it through + fetch rule for any object, since nothing reads a fetch's body to keep it to a rule's branches and tags. A push + under a ``GrantV2`` is refused here, because only its ref updates show which refs it touches: relay it through GitServer, which reads them before it relays the push. """ operation = _authorize(grant, repository_id, method, route, query) @@ -120,10 +120,10 @@ def _authorize_v2(grant: GrantV2, repository_id: RepositoryId, operation: GitOpe if not grant.rules_for(repository_id, other): raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") raise GitOperationNotGranted(detail=f"The git grant allows {other} on this repository, not {operation}.") - # Delete this once GitServer keeps a fetch to its rule's branches. + # Delete this once GitServer keeps a fetch to its rule's branches and tags. if operation == "fetch" and not any(isinstance(rule, GrantV2FetchRule) and rule.any_object for rule in rules): raise GitOperationNotGranted( - detail="The git endpoint cannot keep a fetch to a rule's branches yet, so it refuses the rule." + detail="The git endpoint cannot keep a fetch to a rule's branches and tags yet, so it refuses the rule." ) diff --git a/src/scm/git/types.py b/src/scm/git/types.py index f9d9dc1b..df7b9313 100644 --- a/src/scm/git/types.py +++ b/src/scm/git/types.py @@ -23,99 +23,121 @@ ``PUT /grant/{sandbox_id}`` refuses a longer one, so a change here must change the router too. A grant grows with its rules: a grant with one is under 500 characters, and the claims are base64-encoded, so a fetch rule for any object of a repository named as ``(provider, path)`` adds about 4/3 of its path's length plus 87, e.g. 120 for a -25-character path, and each branch a rule lists adds about 4/3 of its name's length plus 4. The limit is a +25-character path, and each branch or tag a rule lists adds about 4/3 of its name's length plus 4. The limit is a conservative pick rather than a measured ceiling: header limits further along the path are typically around 8 KiB.""" -class GrantV2FetchRule( - msgspec.Struct, - frozen=True, - kw_only=True, - forbid_unknown_fields=True, - omit_defaults=True, - tag="fetch", - tag_field="operation", -): - """Lets the holder fetch from ``repository``: any of its objects, or only what its listed ``branches`` reach. +class _GrantV2RefRule(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True, omit_defaults=True): + """What fetch and push rules share: the repository, and the branches and tags of it the rule covers. - A rule sets exactly one of the two, so a rule that leaves its branches out is refused rather than read as one - that reads everything. ``any_object`` reaches every object the provider serves, including objects no branch - reaches and, on GitHub, objects of the repository's forks, so give it only to code that runs no agent, such as a - checkout before the agent starts. + Each kind of ref is set by a list of names or by patterns, and not both. A pattern covers only names of its own + kind, so a pattern too broad for branches still can't reach a tag or any other ref. """ - operation: ClassVar[GitOperation] = "fetch" repository: RepositoryId branches: list[BranchName] | None = None """Branch names as ``git branch`` takes them, without ``refs/heads/``.""" + branch_patterns: list[str] | None = None + """Python regular expressions, each matched against a branch's whole name without ``refs/heads/``, such as + ``seer/.+``. The pushing client chooses the names they are matched against, so a pattern must not be one that + backtracks catastrophically.""" + tags: list[str] | None = None + """Tag names as ``git tag`` takes them, without ``refs/tags/``.""" + tag_patterns: list[str] | None = None + """Like ``branch_patterns``, each matched against a tag's whole name without ``refs/tags/``.""" + + def __post_init__(self) -> None: + for names_field, names, patterns_field, patterns, kind in ( + ("branches", self.branches, "branch_patterns", self.branch_patterns, "branch"), + ("tags", self.tags, "tag_patterns", self.tag_patterns, "tag"), + ): + if names is not None and patterns is not None: + raise ValueError(f"A rule must not set both {names_field} and {patterns_field}.") + if names is not None: + _check_names(names_field, names, kind) + if patterns is not None: + _check_patterns(patterns_field, patterns) + + @property + def refs(self) -> list[str]: + """The full names of the refs the rule lists, such as ``refs/heads/main``. Its patterns cover more.""" + return [f"refs/heads/{name}" for name in self.branches or ()] + [ + f"refs/tags/{name}" for name in self.tags or () + ] + + def covers(self, ref: str) -> bool: + """Return whether the rule covers ``ref``, a full ref name such as ``refs/heads/main``.""" + if ref in self.refs: + return True + if ref.startswith("refs/heads/"): + name, patterns = ref.removeprefix("refs/heads/"), self.branch_patterns + elif ref.startswith("refs/tags/"): + name, patterns = ref.removeprefix("refs/tags/"), self.tag_patterns + else: + return False + return any(re.fullmatch(pattern, name) for pattern in patterns or ()) + + def _names_refs(self) -> bool: + return any(field is not None for field in (self.branches, self.branch_patterns, self.tags, self.tag_patterns)) + + +class GrantV2FetchRule(_GrantV2RefRule, frozen=True, tag="fetch", tag_field="operation"): + """Lets the holder fetch from ``repository``: any of its objects, or only what the branches and tags it covers + reach. + + A rule sets ``any_object`` or names refs, and not both, so a rule that names no refs is refused rather than read + as one that reads everything. ``any_object`` reaches every object the provider serves, including objects no ref + reaches and, on GitHub, objects of the repository's forks, so give it only to code that runs no agent, such as a + checkout before the agent starts. + """ + + operation: ClassVar[GitOperation] = "fetch" any_object: bool = False def __post_init__(self) -> None: - if self.branches is None and not self.any_object: - raise ValueError("A fetch rule must list its branches or set any_object.") - if self.branches is not None and self.any_object: - raise ValueError("A fetch rule must not both list branches and set any_object.") - if self.branches is not None: - _check_branches(self.branches) - - -class GrantV2PushRule( - msgspec.Struct, - frozen=True, - kw_only=True, - forbid_unknown_fields=True, - omit_defaults=True, - tag="push", - tag_field="operation", -): - """Lets the holder create or update the branches of ``repository`` the rule covers, and no other ref: no tag, - and no deletion. - - The rule covers the branches it lists in ``branches``, or every branch whose whole name matches one of - ``branch_patterns``, and sets exactly one of the two. With ``create_only`` it allows only creating a covered - branch that doesn't exist yet, so a pattern can let the holder start new branches without moving any that exist. - Without it, a push may move a covered branch to any commit, so a force push passes. + super().__post_init__() + if self.any_object and self._names_refs(): + raise ValueError( + "A fetch rule that sets any_object must not set branches, branch_patterns, tags or tag_patterns." + ) + if not self.any_object and not self._names_refs(): + raise ValueError("A fetch rule must set branches, branch_patterns, tags, tag_patterns or any_object.") + + def covers(self, ref: str) -> bool: + return self.any_object or super().covers(ref) + + +class GrantV2PushRule(_GrantV2RefRule, frozen=True, tag="push", tag_field="operation"): + """Lets the holder create or update the branches and tags of ``repository`` the rule covers, and no other ref, + and delete none. + + With ``create_only`` it allows only creating a covered branch or tag that doesn't exist yet, so a pattern can let + the holder start new branches or tag new releases without moving any that exist. Without it, a push may move a + covered branch or tag to any commit, so a force push passes. Don't cover the repository's default branch, by name or by pattern. The git endpoint doesn't know which branch - that is, so keeping it out is the signer's job, with the provider's branch protection behind it. + that is, so keeping it out is the signer's job, with the provider's branch protection behind it. Cover tags with + the same care, since a new tag often starts a release pipeline. """ operation: ClassVar[GitOperation] = "push" - repository: RepositoryId - branches: list[BranchName] | None = None - """Branch names as ``git branch`` takes them, without ``refs/heads/``.""" - branch_patterns: list[str] | None = None - """Python regular expressions, each matched against a branch's whole name without ``refs/heads/``, such as - ``seer/.+``. The pushing client chooses the names they are matched against, so a pattern must not be one that - backtracks catastrophically.""" create_only: bool = False - """Allow only creating a covered branch. git sends a new branch with an old object id of all zeros, and the - provider refuses such an update if the branch already exists.""" + """Allow only creating a covered branch or tag. git sends a new ref with an old object id of all zeros, and the + provider refuses such an update if the ref already exists.""" def __post_init__(self) -> None: - if self.branches is None and self.branch_patterns is None: - raise ValueError("A push rule must list its branches or set branch_patterns.") - if self.branches is not None and self.branch_patterns is not None: - raise ValueError("A push rule must not both list branches and set branch_patterns.") - if self.branches is not None: - _check_branches(self.branches) - if self.branch_patterns is not None: - _check_patterns(self.branch_patterns) - - def covers(self, branch: BranchName) -> bool: - """Return whether the rule covers ``branch``, a name without ``refs/heads/``.""" - if self.branches is not None: - return branch in self.branches - return any(re.fullmatch(pattern, branch) for pattern in self.branch_patterns or ()) - - -@warnings.deprecated("GrantV1 is deprecated. Sign a GrantV2, whose rules can keep a push to branches.") + super().__post_init__() + if not self._names_refs(): + raise ValueError("A push rule must set branches, branch_patterns, tags or tag_patterns.") + + +@warnings.deprecated("GrantV1 is deprecated. Sign a GrantV2, whose rules can keep a push to branches and tags.") class GrantV1(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): """Permission to run one git operation against a fixed set of an organization's repositories. - Deprecated: sign a ``GrantV2``, which can also keep a push to branches. Verifiers still accept this version, so a - signer can switch once they can read both. + Deprecated: sign a ``GrantV2``, which can also keep a push to branches and tags. Verifiers still accept this + version, so a signer can switch once they can read both. A push grant lets its holder update any ref of its repositories, including tags and deletions, since GitServer relays its pushes unread. Each repository is a ``RepositoryId`` under the grant's organization, as in the RPC @@ -187,31 +209,31 @@ def rules_for(self, repository: RepositoryId, operation: GitOperation) -> list[G _NOT_IN_A_REF = re.compile(r"[\x00-\x20\x7f~^:?*\[\\]|\.\.|@\{|//") -def _check_patterns(patterns: list[str]) -> None: +def _check_patterns(field: str, patterns: list[str]) -> None: if not patterns: - raise ValueError("A rule's branch pattern list must not be empty.") + raise ValueError(f"A rule's {field} must not be empty.") for pattern in patterns: if not pattern: - raise ValueError("A branch pattern must not be empty.") + raise ValueError(f"A pattern in a rule's {field} must not be empty.") try: re.compile(pattern) except re.error as e: raise ValueError(f"{pattern!r} is not a regular expression: {e}") from None -def _check_branches(branches: list[BranchName]) -> None: - if not branches: - raise ValueError("A rule's branch list must not be empty.") - for name in branches: - if not _is_branch_name(name): - raise ValueError( - f"{name!r} is not a branch name. Name a branch as git branch takes it, without refs/heads/." - ) +def _check_names(field: str, names: list[str], kind: str) -> None: + if not names: + raise ValueError(f"A rule's {field} must not be empty.") + for name in names: + if not _is_short_name(name): + prefix = "refs/heads/" if kind == "branch" else "refs/tags/" + raise ValueError(f"{name!r} is not a {kind} name. Name a {kind} as git {kind} takes it, without {prefix}.") -def _is_branch_name(name: str) -> bool: - # What git branch refuses on top of git check-ref-format's rules: a leading "-" and "HEAD". A name starting with - # refs/ is a valid branch, but it's far more likely a full ref name by mistake. +def _is_short_name(name: str) -> bool: + """Return whether ``name`` is a branch or tag name, without its refs/heads/ or refs/tags/.""" + # What git branch and git tag refuse on top of git check-ref-format's rules: a leading "-" and "HEAD". A name + # starting with refs/ is a valid one, but it's far more likely a full ref name by mistake. return _follows_ref_format(name) and name != "HEAD" and not name.startswith(("-", "refs/")) diff --git a/tests/unit/test_git_grant.py b/tests/unit/test_git_grant.py index 9d83b993..cf19a9ca 100644 --- a/tests/unit/test_git_grant.py +++ b/tests/unit/test_git_grant.py @@ -543,6 +543,10 @@ def test_version_1_claims_labelled_version_2(self): assert_invalid(sign_claims(claims_of(make_v1_grant()) | {"version": 2}), "payload is invalid") +FETCH_NAMES_NOTHING = "A fetch rule must set branches, branch_patterns, tags, tag_patterns or any_object" +PUSH_NAMES_NOTHING = "A push rule must set branches, branch_patterns, tags or tag_patterns" + + def rule_claims(*rules: dict) -> dict: return claims_of(make_grant()) | {"rules": list(rules)} @@ -563,56 +567,77 @@ def test_claims_hold_only_what_a_rule_sets(self): # Shorter, and a reader sees which fetch rules reach any object. grant = make_grant( rules=[ - GrantV2FetchRule(repository=5, branches=["main"]), + GrantV2FetchRule(repository=5, branches=["main"], tags=["v1"]), GrantV2FetchRule(repository=6, any_object=True), GrantV2PushRule(repository=5, branches=["seer/fix"]), GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + GrantV2PushRule(repository=5, tag_patterns=["v[0-9]+"], create_only=True), ] ) claims = jwt.decode(sign_grant(KEY.private, grant, now=NOW), options={"verify_signature": False}) assert claims["rules"] == [ - {"operation": "fetch", "repository": 5, "branches": ["main"]}, + {"operation": "fetch", "repository": 5, "branches": ["main"], "tags": ["v1"]}, {"operation": "fetch", "repository": 6, "any_object": True}, {"operation": "push", "repository": 5, "branches": ["seer/fix"]}, {"operation": "push", "repository": 5, "branch_patterns": ["seer/.+"], "create_only": True}, + {"operation": "push", "repository": 5, "tag_patterns": ["v[0-9]+"], "create_only": True}, ] + @pytest.mark.parametrize("operation", ["fetch", "push"]) + def test_same_ref_fields_for_fetch_and_push(self, operation): + rule = { + "operation": operation, + "repository": 5, + "branches": ["main"], + "tag_patterns": ["v[0-9]+"], + } + [verified] = verify_grant([KEY.public], sign_claims(rule_claims(rule)), now=NOW).rules + assert verified.covers("refs/heads/main") + assert verified.covers("refs/tags/v2") + @pytest.mark.parametrize( "rule,error", [ - ({"operation": "fetch", "repository": 5}, "must list its branches or set any_object"), - ({"operation": "fetch", "repository": 5, "any_object": False}, "must list its branches or set any_object"), - ({"operation": "fetch", "repository": 5, "branches": None}, "must list its branches or set any_object"), + ({"operation": "fetch", "repository": 5}, FETCH_NAMES_NOTHING), + ({"operation": "fetch", "repository": 5, "any_object": False}, FETCH_NAMES_NOTHING), + ({"operation": "fetch", "repository": 5, "branches": None}, FETCH_NAMES_NOTHING), ( {"operation": "fetch", "repository": 5, "branches": ["main"], "any_object": True}, - "must not both list branches and set any_object", + "that sets any_object must not set branches, branch_patterns, tags or tag_patterns", + ), + ( + {"operation": "fetch", "repository": 5, "tag_patterns": ["v.+"], "any_object": True}, + "that sets any_object must not set branches, branch_patterns, tags or tag_patterns", ), ({"operation": "fetch", "repository": 5, "branches": []}, "must not be empty"), + ({"operation": "fetch", "repository": 5, "tags": []}, "must not be empty"), ({"operation": "push", "repository": 5, "branches": []}, "must not be empty"), - ({"operation": "push", "repository": 5}, "must list its branches or set branch_patterns"), - ({"operation": "push", "repository": 5, "branches": None}, "must list its branches or set branch_patterns"), + ({"operation": "push", "repository": 5, "tags": []}, "must not be empty"), + ({"operation": "push", "repository": 5}, PUSH_NAMES_NOTHING), + ({"operation": "push", "repository": 5, "branches": None}, PUSH_NAMES_NOTHING), + ({"operation": "push", "repository": 5, "create_only": True}, PUSH_NAMES_NOTHING), ( - {"operation": "push", "repository": 5, "create_only": True}, - "must list its branches or set branch_patterns", + {"operation": "push", "repository": 5, "branches": ["a"], "branch_patterns": ["a"]}, + "must not set both branches and branch_patterns", ), ( - {"operation": "push", "repository": 5, "branches": ["a"], "branch_patterns": ["a"]}, - "must not both list branches and set branch_patterns", + {"operation": "fetch", "repository": 5, "tags": ["a"], "tag_patterns": ["a"]}, + "must not set both tags and tag_patterns", ), ({"operation": "push", "repository": 5, "branch_patterns": []}, "must not be empty"), + ({"operation": "push", "repository": 5, "tag_patterns": []}, "must not be empty"), ({"operation": "push", "repository": 5, "branch_patterns": [""]}, "must not be empty"), + ({"operation": "fetch", "repository": 5, "tag_patterns": [""]}, "must not be empty"), ({"operation": "push", "repository": 5, "branch_patterns": ["seer/("]}, "is not a regular expression"), + ({"operation": "fetch", "repository": 5, "tag_patterns": ["v("]}, "is not a regular expression"), ({"operation": "push", "repository": 5, "branches": ["a"], "create_only": 1}, "Expected `bool`"), ( {"operation": "push", "repository": 5, "branches": ["a"], "any_object": True}, "unknown field `any_object`", ), ({"operation": "fetch", "repository": 5, "any_object": True, "create_only": True}, "unknown field"), - ( - {"operation": "fetch", "repository": 5, "branch_patterns": ["seer/.+"]}, - "unknown field `branch_patterns`", - ), ({"operation": "fetch", "repository": 5, "branches": ["main"], "deepen": 50}, "unknown field `deepen`"), + ({"operation": "push", "repository": 5, "refs": ["refs/heads/main"]}, "unknown field `refs`"), ({"operation": "delete", "repository": 5, "branches": ["main"]}, "Invalid value 'delete'"), ({"repository": 5, "any_object": True}, "missing required field `operation`"), ({"operation": "fetch", "any_object": True}, "missing required field `repository`"), @@ -621,18 +646,21 @@ def test_claims_hold_only_what_a_rule_sets(self): def test_invalid_rule(self, rule, error): assert_invalid(sign_claims(rule_claims(rule)), error) - def test_rule_without_branches_or_any_object_cannot_be_built(self): - with pytest.raises(ValueError, match="must list its branches or set any_object"): + def test_rule_without_refs_or_any_object_cannot_be_built(self): + with pytest.raises(ValueError, match=FETCH_NAMES_NOTHING): GrantV2FetchRule(repository=5) + with pytest.raises(ValueError, match=PUSH_NAMES_NOTHING): + GrantV2PushRule(repository=5, create_only=True) - def test_rule_with_branches_and_any_object_cannot_be_built(self): - with pytest.raises(ValueError, match="must not both list branches and set any_object"): - GrantV2FetchRule(repository=5, branches=["main"], any_object=True) + def test_rule_with_refs_and_any_object_cannot_be_built(self): + with pytest.raises(ValueError, match="that sets any_object must not set"): + GrantV2FetchRule(repository=5, tags=["v1"], any_object=True) @pytest.mark.parametrize("make_rule", [GrantV2FetchRule, GrantV2PushRule], ids=["fetch", "push"]) - def test_rule_without_branches_listed_cannot_be_built(self, make_rule): + @pytest.mark.parametrize("field", ["branches", "branch_patterns", "tags", "tag_patterns"]) + def test_rule_with_an_empty_list_cannot_be_built(self, make_rule, field): with pytest.raises(ValueError, match="must not be empty"): - make_rule(repository=5, branches=[]) + make_rule(repository=5, **{field: []}) def test_no_rules(self): with pytest.raises(ValueError, match="at least one rule"): @@ -662,7 +690,8 @@ def test_one_repository_named_both_ways(self): assert make_grant(rules=rules).rules == rules -INVALID_BRANCH_NAMES = [ +# Invalid as a branch name and as a tag name. +INVALID_REF_NAMES = [ "", "refs/heads/main", "refs/tags/v1", @@ -693,53 +722,107 @@ def test_one_repository_named_both_ways(self): ] -class TestBranchPatterns: +class TestCovers: + """A rule covers full ref names: its branches under refs/heads/ and its tags under refs/tags/.""" + @pytest.mark.parametrize( - "patterns,branch,covered", + "patterns,ref,covered", [ - (["seer/.+"], "seer/fix", True), - (["seer/.+"], "seer/fix/deeper", True), - (["seer/.+"], "seer/", False), + (["seer/.+"], "refs/heads/seer/fix", True), + (["seer/.+"], "refs/heads/seer/fix/deeper", True), + (["seer/.+"], "refs/heads/seer/", False), # Matched against the whole name, not searched for in it. - (["seer/.+"], "main-seer/fix", False), - (["seer/[a-z-]+"], "seer/fix/deeper", False), - (["seer"], "seer/fix", False), - (["seer/[a-z]+", "cursor/.+"], "cursor/x", True), - (["seer/.+"], "Seer/fix", False), + (["seer/.+"], "refs/heads/main-seer/fix", False), + (["seer/[a-z-]+"], "refs/heads/seer/fix/deeper", False), + (["seer"], "refs/heads/seer/fix", False), + (["seer/[a-z]+", "cursor/.+"], "refs/heads/cursor/x", True), + (["seer/.+"], "refs/heads/Seer/fix", False), + # A branch pattern covers only branches. + (["seer/.+"], "refs/tags/seer/fix", False), + (["seer/.+"], "seer/fix", False), ], ) - def test_covers(self, patterns, branch, covered): - assert GrantV2PushRule(repository=5, branch_patterns=patterns).covers(branch) is covered + def test_branch_patterns(self, patterns, ref, covered): + assert GrantV2PushRule(repository=5, branch_patterns=patterns).covers(ref) is covered - def test_listed_branches(self): - rule = GrantV2PushRule(repository=5, branches=["seer/fix"]) - assert rule.covers("seer/fix") - assert not rule.covers("seer/fix2") + @pytest.mark.parametrize( + "ref,covered", + [ + ("refs/tags/v1.2", True), + ("refs/tags/v1", False), + ("refs/tags/x/v1.2", False), + ("refs/heads/v1.2", False), + ], + ) + def test_tag_patterns(self, ref, covered): + assert GrantV2PushRule(repository=5, tag_patterns=[r"v[0-9]+\.[0-9]+"]).covers(ref) is covered + + def test_listed_refs(self): + rule = GrantV2PushRule(repository=5, branches=["seer/fix"], tags=["v1"]) + assert rule.refs == ["refs/heads/seer/fix", "refs/tags/v1"] + assert rule.covers("refs/heads/seer/fix") + assert rule.covers("refs/tags/v1") + assert not rule.covers("refs/heads/seer/fix2") + assert not rule.covers("refs/heads/v1") + assert not rule.covers("refs/tags/seer/fix") + + @pytest.mark.parametrize("ref", ["refs/pull/1/head", "refs/notes/commits", "refs/heads", "refs/tags", "HEAD"]) + def test_patterns_cover_only_branches_and_tags(self, ref): + rule = GrantV2PushRule(repository=5, branch_patterns=[".*"], tag_patterns=[".*"]) + assert rule.refs == [] + assert not rule.covers(ref) - def test_cannot_be_built_with_an_invalid_pattern(self): + @pytest.mark.parametrize("make_rule", [GrantV2FetchRule, GrantV2PushRule], ids=["fetch", "push"]) + def test_same_for_fetch_and_push(self, make_rule): + rule = make_rule(repository=5, branches=["main"], tag_patterns=["v.+"]) + assert rule.refs == ["refs/heads/main"] + assert rule.covers("refs/heads/main") + assert rule.covers("refs/tags/v1") + assert not rule.covers("refs/heads/dev") + + def test_any_object_covers_every_ref(self): + rule = GrantV2FetchRule(repository=5, any_object=True) + assert rule.refs == [] + assert rule.covers("refs/heads/main") + assert rule.covers("refs/pull/1/head") + + @pytest.mark.parametrize("field", ["branch_patterns", "tag_patterns"]) + def test_cannot_be_built_with_an_invalid_pattern(self, field): with pytest.raises(ValueError, match="'seer/\\(' is not a regular expression"): - GrantV2PushRule(repository=5, branch_patterns=["seer/("]) + GrantV2PushRule(repository=5, **{field: ["seer/("]}) + +NAME_FIELDS = pytest.mark.parametrize( + "field,error", [("branches", "is not a branch name"), ("tags", "is not a tag name")], ids=["branch", "tag"] +) -class TestBranchNames: - @pytest.mark.parametrize("name", INVALID_BRANCH_NAMES) - def test_invalid(self, name): - with pytest.raises(ValueError, match="is not a branch name"): - GrantV2PushRule(repository=5, branches=["main", name]) + +class TestRefNames: + @NAME_FIELDS + @pytest.mark.parametrize("name", INVALID_REF_NAMES) + def test_invalid(self, field, error, name): + with pytest.raises(ValueError, match=error): + GrantV2PushRule(repository=5, **{field: ["main", name]}) assert_invalid( - sign_claims(rule_claims({"operation": "push", "repository": 5, "branches": ["main", name]})), - "is not a branch name", + sign_claims(rule_claims({"operation": "push", "repository": 5, field: ["main", name]})), + error, ) - def test_checked_in_fetch_rules_too(self): - with pytest.raises(ValueError, match="is not a branch name"): - GrantV2FetchRule(repository=5, branches=["refs/heads/main"]) + @NAME_FIELDS + def test_checked_in_fetch_rules_too(self, field, error): + with pytest.raises(ValueError, match=error): + GrantV2FetchRule(repository=5, **{field: ["refs/heads/main"]}) + + def test_tag_named_without_refs_tags(self): + with pytest.raises(ValueError, match="Name a tag as git tag takes it, without refs/tags/"): + GrantV2PushRule(repository=5, tags=["refs/tags/v1"]) + @pytest.mark.parametrize("field", ["branches", "tags"]) @pytest.mark.parametrize( - "name", ["main", "seer/fix-123", "feature/a.b", "a@b", "release-1.0", "a.lock.b", "x/HEAD", "ünicode"] + "name", ["main", "seer/fix-123", "feature/a.b", "a@b", "release-1.0", "a.lock.b", "x/HEAD", "ünicode", "v1.0.0"] ) - def test_valid(self, name): - assert GrantV2PushRule(repository=5, branches=[name]).branches == [name] + def test_valid(self, field, name): + assert getattr(GrantV2PushRule(repository=5, **{field: [name]}), field) == [name] def sign_v1(grant: GrantV1) -> str: diff --git a/tests/unit/test_git_push.py b/tests/unit/test_git_push.py index 340fb74d..332ecb43 100644 --- a/tests/unit/test_git_push.py +++ b/tests/unit/test_git_push.py @@ -261,9 +261,9 @@ def test_probe(self): ("refs/heads/seer/fix/deeper", "the git grant does not cover this branch"), ("refs/heads/seer", "the git grant does not cover this branch"), ("refs/heads/Seer/fix", "the git grant does not cover this branch"), - ("refs/tags/seer/fix", "the git grant allows pushing branches only"), - ("refs/pull/1/head", "the git grant allows pushing branches only"), - ("refs/notes/commits", "the git grant allows pushing branches only"), + ("refs/tags/seer/fix", "the git grant does not cover this tag"), + ("refs/pull/1/head", "the git grant allows pushing branches and tags only"), + ("refs/notes/commits", "the git grant allows pushing branches and tags only"), ], ) def test_refs_the_rule_does_not_list(self, ref, reason): @@ -280,9 +280,16 @@ def test_one_refused_update_refuses_the_push(self): b"refs/heads/seer/fix": "refused with the rest of the push", } - def test_no_rules(self): - refused = refused_updates([], request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"))) - assert refused == {b"refs/heads/seer/fix": "the git grant does not cover this branch"} + @pytest.mark.parametrize( + "ref,reason", + [ + ("refs/heads/seer/fix", "the git grant does not cover this branch"), + ("refs/tags/v1", "the git grant does not cover this tag"), + ("refs/notes/commits", "the git grant allows pushing branches and tags only"), + ], + ) + def test_no_rules(self, ref, reason): + assert refused_updates([], request_for((OLD_ID, NEW_ID, ref))) == {ref.encode(): reason} @pytest.mark.parametrize( "old_id,ref,reason", @@ -290,7 +297,7 @@ def test_no_rules(self): (ZERO_ID, "refs/heads/seer/new", None), (OLD_ID, "refs/heads/seer/new", "the git grant allows only creating this branch"), (ZERO_ID, "refs/heads/main", "the git grant does not cover this branch"), - (ZERO_ID, "refs/tags/seer/new", "the git grant allows pushing branches only"), + (ZERO_ID, "refs/tags/seer/new", "the git grant does not cover this tag"), ], ) def test_create_only_pattern(self, old_id, ref, reason): @@ -305,6 +312,31 @@ def test_create_only_listed_branch(self): b"refs/heads/seer/fix": "the git grant allows only creating this branch" } + @pytest.mark.parametrize( + "old_id,new_id,ref,reason", + [ + (ZERO_ID, NEW_ID, "refs/tags/v1.0", None), + (OLD_ID, NEW_ID, "refs/tags/v1.0", "the git grant allows only creating this tag"), + (OLD_ID, ZERO_ID, "refs/tags/v1.0", "the git grant does not allow deleting a tag"), + (ZERO_ID, NEW_ID, "refs/tags/seer-1.0", "the git grant does not cover this tag"), + (ZERO_ID, NEW_ID, "refs/heads/v1.0", "the git grant does not cover this branch"), + ], + ) + def test_create_only_tag_pattern(self, old_id, new_id, ref, reason): + rules = [GrantV2PushRule(repository=5, tag_patterns=[r"v[0-9]+\.[0-9]+"], create_only=True)] + refused = refused_updates(rules, request_for((old_id, new_id, ref))) + assert refused == ({} if reason is None else {ref.encode(): reason}) + + def test_listed_tag_moves(self): + # Without create_only a covered tag may move, as a covered branch may. + rules = [GrantV2PushRule(repository=5, tags=["latest"])] + assert refused_updates(rules, request_for((OLD_ID, NEW_ID, "refs/tags/latest"))) == {} + + def test_one_rule_for_branches_and_tags(self): + rules = [GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], tags=["seer-1"], create_only=True)] + request = request_for((ZERO_ID, NEW_ID, "refs/heads/seer/fix"), (ZERO_ID, NEW_ID, "refs/tags/seer-1")) + assert refused_updates(rules, request) == {} + def test_any_rule_allows_an_update(self): # The agent may start branches under seer/ and keep pushing to the one Seer added after its first push. rules = [ @@ -518,18 +550,23 @@ def test_body_that_ends_early(self): self.check(body[:20]) def test_long_ref_is_cut_short_in_the_detail(self): - ref = "refs/tags/" + "x" * 1000 + ref = "refs/notes/" + "x" * 1000 with pytest.raises(PushRefused) as exc_info: self.check(make_push_body((OLD_ID, NEW_ID, ref), capabilities="report-status")) assert exc_info.value.detail == ( - f"The git grant does not allow this push: {ref[:200]}... (the git grant allows pushing branches only)." + f"The git grant does not allow this push: {ref[:200]}... " + "(the git grant allows pushing branches and tags only)." ) # The report names the whole ref, since git matches it to the ref it pushed. assert ref.encode() in (exc_info.value.report or b"") - @pytest.mark.parametrize("ref", ["HEAD", "seer/fix", "refs/heads/" + "x" * 256], ids=["head", "bare", "too-long"]) + @pytest.mark.parametrize( + "ref", + ["HEAD", "seer/fix", "refs/heads/" + "x" * 256, "refs/tags/" + "x" * 256, "refs/tags/-v1", "refs/tags/HEAD"], + ids=["head", "bare", "long-branch", "long-tag", "dash-tag", "head-tag"], + ) def test_ref_git_would_not_send(self, ref): - # git sends refs under refs/, and a branch name past the cap would make a rule's pattern slow. + # git sends refs under refs/, and a branch or tag name past the cap would make a rule's pattern slow. with pytest.raises(GitMalformedRequestBody): self.check(make_push_body((OLD_ID, NEW_ID, ref))) diff --git a/tests/unit/test_git_relay.py b/tests/unit/test_git_relay.py index 57c39c95..faa4611b 100644 --- a/tests/unit/test_git_relay.py +++ b/tests/unit/test_git_relay.py @@ -799,10 +799,15 @@ def test_fetch_rules_that_all_list_branches_are_refused(self): authorize_request(grant, 5, "POST", "git-upload-pack", "") @pytest.mark.parametrize("route", FETCH_ROUTES) - def test_fetch_rule_that_lists_branches_is_refused(self, route): - # Nothing reads a fetch's body, so nothing would keep the fetch to the rule's branches. - with pytest.raises(GitOperationNotGranted, match="cannot keep a fetch to a rule's branches"): - authorize_request(make_grant(rules=[GrantV2FetchRule(repository=5, branches=["main"])]), 5, *route) + @pytest.mark.parametrize( + "refs", + [{"branches": ["main"]}, {"tags": ["v1"]}, {"tag_patterns": ["v.+"]}], + ids=["branches", "tags", "tag-patterns"], + ) + def test_fetch_rule_that_names_refs_is_refused(self, route, refs): + # Nothing reads a fetch's body, so nothing would keep the fetch to the rule's branches and tags. + with pytest.raises(GitOperationNotGranted, match="cannot keep a fetch to a rule's branches and tags"): + authorize_request(make_grant(rules=[GrantV2FetchRule(repository=5, **refs)]), 5, *route) @pytest.mark.parametrize( "method,route,query", diff --git a/tests/unit/test_git_server.py b/tests/unit/test_git_server.py index 8f2ab711..69f82f66 100644 --- a/tests/unit/test_git_server.py +++ b/tests/unit/test_git_server.py @@ -823,7 +823,8 @@ def test_unlisted_branch_gets_a_report(self): @pytest.mark.parametrize( "ref,old_id,new_id,reason", [ - ("refs/tags/v1", OLD_ID, NEW_ID, "allows pushing branches only"), + ("refs/tags/v1", OLD_ID, NEW_ID, "does not cover this tag"), + ("refs/notes/commits", OLD_ID, NEW_ID, "allows pushing branches and tags only"), ("refs/heads/seer/fix", OLD_ID, "0" * 40, "does not allow deleting a branch"), ], ) From 3a2ed59a752efedc18335fa7804c7e425fb5ef81 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 10 Nov 2026 09:52:08 +0100 Subject: [PATCH 18/23] test(git): Show a digit class in a ref pattern covering non-ASCII digits A rule's pattern runs as a Unicode regular expression, so \d also matches digits that render like ASCII ones. The tests fail until a pattern covers only what its author meant. Co-Authored-By: Claude Fable 5.2 --- tests/unit/test_git_push_adversarial_3.py | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/unit/test_git_push_adversarial_3.py diff --git a/tests/unit/test_git_push_adversarial_3.py b/tests/unit/test_git_push_adversarial_3.py new file mode 100644 index 00000000..b4bcc5f7 --- /dev/null +++ b/tests/unit/test_git_push_adversarial_3.py @@ -0,0 +1,29 @@ +"""Tag pushes built to slip past the push check. Each test fails while the check lets its push through.""" + +import io + +import pytest + +from scm.git.push import PushRefused, check_push +from scm.git.types import GrantV2PushRule +from tests.unit.git_builders import NEW_ID, make_push_body + +ZERO_ID = "0" * 40 + + +class TestPatternDigits: + @pytest.mark.parametrize( + ("field", "ref"), + [ + ("tag_patterns", "refs/tags/v1.0"), + ("tag_patterns", "refs/tags/v١.٠"), + ("branch_patterns", "refs/heads/v1.0"), + ], + ids=["fullwidth-tag", "arabic-indic-tag", "fullwidth-branch"], + ) + def test_digit_class_does_not_cover_a_lookalike_of_a_release(self, field, ref): + # Python's \d matches every Unicode decimal digit, and a ref's name may hold any printable character, so a + # rule written for v1.0 also lets its holder create a tag that renders like v1.0 and is a different ref. + rules = [GrantV2PushRule(repository=5, create_only=True, **{field: [r"v\d+\.\d+"]})] + with pytest.raises(PushRefused): + check_push(rules, {}, io.BytesIO(make_push_body((ZERO_ID, NEW_ID, ref)))) From 8c3b33fdd46248333e658eb0f146294a16b7ab77 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 10 Nov 2026 09:54:12 +0100 Subject: [PATCH 19/23] fix(git): Run rule patterns with ASCII character classes A pattern such as v\d+ also matched fullwidth and other non-ASCII digits, so a release rule let the holder create a tag that renders like a covered one but is a different ref. Patterns now compile and match with re.ASCII. The push rule's docs also ask signers to keep tag patterns to a release shape, since a tag named after a branch redirects whoever resolves that name. Co-Authored-By: Claude Opus 5.5 --- src/scm/git/types.py | 16 +++++++++------- tests/unit/test_git_grant.py | 20 ++++++++++++++++++++ tests/unit/test_git_push_adversarial_3.py | 4 +--- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/scm/git/types.py b/src/scm/git/types.py index df7b9313..836f3f88 100644 --- a/src/scm/git/types.py +++ b/src/scm/git/types.py @@ -40,8 +40,9 @@ class _GrantV2RefRule(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_ """Branch names as ``git branch`` takes them, without ``refs/heads/``.""" branch_patterns: list[str] | None = None """Python regular expressions, each matched against a branch's whole name without ``refs/heads/``, such as - ``seer/.+``. The pushing client chooses the names they are matched against, so a pattern must not be one that - backtracks catastrophically.""" + ``seer/.+``. They run with ``re.ASCII``, so ``\\d`` and ``\\w`` don't match lookalikes such as fullwidth digits. The + pushing client chooses the names they are matched against, so a pattern must not be one that backtracks + catastrophically.""" tags: list[str] | None = None """Tag names as ``git tag`` takes them, without ``refs/tags/``.""" tag_patterns: list[str] | None = None @@ -76,7 +77,7 @@ def covers(self, ref: str) -> bool: name, patterns = ref.removeprefix("refs/tags/"), self.tag_patterns else: return False - return any(re.fullmatch(pattern, name) for pattern in patterns or ()) + return any(re.fullmatch(pattern, name, re.ASCII) for pattern in patterns or ()) def _names_refs(self) -> bool: return any(field is not None for field in (self.branches, self.branch_patterns, self.tags, self.tag_patterns)) @@ -117,8 +118,9 @@ class GrantV2PushRule(_GrantV2RefRule, frozen=True, tag="push", tag_field="opera covered branch or tag to any commit, so a force push passes. Don't cover the repository's default branch, by name or by pattern. The git endpoint doesn't know which branch - that is, so keeping it out is the signer's job, with the provider's branch protection behind it. Cover tags with - the same care, since a new tag often starts a release pipeline. + that is, so keeping it out is the signer's job, with the provider's branch protection behind it. Keep tag patterns + to a release shape such as ``v[0-9]+\\.[0-9]+\\.[0-9]+``: git resolves a short name to a tag before a branch, so a + tag named ``main`` redirects whoever fetches or resolves ``main``, and a new tag often starts a release pipeline. """ operation: ClassVar[GitOperation] = "push" @@ -216,8 +218,8 @@ def _check_patterns(field: str, patterns: list[str]) -> None: if not pattern: raise ValueError(f"A pattern in a rule's {field} must not be empty.") try: - re.compile(pattern) - except re.error as e: + re.compile(pattern, re.ASCII) + except (re.error, ValueError) as e: raise ValueError(f"{pattern!r} is not a regular expression: {e}") from None diff --git a/tests/unit/test_git_grant.py b/tests/unit/test_git_grant.py index cf19a9ca..3a63444f 100644 --- a/tests/unit/test_git_grant.py +++ b/tests/unit/test_git_grant.py @@ -786,6 +786,26 @@ def test_any_object_covers_every_ref(self): assert rule.covers("refs/heads/main") assert rule.covers("refs/pull/1/head") + @pytest.mark.parametrize( + "pattern,ref,covered", + [ + (r"v\d+", "refs/tags/v12", True), + (r"v\d+", "refs/tags/v12", False), + (r"seer/\w+", "refs/heads/seer/fix_1", True), + (r"seer/\w+", "refs/heads/seer/fïx", False), + # A character the pattern names itself still matches. + ("ünicode/.+", "refs/heads/ünicode/x", True), + (r"(?i)seer/.+", "refs/heads/SEER/x", True), + ], + ) + def test_patterns_run_with_ascii_classes(self, pattern, ref, covered): + field = "tag_patterns" if ref.startswith("refs/tags/") else "branch_patterns" + assert GrantV2PushRule(repository=5, **{field: [pattern]}).covers(ref) is covered + + def test_pattern_that_asks_for_unicode_classes_cannot_be_built(self): + with pytest.raises(ValueError, match="is not a regular expression"): + GrantV2PushRule(repository=5, tag_patterns=[r"(?u)v\d+"]) + @pytest.mark.parametrize("field", ["branch_patterns", "tag_patterns"]) def test_cannot_be_built_with_an_invalid_pattern(self, field): with pytest.raises(ValueError, match="'seer/\\(' is not a regular expression"): diff --git a/tests/unit/test_git_push_adversarial_3.py b/tests/unit/test_git_push_adversarial_3.py index b4bcc5f7..6da9da59 100644 --- a/tests/unit/test_git_push_adversarial_3.py +++ b/tests/unit/test_git_push_adversarial_3.py @@ -6,9 +6,7 @@ from scm.git.push import PushRefused, check_push from scm.git.types import GrantV2PushRule -from tests.unit.git_builders import NEW_ID, make_push_body - -ZERO_ID = "0" * 40 +from tests.unit.git_builders import NEW_ID, ZERO_ID, make_push_body class TestPatternDigits: From 74bb772f167992947c498b096abd993706e2e3b1 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 10 Nov 2026 10:05:34 +0100 Subject: [PATCH 20/23] test(git): Check that the provider keeps refs apart from case variants A provider that takes two names differing only in case for one ref would let a rule covering the variant move the ref, and a create_only rule create the variant over it. bin/git-server --check --push now creates a case variant of the pushed branch and tag, then moves the original from its known commit, which fails where the provider resolves the original to the variant. Co-Authored-By: Claude Opus 5.5 --- bin/git-server | 44 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/bin/git-server b/bin/git-server index 1a075b60..078185ba 100755 --- a/bin/git-server +++ b/bin/git-server @@ -72,10 +72,11 @@ Checks: With --push it also commits a 256 KiB file, pushes it with a push grant for a new branch, sending the pack chunked as git does for a large push, then tags the commit under the same grant. It checks that the grant can neither - push another branch or tag nor delete its own, and that the provider - refuses a forged create of the existing branch and tag under a create_only - rule. It then deletes the branch and tag with the provider token. This - writes to the repository, so point it at a test repository. + push another branch or tag nor delete its own, that the provider refuses a + forged create of the existing branch and tag under a create_only rule, and + that it keeps each apart from a name that differs only in case. It then + deletes the branch, the tag and their case variants with the provider + token. This writes to the repository, so point it at a test repository. git runs without the user's or the system's git config, so credential helpers and URL rewrites cannot hide a failure. @@ -611,7 +612,9 @@ class Checks: ) if pushed: tip = self.git(push_grant, "rev-parse", "HEAD", cwd=clone).stdout.strip() - self.check_pushed_ref(url, push_grant, clone, f"refs/heads/{branch}", tip) + # The commit the clone started from, which the provider has on the default branch. + base = self.git(push_grant, "rev-parse", "HEAD~1", cwd=clone).stdout.strip() + self.check_pushed_ref(url, push_grant, clone, f"refs/heads/{branch}", tip, base) tag = f"refs/tags/{branch}-tag" self.expect_rejection( "push to a tag the grant does not list is rejected", @@ -621,12 +624,12 @@ class Checks: if self.expect_success( "push a tag with the push grant", self.git(push_grant, "push", url, f"HEAD:{tag}", cwd=clone) ): - self.check_pushed_ref(url, push_grant, clone, tag, tip) + self.check_pushed_ref(url, push_grant, clone, tag, tip, base) server.shutdown() - def check_pushed_ref(self, url: str, push_grant: str, clone: Path, ref: str, tip: str) -> None: + def check_pushed_ref(self, url: str, push_grant: str, clone: Path, ref: str, tip: str, base: str) -> None: """Check that ``ref``, which the push grant created at ``tip``, can be neither forged over nor deleted, then - delete it with the token.""" + delete it with the token. ``base`` is another commit the provider has.""" kind = "branch" if ref.startswith("refs/heads/") else "tag" response = self.forge_create(url, ref, tip) self.check( @@ -634,6 +637,7 @@ class Checks: subprocess.CompletedProcess([], response.status_code, response.text, ""), response.status_code == 200 and f"ng {ref}".encode() in response.content, ) + self.check_case_variant(url, push_grant, clone, ref, tip, base) self.expect_rejection( f"deleting the pushed {kind} with the push grant is rejected", self.git(push_grant, "push", url, f":{ref}", cwd=clone), @@ -641,6 +645,30 @@ class Checks: ) self.expect_success(f"delete the pushed {kind} {ref} with the token", self.delete_directly(ref, clone)) + def check_case_variant(self, url: str, push_grant: str, clone: Path, ref: str, tip: str, base: str) -> None: + """Check that the provider keeps ``ref`` apart from a name that differs from it only in case. + + A provider that takes the two for one ref would let a rule covering the variant move ``ref``, and a + create_only rule create the variant over it. A listing alone can't show that: git on a case-insensitive + filesystem lists both names but may resolve ``ref`` to the variant, and then refuses to move ``ref`` from + ``tip``. So the check moves ``ref`` to ``base`` after creating the variant there. + """ + kind, prefix = ("branch", "refs/heads/") if ref.startswith("refs/heads/") else ("tag", "refs/tags/") + variant = prefix + ref.removeprefix(prefix).upper() + assert variant != ref, f"{ref} has no letters to change the case of" + response = self.forge_create(url, variant, base) + listed = self.git(fetch_grant_for(self.args.private_key, self.backend.repository_id), "ls-remote", url, ref) + moved = self.git(push_grant, "push", "--force", url, f"{base}:{ref}", cwd=clone) + self.check( + f"the provider keeps a {kind} apart from a name that differs only in case", + subprocess.CompletedProcess([], moved.returncode, f"{response.text}\n{listed.stdout}", moved.stderr), + listed.stdout.strip() == f"{tip}\t{ref}" and moved.returncode == 0, + ) + if response.status_code == 200 and f"ok {variant}".encode() in response.content: + self.expect_success( + f"delete the case variant {variant} with the token", self.delete_directly(variant, clone) + ) + def forge_create(self, url: str, ref: str, tip: str) -> requests.Response: """Push ``ref`` to ``tip`` as a new ref under a create_only rule, though it exists already. From 9c0915ae49c1d6ae5a18eb3e79763973a34eb69d Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 10 Nov 2026 13:23:50 +0100 Subject: [PATCH 21/23] docs(git): Describe the push check's tests by what they guard The adversarial tests' comments described the bugs they caught in the present tense, so they read as if the check still had them. They now say what each push would do and why it's refused, and drop the review's commit and round. A fetch rule that names refs is now told to set any_object, and comments no longer narrate when code goes away. Co-Authored-By: Claude Opus 5.5 --- src/scm/git/push.py | 3 +- src/scm/git/relay.py | 4 +-- src/scm/git/server.py | 2 +- tests/unit/test_git_grant.py | 2 +- tests/unit/test_git_push_adversarial.py | 29 +++++++-------- tests/unit/test_git_push_adversarial_2.py | 44 +++++++++-------------- tests/unit/test_git_push_adversarial_3.py | 6 ++-- tests/unit/test_git_relay.py | 2 +- 8 files changed, 37 insertions(+), 55 deletions(-) diff --git a/src/scm/git/push.py b/src/scm/git/push.py index 99151383..2ca47551 100644 --- a/src/scm/git/push.py +++ b/src/scm/git/push.py @@ -363,8 +363,7 @@ def _ref_update(line: bytes) -> RefUpdate: if not (_OBJECT_ID.fullmatch(old_id) and _OBJECT_ID.fullmatch(new_id) and len(old_id) == len(new_id) and ref): raise GitMalformedRequestBody(detail="The push's ref updates are malformed.") # A rule checks the ref the push names, and a name git wouldn't take might not be the ref the provider updates. - # It also goes into the refusal's detail, which reaches logs, so it must be printable too, which keeps out - # invisible and right-to-left characters that make one ref name look like another. + # The name also reaches logs, where invisible and right-to-left characters can make one ref look like another. try: name = ref.decode() except UnicodeDecodeError: diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py index 0d8ece47..125b40a8 100644 --- a/src/scm/git/relay.py +++ b/src/scm/git/relay.py @@ -120,10 +120,10 @@ def _authorize_v2(grant: GrantV2, repository_id: RepositoryId, operation: GitOpe if not grant.rules_for(repository_id, other): raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") raise GitOperationNotGranted(detail=f"The git grant allows {other} on this repository, not {operation}.") - # Delete this once GitServer keeps a fetch to its rule's branches and tags. if operation == "fetch" and not any(isinstance(rule, GrantV2FetchRule) and rule.any_object for rule in rules): raise GitOperationNotGranted( - detail="The git endpoint cannot keep a fetch to a rule's branches and tags yet, so it refuses the rule." + detail="The git endpoint cannot keep a fetch to a rule's branches and tags, so a fetch rule must set " + "any_object." ) diff --git a/src/scm/git/server.py b/src/scm/git/server.py index 699e0508..e034afa9 100644 --- a/src/scm/git/server.py +++ b/src/scm/git/server.py @@ -65,7 +65,7 @@ class GitServer: doesn't recognise, such as push options, a signed push or a capability git doesn't send, refuses the push too. A client that asked for a status report gets one, a 200 that git shows as a rejected ref with its reason. A push the rules allow is relayed with the updates the server read followed by the rest of the body, unread. A push - under a ``GrantV1`` is relayed unread, as before rules. + under a ``GrantV1`` is relayed unread. The repository's provider says where its git endpoint is, through ``GetGitRemoteProtocol``. A repository whose provider has no git remote is refused. diff --git a/tests/unit/test_git_grant.py b/tests/unit/test_git_grant.py index 3a63444f..49b8d26e 100644 --- a/tests/unit/test_git_grant.py +++ b/tests/unit/test_git_grant.py @@ -881,7 +881,7 @@ def test_token_is_a_jwt_with_the_grants_claims(self): } def test_without_a_version(self): - # Read as version 1, as before there was a second one. + # version defaults to 1, so a grant that leaves it out still reads as version 1. claims = claims_of(make_v1_grant()) del claims["version"] assert verify_grant([KEY.public], sign_claims(claims), now=NOW) == make_v1_grant() diff --git a/tests/unit/test_git_push_adversarial.py b/tests/unit/test_git_push_adversarial.py index 8ffb10ca..df4f98ae 100644 --- a/tests/unit/test_git_push_adversarial.py +++ b/tests/unit/test_git_push_adversarial.py @@ -1,4 +1,4 @@ -"""Pushes built to slip past the push check. Each test fails while the check lets its push through.""" +"""Pushes built to slip past the push check, each of which it must refuse.""" import io import time @@ -32,9 +32,8 @@ class TestCapabilityValues: ids=["nul-in-agent", "nul-in-session-id", "control-in-agent", "escape-in-session-id"], ) def test_value_git_would_not_send_is_refused(self, capabilities): - # git sanitizes agent= to printable characters other than space and ends the line at its first NUL, but the - # allowlist checks only a value's name, so a NUL or control byte rides along in an allowed capability. git's - # receive-pack stops reading at the NUL. A receive-pack that reads the whole pkt-line would see push-options. + # git sends capability values as printable ASCII. A NUL or control byte in an allowed one can hide a + # capability such as push-options from the check, while a receive-pack that reads the whole line sees it. body = first_update(capabilities) + b"0000PACK" with pytest.raises((GitOperationNotGranted, GitMalformedRequestBody)): check_push(RULES, {}, io.BytesIO(body)) @@ -55,8 +54,8 @@ def long_capability_push() -> bytes: class TestRefusalSize: def test_report_does_not_repeat_a_long_capability_name_per_update(self): - # Each update's "ng" line carries the reason, and the reason quotes the capability whole. 64 KiB in makes - # about 12 MB out, built in memory and streamed back. + # Every refused ref's "ng" line repeats the reason, so a reason that quoted a long capability whole would + # answer a small push with megabytes. body = long_capability_push() with pytest.raises(PushRefused) as exc_info: check_push(RULES, {}, io.BytesIO(body)) @@ -64,8 +63,7 @@ def test_report_does_not_repeat_a_long_capability_name_per_update(self): assert len(exc_info.value.report) <= 2 * len(body) def test_detail_cuts_a_long_capability_name_short(self): - # _SHOWN_REF_LENGTH keeps a client-chosen ref short in the detail, which reaches logs and Sentry issues. The - # capability's name is client-chosen too and goes in whole. + # The client chooses the capability's name, and the detail reaches logs and Sentry issues. with pytest.raises(PushRefused) as exc_info: check_push(RULES, {}, io.BytesIO(long_capability_push())) assert len(exc_info.value.detail) <= 1000 @@ -78,8 +76,7 @@ class TestRefusalDetail: ids=["newline", "escape", "carriage-return"], ) def test_detail_holds_no_control_characters(self, ref): - # Only refs under refs/heads/ are checked against git's ref name rules, so any other ref reaches the detail - # with whatever bytes the client chose. git refuses such a name as a "funny refname" and never sends one. + # git refuses such a name as a "funny refname" and never sends one, and the detail reaches logs. body = first_update(b"report-status", ref=ref) + b"0000" with pytest.raises((GitOperationNotGranted, GitMalformedRequestBody)) as exc_info: check_push(RULES, {}, io.BytesIO(body)) @@ -89,8 +86,8 @@ def test_detail_holds_no_control_characters(self, ref): class TestPatternCost: def test_long_branch_name_does_not_pin_the_worker(self): - # A rule's pattern runs against a branch name of up to 64 KiB. An ordinary-looking pattern with three - # wildcards is cubic in the name's length: 1,500 characters take seconds, and 60,000 would take days. + # An ordinary-looking pattern with three wildcards is cubic in the name's length: 1,500 characters take + # seconds, and 60,000 would take days. rules = [GrantV2PushRule(repository=5, branch_patterns=["seer/.+-.+-.+-[0-9]+"])] body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/" + "-" * 1500 + "x")) started = time.perf_counter() @@ -101,9 +98,8 @@ def test_long_branch_name_does_not_pin_the_worker(self): class TestHeaders: def test_second_content_encoding_header_is_not_sent_upstream_unchecked(self): - # The check reads the first Content-Encoding it finds, and the relay's CaseInsensitiveDict keeps the last, so - # a mapping that holds both spellings has the body read as plain and sent upstream marked gzip. Sentry's - # caller passes dict(request.headers), which can't hold both. + # A mapping can hold both spellings and the relay passes on only one, so a body checked as plain could go + # upstream marked gzip. body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")) with running_upstream() as upstream: recorder = Recorder( @@ -121,8 +117,7 @@ def test_second_content_encoding_header_is_not_sent_upstream_unchecked(self): assert [r.headers.get("Content-Encoding") for r in upstream.requests] in ([], ["identity"]) def test_git_protocol_git_does_not_send_on_a_push(self): - # git sends no Git-Protocol header on a push, or version=1, since push has no protocol v2. Any other value - # goes to the provider unchecked. + # git sends no Git-Protocol header on a push, or version=1, since a push has no protocol v2. body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")) with running_upstream() as upstream: recorder = Recorder( diff --git a/tests/unit/test_git_push_adversarial_2.py b/tests/unit/test_git_push_adversarial_2.py index 43e42ba0..39c787b8 100644 --- a/tests/unit/test_git_push_adversarial_2.py +++ b/tests/unit/test_git_push_adversarial_2.py @@ -1,5 +1,4 @@ -"""A second round of pushes built to slip past the push check, against a3fbc3c. Each test fails while the check lets -its input through.""" +"""More pushes built to slip past the push check, each of which it must refuse.""" import io @@ -34,11 +33,11 @@ def send_push(upstream, headers: dict[str, str]) -> tuple[Recorder, int]: class TestHeaderValuesAsRelayed: - """_check_headers compares ``value.strip()`` with git's values, and the relay sends ``value`` on as it came. + """Header values are checked as the relay sends them on, not stripped. str.strip() removes more than the spaces and tabs an HTTP server trims: U+00A0 and U+0085, which reach a WSGI - application as the header bytes 0xA0 and 0x85, and the controls 0x0B, 0x0C and 0x1C to 0x1F. So a value git never - sends passes the check and reaches the provider. + application as the header bytes 0xA0 and 0x85, and the controls 0x0B, 0x0C and 0x1C to 0x1F. So a check of + stripped values would pass values git never sends. """ @pytest.mark.parametrize( @@ -70,9 +69,8 @@ def test_padded_git_protocol_never_reaches_the_provider(self, value): ids=["git-protocol", "accept"], ) def test_value_padded_in_front_is_refused_as_the_clients_fault(self, headers): - # It passes the check, then requests refuses to send a header value that starts with whitespace. That - # surfaces as resource_bad_gateway, a 502 that says the provider failed and that a client retries, after the - # repository was looked up and a token fetched for a request the check should have refused. + # requests refuses to send a value that starts with whitespace, so letting it past the check would answer the + # client's mistake with a 502 that blames the provider, after fetching a token. with running_upstream() as upstream: recorder, status = send_push(upstream, headers) assert recorder.error_codes == ["rpc_malformed_request_headers"] @@ -94,20 +92,16 @@ class TestRefusalDetailUnicode: ids=["line-separator", "paragraph-separator", "next-line", "c1-csi", "right-to-left-override", "zero-width"], ) def test_detail_holds_only_printable_characters(self, ref): - # _NOT_IN_A_REF keeps ASCII control characters out of a ref, as git does, but git takes any byte from 0x80 - # up. So a ref that decodes to a C1 control, a Unicode line or paragraph separator (str.splitlines() splits - # on U+0085, U+2028 and U+2029) or a bidirectional override passes as a name and lands in the refusal's - # detail, which goes to logs and to a Sentry issue's title. + # git takes any byte from 0x80 up in a ref, so a name can decode to a line separator, a C1 control or a + # bidirectional override, and the detail goes to logs and to a Sentry issue's title. body = pkt(f"{OLD_ID} {NEW_ID} {ref}".encode() + b"\0report-status") + b"0000" with pytest.raises((GitOperationNotGranted, GitMalformedRequestBody)) as exc_info: check_push(RULES, {}, io.BytesIO(body)) assert exc_info.value.detail.isprintable(), ascii(exc_info.value.detail) def test_route_in_the_detail_is_printable_and_cut_short(self): - # The route is the URL path below .git/, percent-decoded by the caller's web server, and - # GitRouteNotSupported quotes it whole. Sentry's URL pattern takes any character but a newline there, so an - # escape or a carriage return and as many bytes as a URL holds reach the same logs a ref's name is kept - # short and clean for. + # The caller's web server percent-decodes the route, so it can hold escapes, carriage returns and as many + # bytes as a URL holds, and GitRouteNotSupported's detail reaches the same logs as a ref's name. recorder = Recorder() route = "git-receive-pack\x1b[2J\rERROR forged log line " + "x" * 5000 response = request(make_server(recorder), PUSH_GRANT, method="POST", route=route, query="") @@ -119,10 +113,8 @@ def test_route_in_the_detail_is_printable_and_cut_short(self): class TestShallowClone: def test_push_from_a_clone_with_many_shallow_commits(self): - # git lists every shallow commit of the clone ahead of the updates of every push it makes, 53 bytes each - # with SHA-1. MAX_UPDATES_LENGTH counts them, so a push of one branch from a clone with more than about - # 1,230 shallow commits is refused as malformed. `git clone --depth=1 --no-single-branch` of a repository - # with 1,400 branches makes such a clone, and its push through GitServer fails with "RPC failed; HTTP 400". + # git lists every shallow commit ahead of a push's updates, so a depth-1 clone of 1,400 branches sends about + # 74 KB of them, more than the updates' own budget. shallow = tuple(f"{index:040x}" for index in range(1, 1401)) body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix"), shallow=shallow) assert check_push(RULES, {}, io.BytesIO(body)).read() == body @@ -138,9 +130,8 @@ class TestRefAdvertisementOfAPush: ids=["git-protocol", "content-encoding"], ) def test_header_value_git_does_not_send_stays_behind(self, name, value, sent_by_git): - # check_push runs on the POST only. A push's first request, GET info/refs?service=git-receive-pack, is - # authorized by the same push rule and relayed with whatever the client put in the headers the POST checks. - # git asks for no protocol version there, or for version 1, and sends no Content-Encoding on a GET. + # A push's first request, GET info/refs?service=git-receive-pack, goes to the provider with the same headers. + # git asks for no protocol version there, or for version 1, and sends no Content-Encoding. with running_upstream() as upstream: recorder = Recorder( repository=make_repository(provider_name="github_enterprise", web_base_url=upstream.url) @@ -178,11 +169,8 @@ def read(self, size: int = -1, /) -> bytes: class TestDrain: def test_refused_push_is_not_read_without_bound(self): - # A refused push is read to its end so that git reads the answer. Nothing bounds that read: a client that - # holds any push grant sends four bytes that are no pkt-line and then streams for as long as it likes, and - # the worker reads along with no provider on the other side to cut it off. 8 GiB is past any provider's - # push limit (GitHub's is 2 GiB), so nothing a provider would have taken needs reading that far. Sentry's - # caller buffers the body before it calls GitServer, so this bites only a caller that streams. + # A refused push is read to its end so that git reads the answer, and a client that never stops would hold + # the worker. 8 GiB is past any provider's push limit, so nothing a provider would take needs reading that far. give_up_after = 8 * 1024**3 body = Endless(b"zzzz", give_up_after) with pytest.raises(GitMalformedRequestBody): diff --git a/tests/unit/test_git_push_adversarial_3.py b/tests/unit/test_git_push_adversarial_3.py index 6da9da59..f3d2124b 100644 --- a/tests/unit/test_git_push_adversarial_3.py +++ b/tests/unit/test_git_push_adversarial_3.py @@ -1,4 +1,4 @@ -"""Tag pushes built to slip past the push check. Each test fails while the check lets its push through.""" +"""Tag pushes built to slip past the push check, each of which it must refuse.""" import io @@ -20,8 +20,8 @@ class TestPatternDigits: ids=["fullwidth-tag", "arabic-indic-tag", "fullwidth-branch"], ) def test_digit_class_does_not_cover_a_lookalike_of_a_release(self, field, ref): - # Python's \d matches every Unicode decimal digit, and a ref's name may hold any printable character, so a - # rule written for v1.0 also lets its holder create a tag that renders like v1.0 and is a different ref. + # Without re.ASCII, \d matches every Unicode decimal digit, so a rule written for v1.0 would let its holder + # create a tag that renders like v1.0 and is a different ref. rules = [GrantV2PushRule(repository=5, create_only=True, **{field: [r"v\d+\.\d+"]})] with pytest.raises(PushRefused): check_push(rules, {}, io.BytesIO(make_push_body((ZERO_ID, NEW_ID, ref)))) diff --git a/tests/unit/test_git_relay.py b/tests/unit/test_git_relay.py index faa4611b..cb080112 100644 --- a/tests/unit/test_git_relay.py +++ b/tests/unit/test_git_relay.py @@ -760,7 +760,7 @@ def test_push_rule_lists_refs(self): assert authorize_request(make_grant(rules=[PUSH_BRANCH]), 5, *PUSH_ROUTES[0]) == "push" def test_push_under_rules_is_refused_outside_gitserver(self): - # Only the push's ref updates show which branches it touches, and this doesn't read them. + # Only the push's ref updates show which refs it touches, and this doesn't read them. with pytest.raises(GitOperationNotGranted, match="goes through GitServer"): authorize_request(make_grant(rules=[PUSH_BRANCH]), 5, *PUSH_ROUTES[1]) From 6e104a4270bf77d3026ef5970a52ef70886e06f2 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 11 Nov 2026 07:51:53 +0100 Subject: [PATCH 22/23] feat(git)!: Accept only version 2 git grants No version 1 grant is in use yet, since the endpoint's feature flag hasn't been enabled, so keeping it deprecated only kept dead code. A version 1 push grant lets a push update any ref, so the verifier now refuses every grant that isn't version 2, naming the version as the reason. GrantV1, its Grant alias and GRANT_VERSION are gone, and verify_grant returns a GrantV2. Co-Authored-By: Claude Opus 5.5 --- src/scm/git/grant.py | 22 ++++----- src/scm/git/relay.py | 60 +++++++++---------------- src/scm/git/server.py | 15 +++---- src/scm/git/types.py | 42 +----------------- tests/unit/git_builders.py | 26 +++++------ tests/unit/test_git_grant.py | 84 +++++++---------------------------- tests/unit/test_git_relay.py | 33 +------------- tests/unit/test_git_server.py | 18 ++++---- 8 files changed, 78 insertions(+), 222 deletions(-) diff --git a/src/scm/git/grant.py b/src/scm/git/grant.py index b188e1cf..59b055e1 100644 --- a/src/scm/git/grant.py +++ b/src/scm/git/grant.py @@ -37,7 +37,7 @@ ) from scm.errors import GitGrantExpired, GitGrantKeyError, GitInvalidGrant -from scm.git.types import GRANT_MAX_LENGTH, GrantV1, GrantV2 +from scm.git.types import GRANT_MAX_LENGTH, GrantV2 from scm.secret import Secret GRANT_AUDIENCE = "scm-git-grant" @@ -48,7 +48,7 @@ _JWT_CLAIMS = frozenset({"aud", "exp", "iat"}) -def sign_grant(private_key: Secret, grant: GrantV1 | GrantV2, *, now: float | None = None) -> str: +def sign_grant(private_key: Secret, grant: GrantV2, *, now: float | None = None) -> str: """Sign ``grant`` with ``private_key``, an Ed25519 private key in PEM. The ``kid`` header is derived from the key, so a signer needs no key id setting next to it. Raises @@ -111,10 +111,10 @@ def require_private_key(private_key: Secret) -> Secret: return private_key -def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = None) -> GrantV1 | GrantV2: +def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = None) -> GrantV2: """Return the grant if the private key of one of ``public_keys`` signed it and it has not expired. - The grant is a ``GrantV2`` if its version is 2, and a ``GrantV1`` otherwise. + Only version 2 grants verify. A version 1 grant, which lets a push update any ref, is refused. Verification takes a list of Ed25519 public keys in PEM, so a new key can be added before the signer switches to it and the old one removed after its grants expire. Raises GitGrantKeyError, rather than refusing the grant, if @@ -158,22 +158,18 @@ def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = fields = {name: value for name, value in claims.items() if name not in _JWT_CLAIMS} payload = {**fields, "expires_at": claims["exp"]} + # Checked first, so a version 1 grant is told its version is the problem rather than its first unknown field. + if fields.get("version") != 2: + raise GitInvalidGrant(detail="The git grant is not version 2, the only version the git endpoint accepts.") try: - grant: GrantV1 | GrantV2 - if fields.get("version") == 2: - grant = msgspec.convert(payload, type=GrantV2, strict=True) - else: - # GrantV1's version field refuses any version but 1. - grant = msgspec.convert(payload, type=GrantV1, strict=True) + grant = msgspec.convert(payload, type=GrantV2, strict=True) except msgspec.ValidationError as e: - # The signature matched, so the holder of the private key wrote these claims; telling them why they are + # The signature matched, so the holder of the private key wrote these claims, and telling them why they are # invalid gives nothing away. raise GitInvalidGrant(detail=f"The git grant's payload is invalid: {e}") from None if grant.expires_at <= now: raise GitGrantExpired(detail="The git grant has expired.") - if isinstance(grant, GrantV1) and not grant.repositories: - raise GitInvalidGrant(detail="The git grant names no repositories.") return grant diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py index 125b40a8..60441d48 100644 --- a/src/scm/git/relay.py +++ b/src/scm/git/relay.py @@ -20,7 +20,7 @@ RpcMalformedRequestHeaders, SCMCodedError, ) -from scm.git.types import GitOperation, GrantV1, GrantV2, GrantV2FetchRule, Readable +from scm.git.types import GitOperation, GrantV2, GrantV2FetchRule, Readable from scm.rpc.types import StreamResponse from scm.secret import Secret from scm.types import GitRemote, RepositoryId @@ -64,56 +64,26 @@ ) -def authorize_request( - grant: GrantV1 | GrantV2, repository_id: RepositoryId, method: str, route: str, query: str -) -> GitOperation: +def authorize_request(grant: GrantV2, repository_id: RepositoryId, method: str, route: str, query: str) -> GitOperation: """Return the git operation a request belongs to, refusing the request unless the grant allows it. - Fetches reach only upload-pack and pushes only receive-pack. The grant must name the repository in the same form - the request uses: a database id does not match the same repository's ``(provider, external id)`` pair. + Fetches reach only upload-pack and pushes only receive-pack. A request runs only if the grant has a rule for its + repository and operation, and the rule must name the repository in the same form the request uses: a database id + does not match the same repository's ``(provider, external id)`` pair. - A ``GrantV2`` allows a request only if it has a rule for the request's repository and operation. A fetch needs a - fetch rule for any object, since nothing reads a fetch's body to keep it to a rule's branches and tags. A push - under a ``GrantV2`` is refused here, because only its ref updates show which refs it touches: relay it through + A fetch needs a fetch rule for any object, since nothing reads a fetch's body to keep it to a rule's branches and + tags. A push is refused here, because only its ref updates show which refs it touches: relay it through GitServer, which reads them before it relays the push. """ operation = _authorize(grant, repository_id, method, route, query) - if isinstance(grant, GrantV2) and operation == "push" and method == "POST": - raise GitOperationNotGranted( - detail="A push under a git grant with rules goes through GitServer, which checks its ref updates." - ) + if operation == "push" and method == "POST": + raise GitOperationNotGranted(detail="A git push goes through GitServer, which checks its ref updates.") return operation -def _authorize( - grant: GrantV1 | GrantV2, repository_id: RepositoryId, method: str, route: str, query: str -) -> GitOperation: +def _authorize(grant: GrantV2, repository_id: RepositoryId, method: str, route: str, query: str) -> GitOperation: """``authorize_request`` for GitServer, which checks a push's ref updates against the grant's push rules itself.""" operation = _route_operation(method, route, query) - if isinstance(grant, GrantV1): - _authorize_v1(grant, repository_id, operation) - else: - _authorize_v2(grant, repository_id, operation) - return operation - - -def _route_operation(method: str, route: str, query: str) -> GitOperation: - operation = _ROUTES.get((method, route, query)) - if operation is None: - raise GitRouteNotSupported( - detail=f"{_printable(method, 20)} {_printable(route, 200)} is not a git fetch or push route." - ) - return operation - - -def _authorize_v1(grant: GrantV1, repository_id: RepositoryId, operation: GitOperation) -> None: - if operation != grant.operation: - raise GitOperationNotGranted(detail=f"The git grant allows {grant.operation}, not {operation}.") - if repository_id not in grant.repositories: - raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") - - -def _authorize_v2(grant: GrantV2, repository_id: RepositoryId, operation: GitOperation) -> None: rules = grant.rules_for(repository_id, operation) if not rules: other: GitOperation = "push" if operation == "fetch" else "fetch" @@ -125,6 +95,16 @@ def _authorize_v2(grant: GrantV2, repository_id: RepositoryId, operation: GitOpe detail="The git endpoint cannot keep a fetch to a rule's branches and tags, so a fetch rule must set " "any_object." ) + return operation + + +def _route_operation(method: str, route: str, query: str) -> GitOperation: + operation = _ROUTES.get((method, route, query)) + if operation is None: + raise GitRouteNotSupported( + detail=f"{_printable(method, 20)} {_printable(route, 200)} is not a git fetch or push route." + ) + return operation def _printable(text: str, length: int) -> str: diff --git a/src/scm/git/server.py b/src/scm/git/server.py index e034afa9..dedd2f96 100644 --- a/src/scm/git/server.py +++ b/src/scm/git/server.py @@ -5,7 +5,7 @@ from scm.git.grant import require_public_keys, verify_grant from scm.git.push import PushRefused, check_push, check_push_headers from scm.git.relay import GitRelay, ResponseBody, _authorize -from scm.git.types import GRANT_HEADER, GetToken, GrantV2, GrantV2PushRule, Readable +from scm.git.types import GRANT_HEADER, GetToken, GrantV2PushRule, Readable from scm.helpers import initialize_provider from scm.rpc.types import StreamResponse from scm.secret import Secret @@ -60,12 +60,11 @@ class GitServer: does not allow never reaches the database, the token supplier or the provider. Give it a relay whose adapter refuses internal addresses; see ``GitRelay``. - A push under a ``GrantV2`` is kept to its push rules by ``scm.git.push.check_push``, which reads the push's ref - updates before anything is looked up and refuses the whole push unless a rule allows every update. Anything it - doesn't recognise, such as push options, a signed push or a capability git doesn't send, refuses the push too. A - client that asked for a status report gets one, a 200 that git shows as a rejected ref with its reason. A push - the rules allow is relayed with the updates the server read followed by the rest of the body, unread. A push - under a ``GrantV1`` is relayed unread. + A push is kept to the grant's push rules by ``scm.git.push.check_push``, which reads the push's ref updates + before anything is looked up and refuses the whole push unless a rule allows every update. Anything it doesn't + recognise, such as push options, a signed push or a capability git doesn't send, refuses the push too. A client + that asked for a status report gets one, a 200 that git shows as a rejected ref with its reason. A push the + rules allow is relayed with the updates the server read followed by the rest of the body, unread. The repository's provider says where its git endpoint is, through ``GetGitRemoteProtocol``. A repository whose provider has no git remote is refused. @@ -145,7 +144,7 @@ def handle( operation = _authorize(grant, repository_id, method, route, query) context["operation"] = operation relayed_body: Readable | None = counted_body - if isinstance(grant, GrantV2) and operation == "push": + if operation == "push": if method == "POST": push_rules = [ rule for rule in grant.rules_for(repository_id, "push") if isinstance(rule, GrantV2PushRule) diff --git a/src/scm/git/types.py b/src/scm/git/types.py index 836f3f88..3dfd36a8 100644 --- a/src/scm/git/types.py +++ b/src/scm/git/types.py @@ -1,5 +1,4 @@ import re -import warnings from collections.abc import Callable from typing import ClassVar, Literal, Protocol @@ -9,11 +8,7 @@ from scm.types import BranchName, Referrer, Repository, RepositoryId type GitOperation = Literal["fetch", "push"] -"""What a grant or rule lets its holder do. ``scm.git.relay`` maps each to its git routes.""" - -GRANT_VERSION: Literal[1] = 1 -"""Deprecated: the version of ``GrantV1``, which its ``version`` field already holds. Remove it together with -``GrantV1``.""" +"""What a rule lets its holder do. ``scm.git.relay`` maps each to its git routes.""" GRANT_HEADER = "X-Scm-Git-Grant" """The request header that carries a signed grant to the git endpoint.""" @@ -134,39 +129,6 @@ def __post_init__(self) -> None: raise ValueError("A push rule must set branches, branch_patterns, tags or tag_patterns.") -@warnings.deprecated("GrantV1 is deprecated. Sign a GrantV2, whose rules can keep a push to branches and tags.") -class GrantV1(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): - """Permission to run one git operation against a fixed set of an organization's repositories. - - Deprecated: sign a ``GrantV2``, which can also keep a push to branches and tags. Verifiers still accept this - version, so a signer can switch once they can read both. - - A push grant lets its holder update any ref of its repositories, including tags and deletions, since GitServer - relays its pushes unread. Each repository is a ``RepositoryId`` under the grant's organization, as in the RPC - endpoint: Sentry's repository id or a ``(provider, external id)`` pair. - - A grant with a field this version does not know is refused rather than read without it. If a later version - adds a field that restricts a grant, a verifier that ignored the field would allow more than the grant does. - """ - - organization_id: int - referrer: Referrer - """Who the grant is for, e.g. a coding job or Seer itself, as ``referrer`` is elsewhere in this library. - Sentry tags the git endpoint's metrics with it. Seer sets it, because git requests carry nothing else that - tells callers apart.""" - operation: GitOperation - repositories: list[RepositoryId] - expires_at: int - """Unix time in seconds. Every grant expires.""" - version: Literal[1] = 1 - """The format version. A verifier refuses a version it does not know.""" - - -Grant = GrantV1 -"""The name ``GrantV1`` had before ``GrantV2``, kept so code that imports it keeps working. Remove it together with -``GrantV1`` rather than pointing it at ``GrantV2``.""" - - class GrantV2(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): """Permission to run git operations on an organization's repositories, as a list of rules. @@ -195,7 +157,7 @@ class GrantV2(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=T expires_at: int """Unix time in seconds. Every grant expires.""" version: Literal[2] = 2 - """The format version. A verifier refuses a version it does not know, so one that knows only ``GrantV1`` refuses + """The format version. A verifier refuses a version it does not know, so one that knows only version 1 refuses this one rather than reading its rules as broader than they are.""" def __post_init__(self) -> None: diff --git a/tests/unit/git_builders.py b/tests/unit/git_builders.py index bf323030..870f3adf 100644 --- a/tests/unit/git_builders.py +++ b/tests/unit/git_builders.py @@ -6,8 +6,8 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_private_key -from scm.git.grant import generate_private_key -from scm.git.types import GrantV1, GrantV2, GrantV2FetchRule +from scm.git.grant import GRANT_AUDIENCE, generate_private_key +from scm.git.types import GrantV2, GrantV2FetchRule from scm.providers.cursor_origin.provider import CursorOriginProvider from scm.providers.github.provider import GitHubProvider from scm.providers.gitlab.provider import GitLabProvider @@ -62,17 +62,17 @@ def make_grant(**overrides) -> GrantV2: return GrantV2(**fields) # type: ignore[arg-type] -def make_v1_grant(**overrides) -> GrantV1: - """A version 1 fetch grant for the default repository, named both ways a grant can name it.""" - fields = { - "organization_id": 1, - "referrer": "seer.coding_job", - "operation": "fetch", - "repositories": [5, ("github", "123")], - "expires_at": FAR_FUTURE, - } - fields.update(overrides) - return GrantV1(**fields) # type: ignore[arg-type] +VERSION_1_PUSH_CLAIMS = { + "organization_id": 1, + "referrer": "seer.coding_job", + "operation": "push", + "repositories": [5], + "version": 1, + "exp": FAR_FUTURE, + "iat": 1_700_000_000, + "aud": GRANT_AUDIENCE, +} +"""A push grant for the default repository as earlier releases sign one, which lets a push update any ref.""" class KeyPair(NamedTuple): diff --git a/tests/unit/test_git_grant.py b/tests/unit/test_git_grant.py index 49b8d26e..f46e0211 100644 --- a/tests/unit/test_git_grant.py +++ b/tests/unit/test_git_grant.py @@ -31,10 +31,10 @@ sign_grant, verify_grant, ) -from scm.git.types import GRANT_MAX_LENGTH, Grant, GrantV1, GrantV2, GrantV2FetchRule, GrantV2PushRule +from scm.git.types import GRANT_MAX_LENGTH, GrantV2, GrantV2FetchRule, GrantV2PushRule from scm.secret import Secret from tests.unit.error_reports import assert_not_in_frames -from tests.unit.git_builders import FAR_FUTURE, KeyPair, make_grant, make_key_pair, make_v1_grant +from tests.unit.git_builders import FAR_FUTURE, VERSION_1_PUSH_CLAIMS, KeyPair, make_grant, make_key_pair # Before the real time: PyJWT checks iat against the real clock, so a grant must not be issued after it. NOW = 1_700_000_000 @@ -50,7 +50,7 @@ def expected_key_id(key: KeyPair) -> str: return hashlib.sha256(raw).hexdigest()[:16] -def claims_of(grant: Grant | GrantV2) -> dict: +def claims_of(grant: GrantV2) -> dict: claims = msgspec.to_builtins(grant) claims["exp"] = claims.pop("expires_at") return claims | {"iat": NOW, "aud": GRANT_AUDIENCE} @@ -520,9 +520,6 @@ def test_missing(self): {"organization_id": True}, {"organization_id": "1"}, {"referrer": None}, - {"version": 1}, - {"version": 3}, - {"version": "2"}, {"rules": None}, {"rules": [{"operation": "fetch", "repository": ["github"], "any_object": True}]}, {"rules": [{"operation": "fetch", "repository": ["unknown_provider", "123"], "any_object": True}]}, @@ -539,8 +536,20 @@ def test_referrer_is_required(self): del claims["referrer"] assert_invalid(sign_claims(claims), "referrer") + @pytest.mark.parametrize("version", [1, 3, "2", None]) + def test_version_other_than_2(self, version): + assert_invalid(sign_claims(claims_of(make_grant()) | {"version": version}), "not version 2") + + def test_without_a_version(self): + claims = claims_of(make_grant()) + del claims["version"] + assert_invalid(sign_claims(claims), "not version 2") + + def test_version_1_grant(self): + assert_invalid(sign_claims(VERSION_1_PUSH_CLAIMS), "not version 2") + def test_version_1_claims_labelled_version_2(self): - assert_invalid(sign_claims(claims_of(make_v1_grant()) | {"version": 2}), "payload is invalid") + assert_invalid(sign_claims(VERSION_1_PUSH_CLAIMS | {"version": 2}), "payload is invalid") FETCH_NAMES_NOTHING = "A fetch rule must set branches, branch_patterns, tags, tag_patterns or any_object" @@ -843,64 +852,3 @@ def test_tag_named_without_refs_tags(self): ) def test_valid(self, field, name): assert getattr(GrantV2PushRule(repository=5, **{field: [name]}), field) == [name] - - -def sign_v1(grant: GrantV1) -> str: - return sign_grant(KEY.private, grant, now=NOW) - - -class TestVersion1: - """Grant, which GrantV2 replaces, still signs and verifies as it did.""" - - @pytest.mark.parametrize("operation", ["fetch", "push"]) - def test_verifies_what_it_signed(self, operation): - grant = make_v1_grant(operation=operation) - verified = verify_grant([KEY.public], sign_v1(grant), now=NOW) - assert verified == grant - assert isinstance(verified, GrantV1) - - def test_deprecated(self): - # Type checkers and IDEs flag code that uses it, by either name. - assert "Sign a GrantV2" in GrantV1.__deprecated__ # type: ignore[attr-defined] - - def test_old_name(self): - assert Grant is GrantV1 - assert Grant(**msgspec.to_builtins(make_v1_grant())) == make_v1_grant() - - def test_token_is_a_jwt_with_the_grants_claims(self): - token = sign_v1(make_v1_grant()) - assert jwt.decode(token, options={"verify_signature": False}) == { - "organization_id": 1, - "referrer": "seer.coding_job", - "operation": "fetch", - "repositories": [5, ["github", "123"]], - "version": 1, - "exp": FAR_FUTURE, - "iat": NOW, - "aud": GRANT_AUDIENCE, - } - - def test_without_a_version(self): - # version defaults to 1, so a grant that leaves it out still reads as version 1. - claims = claims_of(make_v1_grant()) - del claims["version"] - assert verify_grant([KEY.public], sign_claims(claims), now=NOW) == make_v1_grant() - - def test_expired(self): - with pytest.raises(GitGrantExpired): - verify_grant([KEY.public], sign_v1(make_v1_grant(expires_at=NOW)), now=NOW) - - @pytest.mark.parametrize( - "change", - [ - {"operation": "delete"}, - {"repositories": [["github"]]}, - {"repositories": [["unknown_provider", "123"]]}, - {"rules": [{"operation": "push", "repository": 5, "branches": ["main"]}]}, - ], - ) - def test_signed_but_invalid_claims(self, change): - assert_invalid(sign_claims(claims_of(make_v1_grant()) | change), "payload is invalid") - - def test_no_repositories(self): - assert_invalid(sign_v1(make_v1_grant(repositories=[])), "names no repositories") diff --git a/tests/unit/test_git_relay.py b/tests/unit/test_git_relay.py index cb080112..439677f4 100644 --- a/tests/unit/test_git_relay.py +++ b/tests/unit/test_git_relay.py @@ -30,7 +30,7 @@ from scm.secret import Secret from scm.types import GitRemote from tests.unit.error_reports import assert_not_in_frames, assert_not_reported, reported_local -from tests.unit.git_builders import make_grant, make_v1_grant +from tests.unit.git_builders import make_grant from tests.unit.git_upstream import ( ClosingAdapter, HostRefused, @@ -852,34 +852,3 @@ def test_any_repository_with_a_rule(self): ) assert authorize_request(grant, 6, "POST", "git-upload-pack", "") == "fetch" assert authorize_request(grant, ("gitlab", "gitlab.com:7"), "POST", "git-upload-pack", "") == "fetch" - - -class TestAuthorizeRequestVersion1: - @pytest.mark.parametrize( - "operation,route", - [("fetch", route) for route in FETCH_ROUTES] + [("push", route) for route in PUSH_ROUTES], - ) - def test_grant_reaches_its_operations_routes(self, operation, route): - assert authorize_request(make_v1_grant(operation=operation), 5, *route) == operation - - @pytest.mark.parametrize( - "operation,route", - [("fetch", route) for route in PUSH_ROUTES] + [("push", route) for route in FETCH_ROUTES], - ) - def test_grant_cannot_reach_the_other_operations_routes(self, operation, route): - other = "push" if operation == "fetch" else "fetch" - with pytest.raises(GitOperationNotGranted, match=f"allows {operation}, not {other}"): - authorize_request(make_v1_grant(operation=operation), 5, *route) - - def test_route_that_is_not_git(self): - with pytest.raises(GitRouteNotSupported): - authorize_request(make_v1_grant(), 5, "POST", "git-upload-archive", "") - - def test_repository_not_in_grant(self): - with pytest.raises(GitRepositoryNotGranted, match="does not cover this repository"): - authorize_request(make_v1_grant(), 6, "POST", "git-upload-pack", "") - - def test_any_listed_repository(self): - grant = make_v1_grant(repositories=[5, 6, ("gitlab", "gitlab.com:7")]) - assert authorize_request(grant, 6, "POST", "git-upload-pack", "") == "fetch" - assert authorize_request(grant, ("gitlab", "gitlab.com:7"), "POST", "git-upload-pack", "") == "fetch" diff --git a/tests/unit/test_git_server.py b/tests/unit/test_git_server.py index 69f82f66..e831ee28 100644 --- a/tests/unit/test_git_server.py +++ b/tests/unit/test_git_server.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock from urllib.parse import urlsplit +import jwt import pytest from scm.errors import ( @@ -17,7 +18,7 @@ ResourceGatewayTimeout, ResourceServiceUnavailable, ) -from scm.git.grant import sign_grant +from scm.git.grant import key_id, sign_grant from scm.git.relay import GitRelay, RelayResponse, ResponseBody from scm.git.server import _STATUS, GitServer from scm.git.types import GRANT_HEADER, GitOperation, GrantV2FetchRule, GrantV2PushRule, Readable @@ -28,12 +29,12 @@ from tests.unit.git_builders import ( NEW_ID, OLD_ID, + VERSION_1_PUSH_CLAIMS, make_grant, make_key_pair, make_provider, make_push_body, make_repository, - make_v1_grant, pkt, ) from tests.unit.git_upstream import ( @@ -895,11 +896,12 @@ def test_client_closes_while_sending_its_updates(self): assert response.status_code == 499 assert recorder.errors == [] - def test_version_1_push_grant_relays_unread(self): + def test_version_1_push_grant_is_refused(self): recorder = Recorder() server = make_server(recorder) - grant = sign_grant(KEY.private, make_v1_grant(operation="push")) - body = io.BytesIO(b"not a push the server could read") - request(server, grant, method="POST", route="git-receive-pack", query="", body=body) - assert body.tell() == 0 - assert server.relay.forward.call_args.kwargs["body"].read() == b"not a push the server could read" # type: ignore[attr-defined] + grant = jwt.encode(VERSION_1_PUSH_CLAIMS, KEY.key, algorithm="EdDSA", headers={"kid": key_id(KEY.public)}) + body = io.BytesIO(push_body("refs/heads/main")) + response = request(server, grant, method="POST", route="git-receive-pack", query="", body=body) + assert_refused(response, 403, "not version 2") + assert recorder.token_lookups == [] + server.relay.forward.assert_not_called() # type: ignore[attr-defined] From efda0a2831d298b07ba7416b40de8e66c49678cf Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Wed, 11 Nov 2026 08:14:19 +0100 Subject: [PATCH 23/23] ref(git)!: Name the grant and its rules plainly, and authorize in GitServer The V2 names only told the grant apart from version 1, which is gone, so GrantV2 is Grant again and its rules are FetchRule and PushRule. fetch_rules and push_rules replace rules_for, so callers get each kind's type without filtering, and the rules' operation attribute goes with it. authorize_request had no caller outside GitServer, and with version 1 gone all it did on its own was refuse pushes, which only GitServer can check. GitServer now checks the grant itself, so the relay knows nothing of grants. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- bin/git-server | 14 +-- src/scm/git/grant.py | 8 +- src/scm/git/push.py | 8 +- src/scm/git/relay.py | 42 +------- src/scm/git/server.py | 43 ++++++-- src/scm/git/types.py | 26 ++--- tests/unit/git_builders.py | 10 +- tests/unit/test_git_grant.py | 86 ++++++++-------- tests/unit/test_git_push.py | 24 ++--- tests/unit/test_git_push_adversarial.py | 6 +- tests/unit/test_git_push_adversarial_2.py | 4 +- tests/unit/test_git_push_adversarial_3.py | 4 +- tests/unit/test_git_relay.py | 120 +--------------------- tests/unit/test_git_server.py | 118 +++++++++++++++++++-- 15 files changed, 248 insertions(+), 267 deletions(-) diff --git a/README.md b/README.md index edadbfb8..0a8c85b0 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The platform exposes four subsystems: - **Actions** — outbound SCM operations initiated by Sentry code. The `SourceCodeManager` class provides 70+ methods covering comments, reactions, pull requests, branches, git objects, reviews, and check runs. With more actions planned to be added as we port more use cases. - **Actions RPC** - outbound SCM commands exposed over the network. The `SourceCodeManager` is fully available over the network. This enables usage of the SCM for services outside the monolith. -- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. A `GrantV2` names the organization and a list of rules, each a repository, an operation (`GrantV2FetchRule` or `GrantV2PushRule`) and the branches and tags it may touch, or `any_object` for a fetch that may read any object. Both kinds of rule name branches by `branches` or full-match `branch_patterns`, and tags by `tags` or `tag_patterns`, and a push rule's `create_only` limits it to new refs. A request passes if any rule allows it. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. For a push under a push rule it first reads the ref updates, and answers a push that updates any other ref, deletes one or carries push options with a status report git shows as rejected refs. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it. +- **Git relay** - `git` fetches and pushes for services that hold no provider credentials. Seer signs a grant with `scm.git.grant.sign_grant` and an Ed25519 private key only Seer holds. A `Grant` names the organization and a list of rules, each a repository, an operation (`FetchRule` or `PushRule`) and the branches and tags it may touch, or `any_object` for a fetch that may read any object. Both kinds of rule name branches by `branches` or full-match `branch_patterns`, and tags by `tags` or `tag_patterns`, and a push rule's `create_only` limits it to new refs. A request passes if any rule allows it. `scm.git.server.GitServer` verifies the grant against the matching public keys, so whoever verifies grants cannot mint one, and checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. For a push under a push rule it first reads the ref updates, and answers a push that updates any other ref, deletes one or carries push options with a status report git shows as rejected refs. A missing or unusable key on either side raises `scm.errors.GitGrantKeyError`, the server's misconfiguration rather than a refused grant. `require_private_key` and `require_public_keys` check the keys at startup, and `generate_private_key` makes a key for tests and local tools. `key_id` gives a public key's `kid`, the id each grant names its signing key by, so a log can name the key that signed a grant without quoting it. - **Event Stream** — inbound webhook processing. SCM service-providers push events which are deserialized into typed, provider-neutral dataclasses (`CheckRunEvent`, `CommentEvent`, `PullRequestEvent`) which are then dispatched to registered listener functions. ### Why diff --git a/bin/git-server b/bin/git-server index 078185ba..280278da 100755 --- a/bin/git-server +++ b/bin/git-server @@ -110,7 +110,7 @@ from scm.errors import GitGrantKeyError from scm.git.grant import generate_private_key, public_key_of, sign_grant from scm.git.relay import GitRelay from scm.git.server import GitServer -from scm.git.types import GRANT_HEADER, GetToken, GrantV2, GrantV2FetchRule, GrantV2PushRule, Readable +from scm.git.types import GRANT_HEADER, FetchRule, GetToken, Grant, PushRule, Readable from scm.providers.github.provider import GitHubProvider from scm.providers.gitlab.provider import GitLabProvider from scm.types import GetGitRemoteProtocol, Provider, ProviderName, Repository, RepositoryId @@ -288,8 +288,8 @@ def make_git_server( ) -def grant_for(private_key: Secret, rule: GrantV2FetchRule | GrantV2PushRule) -> str: - grant = GrantV2( +def grant_for(private_key: Secret, rule: FetchRule | PushRule) -> str: + grant = Grant( organization_id=ORGANIZATION_ID, referrer=REFERRER, rules=[rule], @@ -299,7 +299,7 @@ def grant_for(private_key: Secret, rule: GrantV2FetchRule | GrantV2PushRule) -> def fetch_grant_for(private_key: Secret, repository_id: RepositoryId) -> str: - return grant_for(private_key, GrantV2FetchRule(repository=repository_id, any_object=True)) + return grant_for(private_key, FetchRule(repository=repository_id, any_object=True)) class GitHandler(BaseHTTPRequestHandler): @@ -444,7 +444,7 @@ def serve(backend: Backend, args: argparse.Namespace) -> None: ) url = f"{server.url}/{backend.repository['name']}.git" fetch_grant = fetch_grant_for(args.private_key, backend.repository_id) - push_grant = grant_for(args.private_key, GrantV2PushRule(repository=backend.repository_id, branches=["my-branch"])) + push_grant = grant_for(args.private_key, PushRule(repository=backend.repository_id, branches=["my-branch"])) logger.info( f"Serving {backend.provider_name} repository {backend.repository['name']} at {url}\n\n" f"Grants, valid for {GRANT_TTL // 60} minutes:\n" @@ -520,7 +520,7 @@ class Checks: branch = f"scm-git-server-check/{int(time.time())}" fetch_grant = fetch_grant_for(self.args.private_key, repository_id) push_grant = grant_for( - self.args.private_key, GrantV2PushRule(repository=repository_id, branches=[branch], tags=[f"{branch}-tag"]) + self.args.private_key, PushRule(repository=repository_id, branches=[branch], tags=[f"{branch}-tag"]) ) server = self.start() @@ -678,7 +678,7 @@ class Checks: """ prefix, _, name = ref.partition("/")[2].partition("/") patterns = {"branch_patterns" if prefix == "heads" else "tag_patterns": [re.escape(name)]} - rule = GrantV2PushRule(repository=self.backend.repository_id, create_only=True, **patterns) + rule = PushRule(repository=self.backend.repository_id, create_only=True, **patterns) return requests.post( f"{url}/git-receive-pack", data=forged_create_body(ref, tip), diff --git a/src/scm/git/grant.py b/src/scm/git/grant.py index 59b055e1..fe35c681 100644 --- a/src/scm/git/grant.py +++ b/src/scm/git/grant.py @@ -37,7 +37,7 @@ ) from scm.errors import GitGrantExpired, GitGrantKeyError, GitInvalidGrant -from scm.git.types import GRANT_MAX_LENGTH, GrantV2 +from scm.git.types import GRANT_MAX_LENGTH, Grant from scm.secret import Secret GRANT_AUDIENCE = "scm-git-grant" @@ -48,7 +48,7 @@ _JWT_CLAIMS = frozenset({"aud", "exp", "iat"}) -def sign_grant(private_key: Secret, grant: GrantV2, *, now: float | None = None) -> str: +def sign_grant(private_key: Secret, grant: Grant, *, now: float | None = None) -> str: """Sign ``grant`` with ``private_key``, an Ed25519 private key in PEM. The ``kid`` header is derived from the key, so a signer needs no key id setting next to it. Raises @@ -111,7 +111,7 @@ def require_private_key(private_key: Secret) -> Secret: return private_key -def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = None) -> GrantV2: +def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = None) -> Grant: """Return the grant if the private key of one of ``public_keys`` signed it and it has not expired. Only version 2 grants verify. A version 1 grant, which lets a push update any ref, is refused. @@ -162,7 +162,7 @@ def verify_grant(public_keys: Sequence[str], token: str, *, now: float | None = if fields.get("version") != 2: raise GitInvalidGrant(detail="The git grant is not version 2, the only version the git endpoint accepts.") try: - grant = msgspec.convert(payload, type=GrantV2, strict=True) + grant = msgspec.convert(payload, type=Grant, strict=True) except msgspec.ValidationError as e: # The signature matched, so the holder of the private key wrote these claims, and telling them why they are # invalid gives nothing away. diff --git a/src/scm/git/push.py b/src/scm/git/push.py index 2ca47551..fc679b2a 100644 --- a/src/scm/git/push.py +++ b/src/scm/git/push.py @@ -14,7 +14,7 @@ from scm.errors import GitMalformedRequestBody, GitOperationNotGranted, RpcMalformedRequestHeaders, SCMCodedError from scm.git.relay import DEFAULT_CHUNK_SIZE, _printable, _read -from scm.git.types import GrantV2PushRule, Readable, _follows_ref_format, _is_short_name +from scm.git.types import PushRule, Readable, _follows_ref_format, _is_short_name MAX_UPDATES_LENGTH = 64 * 1024 """The most bytes a push's ref updates may take. An update takes under 100 bytes plus its ref's name.""" @@ -101,7 +101,7 @@ class PushRequest(NamedTuple): """The bytes read from the body, to send on before the rest of it.""" -def check_push(rules: Sequence[GrantV2PushRule], headers: Mapping[str, str], body: Readable | None) -> Readable: +def check_push(rules: Sequence[PushRule], headers: Mapping[str, str], body: Readable | None) -> Readable: """Return the body to relay for a push that ``rules`` allow, and refuse any other. ``body`` must end where the request body ends. A refused push's body is read to the end first, because git reads @@ -200,7 +200,7 @@ def check_push_headers(method: str, headers: Mapping[str, str]) -> None: raise RpcMalformedRequestHeaders(detail=f"The push's {lowered} header holds a value git doesn't send.") -def refused_updates(rules: Sequence[GrantV2PushRule], request: PushRequest) -> dict[bytes, str]: +def refused_updates(rules: Sequence[PushRule], request: PushRequest) -> dict[bytes, str]: """Return why ``rules`` refuse each ref of ``request``, or nothing if every update has a rule that allows it. A push is relayed whole or not at all, so once one update is refused, every other ref gets a reason too. With no @@ -223,7 +223,7 @@ def refused_updates(rules: Sequence[GrantV2PushRule], request: PushRequest) -> d return refused -def _refusal(rule: GrantV2PushRule | None, update: RefUpdate) -> tuple[int, str] | None: +def _refusal(rule: PushRule | None, update: RefUpdate) -> tuple[int, str] | None: """Return why ``rule``, or a grant with no rule, refuses ``update``, ranked by how far the update got, or None if the rule allows it.""" # Reading the push checked that the ref's name is one git takes, so it decodes. diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py index 60441d48..ff64e615 100644 --- a/src/scm/git/relay.py +++ b/src/scm/git/relay.py @@ -9,9 +9,7 @@ from scm.errors import ( ClientClosedRequest, - GitOperationNotGranted, GitRemoteNotHttps, - GitRepositoryNotGranted, GitRouteNotSupported, ResourceBadGateway, ResourceCertificateInvalid, @@ -20,10 +18,10 @@ RpcMalformedRequestHeaders, SCMCodedError, ) -from scm.git.types import GitOperation, GrantV2, GrantV2FetchRule, Readable +from scm.git.types import GitOperation, Readable from scm.rpc.types import StreamResponse from scm.secret import Secret -from scm.types import GitRemote, RepositoryId +from scm.types import GitRemote # The most bytes the relay reads at once, from the client's body or from the provider's response, so also the # most it holds per request in each direction. Smaller chunks cost more Python per byte of a multi-gigabyte pack; @@ -64,40 +62,6 @@ ) -def authorize_request(grant: GrantV2, repository_id: RepositoryId, method: str, route: str, query: str) -> GitOperation: - """Return the git operation a request belongs to, refusing the request unless the grant allows it. - - Fetches reach only upload-pack and pushes only receive-pack. A request runs only if the grant has a rule for its - repository and operation, and the rule must name the repository in the same form the request uses: a database id - does not match the same repository's ``(provider, external id)`` pair. - - A fetch needs a fetch rule for any object, since nothing reads a fetch's body to keep it to a rule's branches and - tags. A push is refused here, because only its ref updates show which refs it touches: relay it through - GitServer, which reads them before it relays the push. - """ - operation = _authorize(grant, repository_id, method, route, query) - if operation == "push" and method == "POST": - raise GitOperationNotGranted(detail="A git push goes through GitServer, which checks its ref updates.") - return operation - - -def _authorize(grant: GrantV2, repository_id: RepositoryId, method: str, route: str, query: str) -> GitOperation: - """``authorize_request`` for GitServer, which checks a push's ref updates against the grant's push rules itself.""" - operation = _route_operation(method, route, query) - rules = grant.rules_for(repository_id, operation) - if not rules: - other: GitOperation = "push" if operation == "fetch" else "fetch" - if not grant.rules_for(repository_id, other): - raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") - raise GitOperationNotGranted(detail=f"The git grant allows {other} on this repository, not {operation}.") - if operation == "fetch" and not any(isinstance(rule, GrantV2FetchRule) and rule.any_object for rule in rules): - raise GitOperationNotGranted( - detail="The git endpoint cannot keep a fetch to a rule's branches and tags, so a fetch rule must set " - "any_object." - ) - return operation - - def _route_operation(method: str, route: str, query: str) -> GitOperation: operation = _ROUTES.get((method, route, query)) if operation is None: @@ -189,7 +153,7 @@ def forward( ``route`` is the path below ``.git/``. The relay enforces what keeps the token safe: an https remote and one of the four git routes. It knows nothing of grants, so whether the request may run at all is the - caller's to decide; GitServer calls ``authorize_request`` before it looks up the repository or a token. + caller's to decide, as GitServer does before it looks up the repository or a token. Errors carry no URL, because the client has no use for the provider's address. """ diff --git a/src/scm/git/server.py b/src/scm/git/server.py index dedd2f96..e63614f7 100644 --- a/src/scm/git/server.py +++ b/src/scm/git/server.py @@ -1,11 +1,19 @@ from collections.abc import Callable, Mapping, Sequence from urllib.parse import urlsplit -from scm.errors import ErrorCode, GitProviderNotSupported, GitTokenRejected, GitTokenUnavailable, SCMCodedError +from scm.errors import ( + ErrorCode, + GitOperationNotGranted, + GitProviderNotSupported, + GitRepositoryNotGranted, + GitTokenRejected, + GitTokenUnavailable, + SCMCodedError, +) from scm.git.grant import require_public_keys, verify_grant from scm.git.push import PushRefused, check_push, check_push_headers -from scm.git.relay import GitRelay, ResponseBody, _authorize -from scm.git.types import GRANT_HEADER, GetToken, GrantV2PushRule, Readable +from scm.git.relay import GitRelay, ResponseBody, _route_operation +from scm.git.types import GRANT_HEADER, GetToken, GitOperation, Grant, Readable from scm.helpers import initialize_provider from scm.rpc.types import StreamResponse from scm.secret import Secret @@ -146,10 +154,7 @@ def handle( relayed_body: Readable | None = counted_body if operation == "push": if method == "POST": - push_rules = [ - rule for rule in grant.rules_for(repository_id, "push") if isinstance(rule, GrantV2PushRule) - ] - relayed_body = check_push(push_rules, headers, counted_body) + relayed_body = check_push(grant.push_rules(repository_id), headers, counted_body) else: # The push's first request, which lists the refs, goes to the provider with the same headers. check_push_headers(method, headers) @@ -227,6 +232,30 @@ def report(error: SCMCodedError) -> None: ) +def _authorize(grant: Grant, repository_id: RepositoryId, method: str, route: str, query: str) -> GitOperation: + """Return the git operation a request belongs to, refusing the request unless the grant allows it. + + Fetches reach only upload-pack and pushes only receive-pack. A request runs only if the grant has a rule for its + repository and operation, and the rule must name the repository in the same form the request uses: a database id + does not match the same repository's ``(provider, external id)`` pair. A fetch needs a fetch rule for any object, + since nothing reads a fetch's body to keep it to a rule's branches and tags. + """ + operation = _route_operation(method, route, query) + fetch_rules, push_rules = grant.fetch_rules(repository_id), grant.push_rules(repository_id) + if not fetch_rules and not push_rules: + raise GitRepositoryNotGranted(detail="The git grant does not cover this repository.") + if operation == "fetch" and not fetch_rules: + raise GitOperationNotGranted(detail="The git grant allows push on this repository, not fetch.") + if operation == "push" and not push_rules: + raise GitOperationNotGranted(detail="The git grant allows fetch on this repository, not push.") + if operation == "fetch" and not any(rule.any_object for rule in fetch_rules): + raise GitOperationNotGranted( + detail="The git endpoint cannot keep a fetch to a rule's branches and tags, so a fetch rule must set " + "any_object." + ) + return operation + + def _take_grant(headers: Mapping[str, str]) -> tuple[Secret, dict[str, str]]: """Split the grant, empty if there is none, from the other headers. Header names are case-insensitive.""" grant = Secret("") diff --git a/src/scm/git/types.py b/src/scm/git/types.py index 3dfd36a8..d3f829c3 100644 --- a/src/scm/git/types.py +++ b/src/scm/git/types.py @@ -1,6 +1,6 @@ import re from collections.abc import Callable -from typing import ClassVar, Literal, Protocol +from typing import Literal, Protocol import msgspec @@ -8,7 +8,7 @@ from scm.types import BranchName, Referrer, Repository, RepositoryId type GitOperation = Literal["fetch", "push"] -"""What a rule lets its holder do. ``scm.git.relay`` maps each to its git routes.""" +"""Whether a git request fetches or pushes. ``scm.git.relay`` maps each to its git routes.""" GRANT_HEADER = "X-Scm-Git-Grant" """The request header that carries a signed grant to the git endpoint.""" @@ -23,7 +23,7 @@ 8 KiB.""" -class _GrantV2RefRule(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True, omit_defaults=True): +class _RefRule(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True, omit_defaults=True): """What fetch and push rules share: the repository, and the branches and tags of it the rule covers. Each kind of ref is set by a list of names or by patterns, and not both. A pattern covers only names of its own @@ -78,7 +78,7 @@ def _names_refs(self) -> bool: return any(field is not None for field in (self.branches, self.branch_patterns, self.tags, self.tag_patterns)) -class GrantV2FetchRule(_GrantV2RefRule, frozen=True, tag="fetch", tag_field="operation"): +class FetchRule(_RefRule, frozen=True, tag="fetch", tag_field="operation"): """Lets the holder fetch from ``repository``: any of its objects, or only what the branches and tags it covers reach. @@ -88,7 +88,6 @@ class GrantV2FetchRule(_GrantV2RefRule, frozen=True, tag="fetch", tag_field="ope checkout before the agent starts. """ - operation: ClassVar[GitOperation] = "fetch" any_object: bool = False def __post_init__(self) -> None: @@ -104,7 +103,7 @@ def covers(self, ref: str) -> bool: return self.any_object or super().covers(ref) -class GrantV2PushRule(_GrantV2RefRule, frozen=True, tag="push", tag_field="operation"): +class PushRule(_RefRule, frozen=True, tag="push", tag_field="operation"): """Lets the holder create or update the branches and tags of ``repository`` the rule covers, and no other ref, and delete none. @@ -118,7 +117,6 @@ class GrantV2PushRule(_GrantV2RefRule, frozen=True, tag="push", tag_field="opera tag named ``main`` redirects whoever fetches or resolves ``main``, and a new tag often starts a release pipeline. """ - operation: ClassVar[GitOperation] = "push" create_only: bool = False """Allow only creating a covered branch or tag. git sends a new ref with an old object id of all zeros, and the provider refuses such an update if the ref already exists.""" @@ -129,7 +127,7 @@ def __post_init__(self) -> None: raise ValueError("A push rule must set branches, branch_patterns, tags or tag_patterns.") -class GrantV2(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): +class Grant(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=True): """Permission to run git operations on an organization's repositories, as a list of rules. Seer signs a grant for each sandbox, and for its own git requests, and Sentry's git endpoint verifies it on @@ -153,7 +151,7 @@ class GrantV2(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=T """Who the grant is for, e.g. a coding job or Seer itself, as ``referrer`` is elsewhere in this library. Sentry tags the git endpoint's metrics with it. Seer sets it, because git requests carry nothing else that tells callers apart.""" - rules: list[GrantV2FetchRule | GrantV2PushRule] + rules: list[FetchRule | PushRule] expires_at: int """Unix time in seconds. Every grant expires.""" version: Literal[2] = 2 @@ -164,9 +162,13 @@ def __post_init__(self) -> None: if not self.rules: raise ValueError("A git grant must hold at least one rule.") - def rules_for(self, repository: RepositoryId, operation: GitOperation) -> list[GrantV2FetchRule | GrantV2PushRule]: - """Return the rules for ``operation`` on ``repository``, named as the rules name it.""" - return [rule for rule in self.rules if rule.repository == repository and rule.operation == operation] + def fetch_rules(self, repository: RepositoryId) -> list[FetchRule]: + """Return the fetch rules for ``repository``, named as the rules name it.""" + return [rule for rule in self.rules if isinstance(rule, FetchRule) and rule.repository == repository] + + def push_rules(self, repository: RepositoryId) -> list[PushRule]: + """Return the push rules for ``repository``, named as the rules name it.""" + return [rule for rule in self.rules if isinstance(rule, PushRule) and rule.repository == repository] # What git check-ref-format refuses in a ref name: control characters, space, ~ ^ : ? * [ \, "..", "@{" and "//". diff --git a/tests/unit/git_builders.py b/tests/unit/git_builders.py index 870f3adf..7eb8fc14 100644 --- a/tests/unit/git_builders.py +++ b/tests/unit/git_builders.py @@ -7,7 +7,7 @@ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_private_key from scm.git.grant import GRANT_AUDIENCE, generate_private_key -from scm.git.types import GrantV2, GrantV2FetchRule +from scm.git.types import FetchRule, Grant from scm.providers.cursor_origin.provider import CursorOriginProvider from scm.providers.github.provider import GitHubProvider from scm.providers.gitlab.provider import GitLabProvider @@ -47,19 +47,19 @@ def make_provider(organization_id: int, repository: Repository) -> Provider | No return None -def make_grant(**overrides) -> GrantV2: +def make_grant(**overrides) -> Grant: """A grant to fetch any object of the default repository, named both ways a rule can name it.""" fields = { "organization_id": 1, "referrer": "seer.coding_job", "rules": [ - GrantV2FetchRule(repository=5, any_object=True), - GrantV2FetchRule(repository=("github", "123"), any_object=True), + FetchRule(repository=5, any_object=True), + FetchRule(repository=("github", "123"), any_object=True), ], "expires_at": FAR_FUTURE, } fields.update(overrides) - return GrantV2(**fields) # type: ignore[arg-type] + return Grant(**fields) # type: ignore[arg-type] VERSION_1_PUSH_CLAIMS = { diff --git a/tests/unit/test_git_grant.py b/tests/unit/test_git_grant.py index f46e0211..b0d5a159 100644 --- a/tests/unit/test_git_grant.py +++ b/tests/unit/test_git_grant.py @@ -31,7 +31,7 @@ sign_grant, verify_grant, ) -from scm.git.types import GRANT_MAX_LENGTH, GrantV2, GrantV2FetchRule, GrantV2PushRule +from scm.git.types import GRANT_MAX_LENGTH, FetchRule, Grant, PushRule from scm.secret import Secret from tests.unit.error_reports import assert_not_in_frames from tests.unit.git_builders import FAR_FUTURE, VERSION_1_PUSH_CLAIMS, KeyPair, make_grant, make_key_pair @@ -50,7 +50,7 @@ def expected_key_id(key: KeyPair) -> str: return hashlib.sha256(raw).hexdigest()[:16] -def claims_of(grant: GrantV2) -> dict: +def claims_of(grant: Grant) -> dict: claims = msgspec.to_builtins(grant) claims["exp"] = claims.pop("expires_at") return claims | {"iat": NOW, "aud": GRANT_AUDIENCE} @@ -120,14 +120,14 @@ def test_public_key_of_the_signing_key(self): def test_rules_of_each_kind(self): grant = make_grant( rules=[ - GrantV2FetchRule(repository=5, branches=["main", "seer/fix"]), - GrantV2PushRule(repository=5, branches=["seer/fix"]), - GrantV2FetchRule(repository=("gitlab", "gitlab.com:7"), any_object=True), + FetchRule(repository=5, branches=["main", "seer/fix"]), + PushRule(repository=5, branches=["seer/fix"]), + FetchRule(repository=("gitlab", "gitlab.com:7"), any_object=True), ] ) verified = verify_grant([KEY.public], sign_grant(KEY.private, grant, now=NOW), now=NOW) assert verified == grant - assert [rule.operation for rule in verified.rules] == ["fetch", "push", "fetch"] + assert [type(rule) for rule in verified.rules] == [FetchRule, PushRule, FetchRule] def test_token_is_a_jwt_with_the_grants_claims(self): token = sign_grant(KEY.private, make_grant(), now=NOW) @@ -275,13 +275,10 @@ def test_other_asymmetric_algorithm(self): assert_invalid(token, "alg value is not allowed") -def grant_with_repositories(count: int) -> GrantV2: +def grant_with_repositories(count: int) -> Grant: # Names of one length, so each rule adds the same to the grant. return make_grant( - rules=[ - GrantV2FetchRule(repository=("github", f"getsentry/repository-{i:04d}"), any_object=True) - for i in range(count) - ] + rules=[FetchRule(repository=("github", f"getsentry/repository-{i:04d}"), any_object=True) for i in range(count)] ) @@ -312,7 +309,7 @@ def test_longest_grant_that_fits_signs(self): def test_grant_for_one_repository(self): # GRANT_MAX_LENGTH's docstring sizes grants from this. - grant = make_grant(rules=[GrantV2FetchRule(repository=("github", "getsentry/scm-platform"), any_object=True)]) + grant = make_grant(rules=[FetchRule(repository=("github", "getsentry/scm-platform"), any_object=True)]) assert len(sign_grant(KEY.private, grant, now=NOW)) < 500 @@ -566,8 +563,8 @@ class TestRules: def test_fetch_and_push_rules_for_one_repository(self): grant = make_grant( rules=[ - GrantV2FetchRule(repository=5, branches=["main"]), - GrantV2PushRule(repository=5, branches=["seer/fix"]), + FetchRule(repository=5, branches=["main"]), + PushRule(repository=5, branches=["seer/fix"]), ] ) assert verify_grant([KEY.public], sign_grant(KEY.private, grant, now=NOW), now=NOW) == grant @@ -576,11 +573,11 @@ def test_claims_hold_only_what_a_rule_sets(self): # Shorter, and a reader sees which fetch rules reach any object. grant = make_grant( rules=[ - GrantV2FetchRule(repository=5, branches=["main"], tags=["v1"]), - GrantV2FetchRule(repository=6, any_object=True), - GrantV2PushRule(repository=5, branches=["seer/fix"]), - GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), - GrantV2PushRule(repository=5, tag_patterns=["v[0-9]+"], create_only=True), + FetchRule(repository=5, branches=["main"], tags=["v1"]), + FetchRule(repository=6, any_object=True), + PushRule(repository=5, branches=["seer/fix"]), + PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + PushRule(repository=5, tag_patterns=["v[0-9]+"], create_only=True), ] ) claims = jwt.decode(sign_grant(KEY.private, grant, now=NOW), options={"verify_signature": False}) @@ -657,15 +654,15 @@ def test_invalid_rule(self, rule, error): def test_rule_without_refs_or_any_object_cannot_be_built(self): with pytest.raises(ValueError, match=FETCH_NAMES_NOTHING): - GrantV2FetchRule(repository=5) + FetchRule(repository=5) with pytest.raises(ValueError, match=PUSH_NAMES_NOTHING): - GrantV2PushRule(repository=5, create_only=True) + PushRule(repository=5, create_only=True) def test_rule_with_refs_and_any_object_cannot_be_built(self): with pytest.raises(ValueError, match="that sets any_object must not set"): - GrantV2FetchRule(repository=5, tags=["v1"], any_object=True) + FetchRule(repository=5, tags=["v1"], any_object=True) - @pytest.mark.parametrize("make_rule", [GrantV2FetchRule, GrantV2PushRule], ids=["fetch", "push"]) + @pytest.mark.parametrize("make_rule", [FetchRule, PushRule], ids=["fetch", "push"]) @pytest.mark.parametrize("field", ["branches", "branch_patterns", "tags", "tag_patterns"]) def test_rule_with_an_empty_list_cannot_be_built(self, make_rule, field): with pytest.raises(ValueError, match="must not be empty"): @@ -680,21 +677,22 @@ def test_several_rules_for_one_repository_and_operation(self): # A request passes if any of them allows it, so their order doesn't matter. grant = make_grant( rules=[ - GrantV2FetchRule(repository=5, any_object=True), - GrantV2FetchRule(repository=5, branches=["main"]), - GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), - GrantV2PushRule(repository=5, branches=["seer/fix"]), + FetchRule(repository=5, any_object=True), + FetchRule(repository=5, branches=["main"]), + PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + PushRule(repository=5, branches=["seer/fix"]), ] ) assert verify_grant([KEY.public], sign_grant(KEY.private, grant, now=NOW), now=NOW) == grant - assert grant.rules_for(5, "push") == grant.rules[2:] - assert grant.rules_for(("github", "123"), "push") == [] + assert grant.fetch_rules(5) == grant.rules[:2] + assert grant.push_rules(5) == grant.rules[2:] + assert grant.push_rules(("github", "123")) == [] def test_one_repository_named_both_ways(self): # Nothing matches a database id to a provider id, so the two are separate repositories here, as in requests. rules = [ - GrantV2FetchRule(repository=5, any_object=True), - GrantV2FetchRule(repository=("github", "123"), branches=["main"]), + FetchRule(repository=5, any_object=True), + FetchRule(repository=("github", "123"), branches=["main"]), ] assert make_grant(rules=rules).rules == rules @@ -752,7 +750,7 @@ class TestCovers: ], ) def test_branch_patterns(self, patterns, ref, covered): - assert GrantV2PushRule(repository=5, branch_patterns=patterns).covers(ref) is covered + assert PushRule(repository=5, branch_patterns=patterns).covers(ref) is covered @pytest.mark.parametrize( "ref,covered", @@ -764,10 +762,10 @@ def test_branch_patterns(self, patterns, ref, covered): ], ) def test_tag_patterns(self, ref, covered): - assert GrantV2PushRule(repository=5, tag_patterns=[r"v[0-9]+\.[0-9]+"]).covers(ref) is covered + assert PushRule(repository=5, tag_patterns=[r"v[0-9]+\.[0-9]+"]).covers(ref) is covered def test_listed_refs(self): - rule = GrantV2PushRule(repository=5, branches=["seer/fix"], tags=["v1"]) + rule = PushRule(repository=5, branches=["seer/fix"], tags=["v1"]) assert rule.refs == ["refs/heads/seer/fix", "refs/tags/v1"] assert rule.covers("refs/heads/seer/fix") assert rule.covers("refs/tags/v1") @@ -777,11 +775,11 @@ def test_listed_refs(self): @pytest.mark.parametrize("ref", ["refs/pull/1/head", "refs/notes/commits", "refs/heads", "refs/tags", "HEAD"]) def test_patterns_cover_only_branches_and_tags(self, ref): - rule = GrantV2PushRule(repository=5, branch_patterns=[".*"], tag_patterns=[".*"]) + rule = PushRule(repository=5, branch_patterns=[".*"], tag_patterns=[".*"]) assert rule.refs == [] assert not rule.covers(ref) - @pytest.mark.parametrize("make_rule", [GrantV2FetchRule, GrantV2PushRule], ids=["fetch", "push"]) + @pytest.mark.parametrize("make_rule", [FetchRule, PushRule], ids=["fetch", "push"]) def test_same_for_fetch_and_push(self, make_rule): rule = make_rule(repository=5, branches=["main"], tag_patterns=["v.+"]) assert rule.refs == ["refs/heads/main"] @@ -790,7 +788,7 @@ def test_same_for_fetch_and_push(self, make_rule): assert not rule.covers("refs/heads/dev") def test_any_object_covers_every_ref(self): - rule = GrantV2FetchRule(repository=5, any_object=True) + rule = FetchRule(repository=5, any_object=True) assert rule.refs == [] assert rule.covers("refs/heads/main") assert rule.covers("refs/pull/1/head") @@ -809,16 +807,16 @@ def test_any_object_covers_every_ref(self): ) def test_patterns_run_with_ascii_classes(self, pattern, ref, covered): field = "tag_patterns" if ref.startswith("refs/tags/") else "branch_patterns" - assert GrantV2PushRule(repository=5, **{field: [pattern]}).covers(ref) is covered + assert PushRule(repository=5, **{field: [pattern]}).covers(ref) is covered def test_pattern_that_asks_for_unicode_classes_cannot_be_built(self): with pytest.raises(ValueError, match="is not a regular expression"): - GrantV2PushRule(repository=5, tag_patterns=[r"(?u)v\d+"]) + PushRule(repository=5, tag_patterns=[r"(?u)v\d+"]) @pytest.mark.parametrize("field", ["branch_patterns", "tag_patterns"]) def test_cannot_be_built_with_an_invalid_pattern(self, field): with pytest.raises(ValueError, match="'seer/\\(' is not a regular expression"): - GrantV2PushRule(repository=5, **{field: ["seer/("]}) + PushRule(repository=5, **{field: ["seer/("]}) NAME_FIELDS = pytest.mark.parametrize( @@ -831,7 +829,7 @@ class TestRefNames: @pytest.mark.parametrize("name", INVALID_REF_NAMES) def test_invalid(self, field, error, name): with pytest.raises(ValueError, match=error): - GrantV2PushRule(repository=5, **{field: ["main", name]}) + PushRule(repository=5, **{field: ["main", name]}) assert_invalid( sign_claims(rule_claims({"operation": "push", "repository": 5, field: ["main", name]})), error, @@ -840,15 +838,15 @@ def test_invalid(self, field, error, name): @NAME_FIELDS def test_checked_in_fetch_rules_too(self, field, error): with pytest.raises(ValueError, match=error): - GrantV2FetchRule(repository=5, **{field: ["refs/heads/main"]}) + FetchRule(repository=5, **{field: ["refs/heads/main"]}) def test_tag_named_without_refs_tags(self): with pytest.raises(ValueError, match="Name a tag as git tag takes it, without refs/tags/"): - GrantV2PushRule(repository=5, tags=["refs/tags/v1"]) + PushRule(repository=5, tags=["refs/tags/v1"]) @pytest.mark.parametrize("field", ["branches", "tags"]) @pytest.mark.parametrize( "name", ["main", "seer/fix-123", "feature/a.b", "a@b", "release-1.0", "a.lock.b", "x/HEAD", "ünicode", "v1.0.0"] ) def test_valid(self, field, name): - assert getattr(GrantV2PushRule(repository=5, **{field: [name]}), field) == [name] + assert getattr(PushRule(repository=5, **{field: [name]}), field) == [name] diff --git a/tests/unit/test_git_push.py b/tests/unit/test_git_push.py index 332ecb43..e78b9594 100644 --- a/tests/unit/test_git_push.py +++ b/tests/unit/test_git_push.py @@ -15,10 +15,10 @@ refusal_report, refused_updates, ) -from scm.git.types import GrantV2PushRule +from scm.git.types import PushRule from tests.unit.git_builders import NEW_ID, OLD_ID, PACK, ZERO_ID, make_push_body, pkt -RULES = [GrantV2PushRule(repository=5, branches=["seer/fix", "seer/other"])] +RULES = [PushRule(repository=5, branches=["seer/fix", "seer/other"])] def read(body: bytes, headers: dict[str, str] | None = None) -> tuple[PushRequest, io.BytesIO]: @@ -301,12 +301,12 @@ def test_no_rules(self, ref, reason): ], ) def test_create_only_pattern(self, old_id, ref, reason): - rules = [GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True)] + rules = [PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True)] refused = refused_updates(rules, request_for((old_id, NEW_ID, ref))) assert refused == ({} if reason is None else {ref.encode(): reason}) def test_create_only_listed_branch(self): - rules = [GrantV2PushRule(repository=5, branches=["seer/fix"], create_only=True)] + rules = [PushRule(repository=5, branches=["seer/fix"], create_only=True)] assert refused_updates(rules, request_for((ZERO_ID, NEW_ID, "refs/heads/seer/fix"))) == {} assert refused_updates(rules, request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"))) == { b"refs/heads/seer/fix": "the git grant allows only creating this branch" @@ -323,25 +323,25 @@ def test_create_only_listed_branch(self): ], ) def test_create_only_tag_pattern(self, old_id, new_id, ref, reason): - rules = [GrantV2PushRule(repository=5, tag_patterns=[r"v[0-9]+\.[0-9]+"], create_only=True)] + rules = [PushRule(repository=5, tag_patterns=[r"v[0-9]+\.[0-9]+"], create_only=True)] refused = refused_updates(rules, request_for((old_id, new_id, ref))) assert refused == ({} if reason is None else {ref.encode(): reason}) def test_listed_tag_moves(self): # Without create_only a covered tag may move, as a covered branch may. - rules = [GrantV2PushRule(repository=5, tags=["latest"])] + rules = [PushRule(repository=5, tags=["latest"])] assert refused_updates(rules, request_for((OLD_ID, NEW_ID, "refs/tags/latest"))) == {} def test_one_rule_for_branches_and_tags(self): - rules = [GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], tags=["seer-1"], create_only=True)] + rules = [PushRule(repository=5, branch_patterns=["seer/.+"], tags=["seer-1"], create_only=True)] request = request_for((ZERO_ID, NEW_ID, "refs/heads/seer/fix"), (ZERO_ID, NEW_ID, "refs/tags/seer-1")) assert refused_updates(rules, request) == {} def test_any_rule_allows_an_update(self): # The agent may start branches under seer/ and keep pushing to the one Seer added after its first push. rules = [ - GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), - GrantV2PushRule(repository=5, branches=["seer/fix"]), + PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + PushRule(repository=5, branches=["seer/fix"]), ] request = request_for((OLD_ID, NEW_ID, "refs/heads/seer/fix"), (ZERO_ID, NEW_ID, "refs/heads/seer/next")) assert refused_updates(rules, request) == {} @@ -349,8 +349,8 @@ def test_any_rule_allows_an_update(self): def test_reason_of_the_rule_the_update_came_closest_to_passing(self): rules = [ - GrantV2PushRule(repository=5, branches=["other"]), - GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + PushRule(repository=5, branches=["other"]), + PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), ] assert refused_updates(rules, request_for((OLD_ID, NEW_ID, "refs/heads/seer/old"))) == { b"refs/heads/seer/old": "the git grant allows only creating this branch" @@ -572,6 +572,6 @@ def test_ref_git_would_not_send(self, ref): def test_longest_branch_name(self): ref = "refs/heads/seer/" + "x" * 250 - rules = [GrantV2PushRule(repository=5, branch_patterns=["seer/x+"])] + rules = [PushRule(repository=5, branch_patterns=["seer/x+"])] relayed, _ = self.check(make_push_body((OLD_ID, NEW_ID, ref)), rules=rules) assert ref.encode() in relayed diff --git a/tests/unit/test_git_push_adversarial.py b/tests/unit/test_git_push_adversarial.py index df4f98ae..887fd69a 100644 --- a/tests/unit/test_git_push_adversarial.py +++ b/tests/unit/test_git_push_adversarial.py @@ -8,12 +8,12 @@ from scm.errors import GitMalformedRequestBody, GitOperationNotGranted from scm.git.push import MAX_UPDATES_LENGTH, PushRefused, check_push from scm.git.relay import GitRelay -from scm.git.types import GrantV2PushRule +from scm.git.types import PushRule from tests.unit.git_builders import NEW_ID, OLD_ID, make_push_body, make_repository, pkt from tests.unit.git_upstream import running_upstream from tests.unit.test_git_server import PUSH_GRANT, Recorder, make_server, request -RULES = [GrantV2PushRule(repository=5, branches=["seer/fix"])] +RULES = [PushRule(repository=5, branches=["seer/fix"])] def first_update(capabilities: bytes, ref: bytes = b"refs/heads/seer/fix") -> bytes: @@ -88,7 +88,7 @@ class TestPatternCost: def test_long_branch_name_does_not_pin_the_worker(self): # An ordinary-looking pattern with three wildcards is cubic in the name's length: 1,500 characters take # seconds, and 60,000 would take days. - rules = [GrantV2PushRule(repository=5, branch_patterns=["seer/.+-.+-.+-[0-9]+"])] + rules = [PushRule(repository=5, branch_patterns=["seer/.+-.+-.+-[0-9]+"])] body = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/" + "-" * 1500 + "x")) started = time.perf_counter() with pytest.raises((GitOperationNotGranted, GitMalformedRequestBody)): diff --git a/tests/unit/test_git_push_adversarial_2.py b/tests/unit/test_git_push_adversarial_2.py index 39c787b8..ad987d21 100644 --- a/tests/unit/test_git_push_adversarial_2.py +++ b/tests/unit/test_git_push_adversarial_2.py @@ -7,12 +7,12 @@ from scm.errors import GitMalformedRequestBody, GitOperationNotGranted, RpcMalformedRequestHeaders from scm.git.push import check_push from scm.git.relay import DEFAULT_CHUNK_SIZE, GitRelay -from scm.git.types import GrantV2PushRule +from scm.git.types import PushRule from tests.unit.git_builders import NEW_ID, OLD_ID, make_push_body, make_repository, pkt from tests.unit.git_upstream import running_upstream from tests.unit.test_git_server import PUSH_GRANT, Recorder, make_server, request -RULES = [GrantV2PushRule(repository=5, branches=["seer/fix"])] +RULES = [PushRule(repository=5, branches=["seer/fix"])] ALLOWED_PUSH = make_push_body((OLD_ID, NEW_ID, "refs/heads/seer/fix")) GIT_PROTOCOL_VALUES = (None, "version=0", "version=1") diff --git a/tests/unit/test_git_push_adversarial_3.py b/tests/unit/test_git_push_adversarial_3.py index f3d2124b..9a27b162 100644 --- a/tests/unit/test_git_push_adversarial_3.py +++ b/tests/unit/test_git_push_adversarial_3.py @@ -5,7 +5,7 @@ import pytest from scm.git.push import PushRefused, check_push -from scm.git.types import GrantV2PushRule +from scm.git.types import PushRule from tests.unit.git_builders import NEW_ID, ZERO_ID, make_push_body @@ -22,6 +22,6 @@ class TestPatternDigits: def test_digit_class_does_not_cover_a_lookalike_of_a_release(self, field, ref): # Without re.ASCII, \d matches every Unicode decimal digit, so a rule written for v1.0 would let its holder # create a tag that renders like v1.0 and is a different ref. - rules = [GrantV2PushRule(repository=5, create_only=True, **{field: [r"v\d+\.\d+"]})] + rules = [PushRule(repository=5, create_only=True, **{field: [r"v\d+\.\d+"]})] with pytest.raises(PushRefused): check_push(rules, {}, io.BytesIO(make_push_body((ZERO_ID, NEW_ID, ref)))) diff --git a/tests/unit/test_git_relay.py b/tests/unit/test_git_relay.py index 439677f4..29a1bbf3 100644 --- a/tests/unit/test_git_relay.py +++ b/tests/unit/test_git_relay.py @@ -14,10 +14,8 @@ from requests.adapters import HTTPAdapter from scm.errors import ( - GitOperationNotGranted, GitRemoteNotHttps, GitRemoteRefused, - GitRepositoryNotGranted, GitRouteNotSupported, ResourceBadGateway, ResourceCertificateInvalid, @@ -25,12 +23,10 @@ ResourceServiceUnavailable, SCMCodedError, ) -from scm.git.relay import GitRelay, _authorize, _UpstreamStream, authorize_request -from scm.git.types import GrantV2FetchRule, GrantV2PushRule +from scm.git.relay import GitRelay, _UpstreamStream from scm.secret import Secret from scm.types import GitRemote from tests.unit.error_reports import assert_not_in_frames, assert_not_reported, reported_local -from tests.unit.git_builders import make_grant from tests.unit.git_upstream import ( ClosingAdapter, HostRefused, @@ -738,117 +734,3 @@ def handler(h: UpstreamHandler) -> None: response = fetch_refs(relay, upstream) response.content.close() # type: ignore[attr-defined] assert upstream_saw_close.wait(WAIT) - - -FETCH_ROUTES = [("GET", "info/refs", "service=git-upload-pack"), ("POST", "git-upload-pack", "")] -PUSH_ROUTES = [("GET", "info/refs", "service=git-receive-pack"), ("POST", "git-receive-pack", "")] -FETCH_ANY = GrantV2FetchRule(repository=5, any_object=True) -PUSH_BRANCH = GrantV2PushRule(repository=5, branches=["seer/fix"]) - - -class TestAuthorizeRequest: - @pytest.mark.parametrize("route", FETCH_ROUTES) - def test_fetch_rule_reaches_fetch_routes(self, route): - assert authorize_request(make_grant(rules=[FETCH_ANY]), 5, *route) == "fetch" - - @pytest.mark.parametrize("route", PUSH_ROUTES) - def test_push_rule_reaches_push_routes_through_gitserver(self, route): - # GitServer keeps the push to the rule's branches. - assert _authorize(make_grant(rules=[PUSH_BRANCH]), 5, *route) == "push" - - def test_push_rule_lists_refs(self): - assert authorize_request(make_grant(rules=[PUSH_BRANCH]), 5, *PUSH_ROUTES[0]) == "push" - - def test_push_under_rules_is_refused_outside_gitserver(self): - # Only the push's ref updates show which refs it touches, and this doesn't read them. - with pytest.raises(GitOperationNotGranted, match="goes through GitServer"): - authorize_request(make_grant(rules=[PUSH_BRANCH]), 5, *PUSH_ROUTES[1]) - - @pytest.mark.parametrize("route", PUSH_ROUTES) - def test_fetch_rule_cannot_reach_push_routes(self, route): - with pytest.raises(GitOperationNotGranted, match="allows fetch on this repository, not push"): - authorize_request(make_grant(rules=[FETCH_ANY]), 5, *route) - - @pytest.mark.parametrize("route", FETCH_ROUTES) - def test_push_rule_cannot_reach_fetch_routes(self, route): - with pytest.raises(GitOperationNotGranted, match="allows push on this repository, not fetch"): - authorize_request(make_grant(rules=[PUSH_BRANCH]), 5, *route) - - def test_rule_for_another_repository_does_not_apply(self): - grant = make_grant(rules=[FETCH_ANY, GrantV2PushRule(repository=6, branches=["seer/fix"])]) - with pytest.raises(GitOperationNotGranted, match="allows fetch on this repository, not push"): - authorize_request(grant, 5, "POST", "git-receive-pack", "") - - def test_any_of_several_rules(self): - grant = make_grant( - rules=[ - GrantV2FetchRule(repository=5, branches=["main"]), - FETCH_ANY, - PUSH_BRANCH, - GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), - ] - ) - assert authorize_request(grant, 5, "POST", "git-upload-pack", "") == "fetch" - assert _authorize(grant, 5, "POST", "git-receive-pack", "") == "push" - - def test_fetch_rules_that_all_list_branches_are_refused(self): - grant = make_grant( - rules=[GrantV2FetchRule(repository=5, branches=["main"]), GrantV2FetchRule(repository=5, branches=["a"])] - ) - with pytest.raises(GitOperationNotGranted, match="cannot keep a fetch to a rule's branches"): - authorize_request(grant, 5, "POST", "git-upload-pack", "") - - @pytest.mark.parametrize("route", FETCH_ROUTES) - @pytest.mark.parametrize( - "refs", - [{"branches": ["main"]}, {"tags": ["v1"]}, {"tag_patterns": ["v.+"]}], - ids=["branches", "tags", "tag-patterns"], - ) - def test_fetch_rule_that_names_refs_is_refused(self, route, refs): - # Nothing reads a fetch's body, so nothing would keep the fetch to the rule's branches and tags. - with pytest.raises(GitOperationNotGranted, match="cannot keep a fetch to a rule's branches and tags"): - authorize_request(make_grant(rules=[GrantV2FetchRule(repository=5, **refs)]), 5, *route) - - @pytest.mark.parametrize( - "method,route,query", - [ - ("GET", "info/refs", ""), - ("GET", "info/refs", "service=git-upload-pack&x=1"), - ("GET", "info/refs", "service=git-upload-archive"), - ("POST", "info/refs", "service=git-upload-pack"), - ("GET", "git-upload-pack", ""), - ("POST", "git-upload-pack", "service=git-upload-pack"), - ("post", "git-upload-pack", ""), - ("POST", "git-upload-archive", ""), - ("POST", "info/lfs/objects/batch", ""), - ("GET", "HEAD", ""), - ("GET", "objects/info/packs", ""), - ("GET", "../info/refs", "service=git-upload-pack"), - ("POST", "/git-upload-pack", ""), - ], - ) - def test_routes_that_are_not_git(self, method, route, query): - with pytest.raises(GitRouteNotSupported, match="not a git fetch or push route"): - authorize_request(make_grant(rules=[FETCH_ANY, PUSH_BRANCH]), 5, method, route, query) - - def test_repository_not_in_grant(self): - with pytest.raises(GitRepositoryNotGranted, match="does not cover this repository"): - authorize_request(make_grant(), 6, "POST", "git-upload-pack", "") - - def test_repository_named_another_way_is_not_in_grant(self): - # The rule and the request must name a repository the same way, since matching a database id to a provider - # id would mean resolving every repository the grant names. - grant = make_grant(rules=[GrantV2FetchRule(repository=("github", "123"), any_object=True)]) - with pytest.raises(GitRepositoryNotGranted): - authorize_request(grant, 5, "POST", "git-upload-pack", "") - assert authorize_request(grant, ("github", "123"), "POST", "git-upload-pack", "") == "fetch" - - def test_any_repository_with_a_rule(self): - grant = make_grant( - rules=[ - GrantV2FetchRule(repository=repository, any_object=True) - for repository in (5, 6, ("gitlab", "gitlab.com:7")) - ] - ) - assert authorize_request(grant, 6, "POST", "git-upload-pack", "") == "fetch" - assert authorize_request(grant, ("gitlab", "gitlab.com:7"), "POST", "git-upload-pack", "") == "fetch" diff --git a/tests/unit/test_git_server.py b/tests/unit/test_git_server.py index e831ee28..8b2cef02 100644 --- a/tests/unit/test_git_server.py +++ b/tests/unit/test_git_server.py @@ -13,15 +13,18 @@ from scm.errors import ( ClientClosedRequest, GitGrantKeyError, + GitOperationNotGranted, GitRemoteRefused, + GitRepositoryNotGranted, + GitRouteNotSupported, RateLimitExceeded, ResourceGatewayTimeout, ResourceServiceUnavailable, ) from scm.git.grant import key_id, sign_grant from scm.git.relay import GitRelay, RelayResponse, ResponseBody -from scm.git.server import _STATUS, GitServer -from scm.git.types import GRANT_HEADER, GitOperation, GrantV2FetchRule, GrantV2PushRule, Readable +from scm.git.server import _STATUS, GitServer, _authorize +from scm.git.types import GRANT_HEADER, FetchRule, GitOperation, PushRule, Readable from scm.rpc.types import StreamResponse from scm.secret import Secret from scm.types import GitRemote, Provider, Repository, RepositoryId @@ -247,7 +250,7 @@ def test_operation_the_grant_does_not_allow(self): def test_fetch_rule_that_lists_branches(self): recorder = Recorder() - grant = signed_grant(rules=[GrantV2FetchRule(repository=5, branches=["main"])]) + grant = signed_grant(rules=[FetchRule(repository=5, branches=["main"])]) assert_refused(request(make_server(recorder), grant), 403, "cannot keep a fetch to a rule's branches") assert recorder.repository_lookups == [] assert recorder.token_lookups == [] @@ -741,7 +744,7 @@ def test_token_that_is_not_a_secret_is_refused_and_left_out(self): ] -PUSH_GRANT = sign_grant(KEY.private, make_grant(rules=[GrantV2PushRule(repository=5, branches=["seer/fix"])])) +PUSH_GRANT = sign_grant(KEY.private, make_grant(rules=[PushRule(repository=5, branches=["seer/fix"])])) def push_body(ref: str, capabilities: str = "report-status side-band-64k") -> bytes: @@ -853,8 +856,8 @@ def test_create_only_pattern_and_listed_branch(self): KEY.private, make_grant( rules=[ - GrantV2PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), - GrantV2PushRule(repository=5, branches=["seer/fix"]), + PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + PushRule(repository=5, branches=["seer/fix"]), ] ), ) @@ -905,3 +908,106 @@ def test_version_1_push_grant_is_refused(self): assert_refused(response, 403, "not version 2") assert recorder.token_lookups == [] server.relay.forward.assert_not_called() # type: ignore[attr-defined] + + +FETCH_ROUTES = [("GET", "info/refs", "service=git-upload-pack"), ("POST", "git-upload-pack", "")] +PUSH_ROUTES = [("GET", "info/refs", "service=git-receive-pack"), ("POST", "git-receive-pack", "")] +FETCH_ANY = FetchRule(repository=5, any_object=True) +PUSH_BRANCH = PushRule(repository=5, branches=["seer/fix"]) + + +class TestAuthorize: + @pytest.mark.parametrize("route", FETCH_ROUTES) + def test_fetch_rule_reaches_fetch_routes(self, route): + assert _authorize(make_grant(rules=[FETCH_ANY]), 5, *route) == "fetch" + + @pytest.mark.parametrize("route", PUSH_ROUTES) + def test_push_rule_reaches_push_routes(self, route): + # check_push then keeps the push to the rule's branches. + assert _authorize(make_grant(rules=[PUSH_BRANCH]), 5, *route) == "push" + + @pytest.mark.parametrize("route", PUSH_ROUTES) + def test_fetch_rule_cannot_reach_push_routes(self, route): + with pytest.raises(GitOperationNotGranted, match="allows fetch on this repository, not push"): + _authorize(make_grant(rules=[FETCH_ANY]), 5, *route) + + @pytest.mark.parametrize("route", FETCH_ROUTES) + def test_push_rule_cannot_reach_fetch_routes(self, route): + with pytest.raises(GitOperationNotGranted, match="allows push on this repository, not fetch"): + _authorize(make_grant(rules=[PUSH_BRANCH]), 5, *route) + + def test_rule_for_another_repository_does_not_apply(self): + grant = make_grant(rules=[FETCH_ANY, PushRule(repository=6, branches=["seer/fix"])]) + with pytest.raises(GitOperationNotGranted, match="allows fetch on this repository, not push"): + _authorize(grant, 5, "POST", "git-receive-pack", "") + + def test_any_of_several_rules(self): + grant = make_grant( + rules=[ + FetchRule(repository=5, branches=["main"]), + FETCH_ANY, + PUSH_BRANCH, + PushRule(repository=5, branch_patterns=["seer/.+"], create_only=True), + ] + ) + assert _authorize(grant, 5, "POST", "git-upload-pack", "") == "fetch" + assert _authorize(grant, 5, "POST", "git-receive-pack", "") == "push" + + def test_fetch_rules_that_all_list_branches_are_refused(self): + grant = make_grant(rules=[FetchRule(repository=5, branches=["main"]), FetchRule(repository=5, branches=["a"])]) + with pytest.raises(GitOperationNotGranted, match="cannot keep a fetch to a rule's branches"): + _authorize(grant, 5, "POST", "git-upload-pack", "") + + @pytest.mark.parametrize("route", FETCH_ROUTES) + @pytest.mark.parametrize( + "refs", + [{"branches": ["main"]}, {"tags": ["v1"]}, {"tag_patterns": ["v.+"]}], + ids=["branches", "tags", "tag-patterns"], + ) + def test_fetch_rule_that_names_refs_is_refused(self, route, refs): + # Nothing reads a fetch's body, so nothing would keep the fetch to the rule's branches and tags. + with pytest.raises(GitOperationNotGranted, match="cannot keep a fetch to a rule's branches and tags"): + _authorize(make_grant(rules=[FetchRule(repository=5, **refs)]), 5, *route) + + @pytest.mark.parametrize( + "method,route,query", + [ + ("GET", "info/refs", ""), + ("GET", "info/refs", "service=git-upload-pack&x=1"), + ("GET", "info/refs", "service=git-upload-archive"), + ("POST", "info/refs", "service=git-upload-pack"), + ("GET", "git-upload-pack", ""), + ("POST", "git-upload-pack", "service=git-upload-pack"), + ("post", "git-upload-pack", ""), + ("POST", "git-upload-archive", ""), + ("POST", "info/lfs/objects/batch", ""), + ("GET", "HEAD", ""), + ("GET", "objects/info/packs", ""), + ("GET", "../info/refs", "service=git-upload-pack"), + ("POST", "/git-upload-pack", ""), + ], + ) + def test_routes_that_are_not_git(self, method, route, query): + with pytest.raises(GitRouteNotSupported, match="not a git fetch or push route"): + _authorize(make_grant(rules=[FETCH_ANY, PUSH_BRANCH]), 5, method, route, query) + + def test_repository_not_in_grant(self): + with pytest.raises(GitRepositoryNotGranted, match="does not cover this repository"): + _authorize(make_grant(), 6, "POST", "git-upload-pack", "") + + def test_repository_named_another_way_is_not_in_grant(self): + # The rule and the request must name a repository the same way, since matching a database id to a provider + # id would mean resolving every repository the grant names. + grant = make_grant(rules=[FetchRule(repository=("github", "123"), any_object=True)]) + with pytest.raises(GitRepositoryNotGranted): + _authorize(grant, 5, "POST", "git-upload-pack", "") + assert _authorize(grant, ("github", "123"), "POST", "git-upload-pack", "") == "fetch" + + def test_any_repository_with_a_rule(self): + grant = make_grant( + rules=[ + FetchRule(repository=repository, any_object=True) for repository in (5, 6, ("gitlab", "gitlab.com:7")) + ] + ) + assert _authorize(grant, 6, "POST", "git-upload-pack", "") == "fetch" + assert _authorize(grant, ("gitlab", "gitlab.com:7"), "POST", "git-upload-pack", "") == "fetch"