diff --git a/README.md b/README.md index 1777685..2547b6f 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,11 @@ The SCM (Source Code Management) platform is a vendor-agnostic abstraction layer ### Features -The platform exposes three subsystems: +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 `scm.git.server.GitServer` checks each git smart-HTTP request against it before relaying the request to the provider with a token, streaming both bodies. - **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/pyproject.toml b/pyproject.toml index 673740a..5e32186 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,11 @@ authors = [ requires-python = ">=3.13" dependencies = [ "msgspec>=0.19.0", + "pyjwt>=2.9.0", "requests>=2.32.4", + # The git relay streams responses through urllib3 directly. Before 2.8.0, a server could make its chunked + # reader buffer without limit (GHSA-vxq7-64xx-v4gw). + "urllib3>=2.8.0", ] [build-system] @@ -50,6 +54,7 @@ select = [ [dependency-groups] dev = [ + "cryptography>=48.0.1", "mypy>=1.20.0", "prek>=0.3.6", "pyjwt[crypto]>=2.9.0", diff --git a/src/scm/errors.py b/src/scm/errors.py index 0d10b79..cb40781 100644 --- a/src/scm/errors.py +++ b/src/scm/errors.py @@ -1,6 +1,15 @@ from typing import ClassVar, Literal type ErrorCode = Literal[ + "git_grant_expired", + "git_invalid_grant", + "git_operation_not_granted", + "git_provider_not_supported", + "git_remote_not_https", + "git_repository_not_granted", + "git_route_not_supported", + "git_token_rejected", + "git_token_unavailable", "malformed_external_id", "missing_repository_path", "path_is_directory", @@ -26,6 +35,7 @@ "resource_unprocessable_content", "resource_server_error", "resource_bad_gateway", + "resource_certificate_invalid", "resource_service_unavailable", "resource_gateway_timeout", "unexpected_response_format", @@ -38,6 +48,15 @@ ] 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_operation_not_granted": "The git grant does not allow this operation.", + "git_provider_not_supported": "The repository's provider has no git remote.", + "git_remote_not_https": "The repository's git remote is not an https URL.", + "git_repository_not_granted": "The git grant does not cover this repository.", + "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.", "malformed_external_id": "The repository's external ID was malformed.", "missing_repository_path": "No path was given for the repository.", "path_is_directory": "The requested path is a directory, not a file.", @@ -63,6 +82,7 @@ "resource_unprocessable_content": "Request could not be processed.", "resource_server_error": "The service-provider encountered an internal error.", "resource_bad_gateway": "The service-provider returned an invalid response from an upstream gateway.", + "resource_certificate_invalid": "The service-provider's TLS certificate could not be verified.", "resource_service_unavailable": "The service-provider is temporarily unavailable. The request can be retried.", "resource_gateway_timeout": "The service-provider did not respond in time. The request can be retried.", "unexpected_response_format": "The response format was in an unexpected format.", @@ -125,6 +145,46 @@ def from_code(cls, code: ErrorCode, detail: str | None = None) -> "SCMCodedError return error_cls(detail=detail) +class GitSCMCodedError(SCMCodedError): + """Base class for the errors of the git endpoint (``scm.git``), so a caller can catch them all at once.""" + + +class GitGrantExpired(GitSCMCodedError): + code = "git_grant_expired" + + +class GitInvalidGrant(GitSCMCodedError): + code = "git_invalid_grant" + + +class GitOperationNotGranted(GitSCMCodedError): + code = "git_operation_not_granted" + + +class GitProviderNotSupported(GitSCMCodedError): + code = "git_provider_not_supported" + + +class GitRemoteNotHttps(GitSCMCodedError): + code = "git_remote_not_https" + + +class GitRepositoryNotGranted(GitSCMCodedError): + code = "git_repository_not_granted" + + +class GitRouteNotSupported(GitSCMCodedError): + code = "git_route_not_supported" + + +class GitTokenRejected(GitSCMCodedError): + code = "git_token_rejected" + + +class GitTokenUnavailable(GitSCMCodedError): + code = "git_token_unavailable" + + class MalformedExternalId(SCMCodedError): code = "malformed_external_id" @@ -233,6 +293,10 @@ class ResourceBadGateway(SCMCodedError): code = "resource_bad_gateway" +class ResourceCertificateInvalid(SCMCodedError): + code = "resource_certificate_invalid" + + class ResourceServiceUnavailable(SCMCodedError): code = "resource_service_unavailable" diff --git a/src/scm/git/grant.py b/src/scm/git/grant.py new file mode 100644 index 0000000..89586d2 --- /dev/null +++ b/src/scm/git/grant.py @@ -0,0 +1,102 @@ +"""Signing and verifying git grants. + +A grant travels as an HS256 JWT, the format Sentry and Seer already sign viewer contexts with. The grant's fields +are claims, alongside ``exp``, ``iat`` and an ``aud`` of ``scm-git-grant``. The verifier requires that audience, +so no other token signed with the same secret, such as a viewer context, passes as a grant. A ``kid`` header +names the secret that signed the grant, as in Sentry's viewer contexts, so the verifier can hold several secrets +while they rotate. +""" + +import hashlib +import time +from typing import Any + +import jwt +import msgspec + +from scm.errors import GitGrantExpired, GitInvalidGrant +from scm.git.types import GRANT_MAX_LENGTH, Grant + +GRANT_AUDIENCE = "scm-git-grant" +_ALGORITHM = "HS256" +# Claims the JWT layer owns. Every other claim must be a Grant field. +_JWT_CLAIMS = frozenset({"aud", "exp", "iat"}) + + +def sign_grant(secret: str, grant: Grant, *, now: float | None = None) -> str: + # HMAC accepts an empty key, so an empty secret, say from an unset setting, would let anyone sign a grant. + if not secret: + raise ValueError("A git grant secret is empty.") + claims: dict[str, Any] = msgspec.to_builtins(grant) + claims["exp"] = claims.pop("expires_at") + claims["iat"] = int(time.time() if now is None else now) + claims["aud"] = GRANT_AUDIENCE + token = jwt.encode(claims, secret, algorithm=_ALGORITHM, headers={"kid": _key_id(secret)}) + # Refused here, the signer's tests catch a grant that is too long, rather than the sandbox router in production. + if len(token) > GRANT_MAX_LENGTH: + raise ValueError( + f"The git grant is {len(token)} characters, over GRANT_MAX_LENGTH ({GRANT_MAX_LENGTH}). It travels in a " + "request header, and sandbox-router refuses a longer one." + ) + return token + + +def verify_grant(secrets: list[str], token: str, *, now: float | None = None) -> Grant: + """Return the grant if one of ``secrets`` signed it and it has not expired. + + Verification takes a list of secrets, as ``RpcServer`` does, so a new secret can be added before signers + switch to it and the old one removed after their grants expire. + """ + # As in sign_grant: a grant signed with an empty secret proves nothing. + if not all(secrets): + raise ValueError("A git grant secret is empty.") + now = time.time() if now is None else now + if not token: + raise GitInvalidGrant(detail="The git grant is missing.") + try: + kid = jwt.get_unverified_header(token).get("kid") + except jwt.DecodeError: + raise GitInvalidGrant(detail="The git grant is malformed.") from None + secret = next((secret for secret in secrets if _key_id(secret) == kid), None) + if secret is None: + raise GitInvalidGrant(detail="The git grant was not signed with a known secret.") + + try: + claims = jwt.decode( + token, + secret, + algorithms=[_ALGORITHM], + audience=GRANT_AUDIENCE, + # exp is checked below against an injectable clock, so tests can pin it. PyJWT checks iat, which it + # also requires to be a number. + options={"require": sorted(_JWT_CLAIMS), "verify_exp": False}, + # How far in the future iat may be, in seconds. Seer signs grants on other hosts than the one verifying + # them, and a clock running a little ahead should not get its grants refused. + leeway=60, + ) + except jwt.InvalidSignatureError: + raise GitInvalidGrant(detail="The git grant's signature does not match.") from None + except jwt.DecodeError: + raise GitInvalidGrant(detail="The git grant is malformed.") from None + except jwt.InvalidTokenError as e: + # The signature matched or was never reached, and PyJWT's reasons name only claims, not values. + 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} + try: + grant = msgspec.convert({**fields, "expires_at": claims["exp"]}, type=Grant, strict=True) + except msgspec.ValidationError as e: + # The signature matched, so a holder of the secret wrote these claims; 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 not grant.repositories: + raise GitInvalidGrant(detail="The git grant names no repositories.") + return grant + + +def _key_id(secret: str) -> str: + # The first 8 hex characters of the secret's SHA-256, the same key id Sentry's viewer contexts use. + return hashlib.sha256(secret.encode()).hexdigest()[:8] diff --git a/src/scm/git/relay.py b/src/scm/git/relay.py new file mode 100644 index 0000000..1d60ed8 --- /dev/null +++ b/src/scm/git/relay.py @@ -0,0 +1,319 @@ +from collections.abc import Callable, Iterator, Mapping +from typing import Protocol + +import requests +import urllib3 +from requests.adapters import HTTPAdapter +from requests.auth import HTTPBasicAuth +from requests.structures import CaseInsensitiveDict + +from scm.errors import ( + ClientClosedRequest, + GitOperationNotGranted, + GitRemoteNotHttps, + GitRepositoryNotGranted, + GitRouteNotSupported, + ResourceBadGateway, + ResourceCertificateInvalid, + ResourceGatewayTimeout, + ResourceServiceUnavailable, + RpcMalformedRequestHeaders, +) +from scm.git.types import GitOperation, Grant, Readable +from scm.rpc.types import StreamResponse +from scm.types import GitRemote, RepositoryId + +# 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; +# larger ones hold more memory per request. 64 KiB is what RpcServer streams with and what the prototype ran; no +# other size was measured. +DEFAULT_CHUNK_SIZE = 64 * 1024 +# (connect, read) in seconds. The read timeout bounds a silence, not a transfer: it is the longest the provider may +# go without sending a byte, so a clone that streams for minutes never hits it. 300s leaves room for a provider that +# assembles a large pack before it sends anything. Both values come from the prototype. +DEFAULT_TIMEOUT = (10.0, 300.0) + +# The git smart-HTTP routes and the operation each belongs to, keyed by (method, path below .git/, query +# string). git names its two services from the server's side: in a fetch the server uploads a pack to the client +# (git-upload-pack), and in a push it receives a pack from the client (git-receive-pack). Each service has a GET +# that lists the refs and a POST that does the transfer. +# +# The query string is compared whole instead of parsed. git sends exactly these strings, so a request with extra +# or repeated parameters is refused rather than passed on to a provider that might read them differently. +_ROUTES: dict[tuple[str, str, str], GitOperation] = { + ("GET", "info/refs", "service=git-upload-pack"): "fetch", + ("POST", "git-upload-pack", ""): "fetch", + ("GET", "info/refs", "service=git-receive-pack"): "push", + ("POST", "git-receive-pack", ""): "push", +} + +# The headers git sends that the provider needs. Accept and Content-Type name the git service, Git-Protocol +# negotiates protocol v2, Content-Encoding marks a gzipped request body (git gzips large fetch requests), +# Accept-Encoding says what the client can decode, and User-Agent names the git client. Everything else stays +# behind, including the caller's own credentials and the grant. +_REQUEST_HEADERS = frozenset( + {"accept", "accept-encoding", "content-encoding", "content-type", "git-protocol", "user-agent"} +) +# Response bodies are passed on without decoding, so Content-Encoding and Content-Length still describe the bytes +# the client receives. Location is dropped because following a redirect would take the client to the provider +# directly, past the relay. +_RESPONSE_HEADERS = frozenset( + {"cache-control", "content-encoding", "content-length", "content-type", "expires", "pragma"} +) + + +def authorize_request(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. + + 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. + """ + 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.") + return operation + + +class ResponseBody(Iterator[bytes], Protocol): + """The provider's response body. Closing it before it ends drops the upstream connection.""" + + def close(self) -> None: ... + + +class RelayResponse(StreamResponse): + """The provider's response, whose body GitServer closes when it no longer needs it.""" + + content: ResponseBody + + +class GitRelay: + """Forwards git smart-HTTP requests to a provider with a token, streaming the body in both directions. + + Nothing buffers a request or a response, and neither body is parsed. + + Each request opens its own connection and closes it when the response ends, so nothing one organization's + request leaves behind, such as a cookie or an unread response, can reach another's. A clone's three requests + each pay a TCP and TLS handshake. Over the prototype's slow local link that added hundreds of milliseconds per + clone; from Sentry's servers it should be small. + + Only https remotes are accepted, because every request carries a provider token. + + ``adapter`` builds the adapter that sends each upstream request. Pass one that refuses internal addresses: a + GitHub Enterprise or self-managed GitLab host is whatever the customer configured, and the relay connects + wherever it resolves. Sentry's ``BlacklistAdapter`` does this for its API clients, and the class itself can be + passed. An error the adapter raises for a refused host reaches the caller unchanged, unless it is a requests or + urllib3 exception, which the relay replaces like any other so that it cannot carry the token. The adapter must + not retry, because a retry cannot resend a body that has already been streamed. Without one, the relay uses a plain + ``HTTPAdapter``, which checks nothing. + + The relay takes no settings from the environment. Through a proxy from ``HTTPS_PROXY``, the adapter would + check the proxy's address rather than the provider's. ``ca_bundle`` names a CA file to trust instead of the + system's, in place of ``REQUESTS_CA_BUNDLE``. + """ + + def __init__( + self, + *, + adapter: Callable[[], HTTPAdapter] = HTTPAdapter, + ca_bundle: str | None = None, + timeout: tuple[float, float] = DEFAULT_TIMEOUT, + chunk_size: int = DEFAULT_CHUNK_SIZE, + ) -> None: + self.adapter = adapter + self.ca_bundle = ca_bundle + self.timeout = timeout + self.chunk_size = chunk_size + + def forward( + self, + remote: GitRemote, + token: str, + *, + method: str, + route: str, + query: str, + headers: Mapping[str, str], + body: Readable | None, + ) -> RelayResponse: + """Send one request to ``remote`` and return the provider's response as it arrives. + + ``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. + + Errors carry no URL, because the client has no use for the provider's address. + """ + if not remote["url"].startswith("https://"): + # 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"{method} {route} is not a git fetch or push route.") + if not f"{remote['basic_auth_username']}:{token}".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. + raise ValueError(f"The credentials for {remote['url']} contain characters that are not ASCII.") + + client_headers = CaseInsensitiveDict(headers) + upstream_headers = CaseInsensitiveDict( + {name: value for name, value in client_headers.items() if name.lower() in _REQUEST_HEADERS} + ) + # Otherwise requests asks for gzip, deflate and more, and since the response is passed on undecoded the + # client could get an encoding it cannot read. + upstream_headers.setdefault("Accept-Encoding", "identity") + + # Of the git routes only the POSTs carry a body. A caller may pass its request stream for a GET too, and sent + # on, it would go upstream as an empty chunked body. + data: Iterator[bytes] | _SizedBody | None = None + if method == "POST" and body is not None: + raw_length = client_headers.get("Content-Length") + if raw_length is None: + # requests sends a generator with chunked transfer encoding, the way git sends a large push. + data = _read_until_eof(body, self.chunk_size) + else: + 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") + if length > 0: + data = _SizedBody(body, length, self.chunk_size) + + url = f"{remote['url']}/{route}" + if query: + url += f"?{query}" + session = self._session() + upstream = None + try: + upstream = session.request( + method, + url, + headers=upstream_headers, + data=data, + auth=HTTPBasicAuth(remote["basic_auth_username"], token), + stream=True, + # Following a redirect would send the request somewhere other than the repository's remote. + allow_redirects=False, + timeout=self.timeout, + ) + # "from None" drops requests' exception, whose request holds the Authorization header. + except requests.Timeout: + raise ResourceGatewayTimeout() from None + # SSLError is a kind of ConnectionError, so catch it before that one, or a bad certificate would be + # reported as an unreachable host. + except requests.exceptions.SSLError: + raise ResourceCertificateInvalid() from None + except requests.ConnectionError: + raise ResourceServiceUnavailable() from None + # HTTPAdapter re-raises the urllib3 errors it does not translate, which are not RequestExceptions. + except (requests.RequestException, urllib3.exceptions.HTTPError): + # Any other failure of the request, e.g. a malformed response. A requests exception, like those above, + # holds the request. + raise ResourceBadGateway() from None + finally: + # Once a response arrives, _stream closes the session when the response ends. + if upstream is None: + session.close() + + return RelayResponse( + status_code=upstream.status_code, + headers={name: value for name, value in upstream.headers.items() if name.lower() in _RESPONSE_HEADERS}, + content=_UpstreamStream(session, upstream, self.chunk_size), + ) + + def _session(self) -> requests.Session: + session = _NoRedirectSession() + # Without this, requests reads proxies, CA files and .netrc credentials from the environment. + session.trust_env = False + session.verify = self.ca_bundle or True + session.mount("https://", self.adapter()) + return session + + +class _NoRedirectSession(requests.Session): + """A session that never looks for a redirect. + + Even with redirects off, requests prepares the next request of a redirect response for ``Response.next``, and + reads the whole response body into memory first, however large the provider makes it. + """ + + def get_redirect_target(self, resp: requests.Response) -> None: + return None + + +class _UpstreamStream: + """The provider's response body, which closes the response and its session when it ends or is closed. + + A generator holding them in a ``with`` would not do: closing a generator that never started does not run its + body, so a response nobody read, such as a 401 that GitServer replaces, would keep its connection open. + """ + + def __init__(self, session: requests.Session, upstream: requests.Response, chunk_size: int) -> None: + self.session = session + self.upstream = upstream + self.chunks = upstream.raw.stream(chunk_size, decode_content=False) + + def __iter__(self) -> "_UpstreamStream": + return self + + def __next__(self) -> bytes: + try: + while not (chunk := next(self.chunks)): + pass + except Exception: + # The end of the body (StopIteration), or the provider's stream breaking. + self.close() + raise + return chunk + + def close(self) -> None: + # Closing the response first drops its connection if the body was not read to the end; closing the session + # then closes a connection the finished response returned to it. + self.upstream.close() + self.session.close() + + +def _read_until_eof(body: Readable, chunk_size: int) -> Iterator[bytes]: + while chunk := _read(body, chunk_size): + yield chunk + + +def _read(body: Readable, size: int) -> bytes: + try: + return body.read(size) + except OSError: + # urllib3 ignores ECONNRESET and EPIPE while it sends a body, taking them for the provider closing the + # connection, and would then wait out the read timeout for a response to the cut-off request. Raised as + # anything other than an OSError, the client's reset ends the request at once. + raise ClientClosedRequest(detail="The client's connection failed while it sent its body.") from None + + +class _SizedBody: + """A body of known length, read as it is sent. + + requests sends Content-Length for anything with a ``__len__`` and streams anything iterable, so this passes + the client's length on without reading the body first. Reading stops at the declared length. + """ + + def __init__(self, body: Readable, length: int, chunk_size: int) -> None: + self.body = body + self.length = length + self.chunk_size = chunk_size + + def __len__(self) -> int: + return self.length + + def __iter__(self) -> Iterator[bytes]: + remaining = self.length + while remaining > 0: + chunk = _read(self.body, min(self.chunk_size, remaining)) + if not chunk: + raise ClientClosedRequest(detail="The client closed the request before sending its whole body.") + remaining -= len(chunk) + yield chunk diff --git a/src/scm/git/server.py b/src/scm/git/server.py new file mode 100644 index 0000000..ba7a851 --- /dev/null +++ b/src/scm/git/server.py @@ -0,0 +1,242 @@ +from collections.abc import Callable, Mapping +from urllib.parse import urlsplit + +from requests.structures import CaseInsensitiveDict + +from scm.errors import ErrorCode, GitProviderNotSupported, GitTokenRejected, GitTokenUnavailable, SCMCodedError +from scm.git.grant import verify_grant +from scm.git.relay import GitRelay, ResponseBody, authorize_request +from scm.git.types import GRANT_HEADER, GetToken, Readable +from scm.helpers import initialize_provider +from scm.rpc.types import StreamResponse +from scm.types import GetGitRemoteProtocol, Provider, Repository, RepositoryId + +# The status of each refusal GitServer answers itself. None is a 401: on a 401 git asks the user for credentials, +# which a sandboxed client must never have and which would not help. +_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_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, + # The grant allows it, but there is no repository to serve. + "repository_not_found": 404, + "repository_inactive": 404, + "repository_organization_mismatch": 404, + "provider_not_found": 404, + "git_provider_not_supported": 404, + "client_closed_request": 499, + # Sentry's record of the repository is incomplete. + "malformed_external_id": 500, + "missing_repository_path": 500, + # The provider cannot be reached safely, or failed. + "git_remote_not_https": 502, + "git_token_rejected": 502, + "git_token_unavailable": 502, + "resource_bad_gateway": 502, + "resource_certificate_invalid": 502, + "resource_service_unavailable": 503, + "resource_gateway_timeout": 504, +} + + +class GitServer: + """Serves a git endpoint: checks each request against its grant, then relays it with a provider token. + + The order of the checks matters. The grant is verified, and the request's route and repository checked + against it, before the repository and its provider are looked up or a token fetched, so a request the grant + does not allow never reaches the database, the token supplier or the provider. Give it a relay whose adapter + refuses internal addresses; see ``GitRelay``. + + The repository's provider says where its git endpoint is, through ``GetGitRemoteProtocol``. A repository whose + provider has no git remote is refused. + + Refusals and provider failures come back as plain-text responses, and each is passed to ``emit_error`` as + well: the client only shows the message, so the server reports it rather than count on every client to. The + message leaves out where the request was going, so ``emit_error`` also gets what the request had resolved + when it failed, as tags: ``repository_id`` as requested, then ``organization_id`` and ``referrer`` once the + grant is verified, ``operation``, ``provider`` and ``remote_host``. A tag is missing when the request failed + before it was known. A client closing its request is not reported, since there is nothing to fix. Any other + exception, such as an adapter refusing a host or the token supplier failing, reaches the caller unchanged, so + the caller can answer it the way it answers the same error elsewhere. + + Every count is tagged with the grant's ``referrer`` once the grant is verified, so Sentry can tell a coding + job's clone from Seer's own fetch or push. + + A relayed request is counted when its response stream ends, not when its headers arrive, so a clone that + fails halfway is counted as failed: ``stream_interrupted`` when the provider's stream breaks and + ``client_closed_request`` when the client goes away first. A response the provider sends with a status other + than 2xx is passed on to the client but counted as failed too, as ``provider_error``. The bytes sent each way + are counted then too, as ``request_size`` and ``response_size``. + """ + + def __init__( + self, + secrets: list[str], + fetch_repository: Callable[[int, RepositoryId], Repository | None], + fetch_provider: Callable[[int, Repository], Provider | None], + get_token: GetToken, + record_count: Callable[[str, int, dict[str, str]], None], + emit_error: Callable[[Exception, dict[str, str]], None], + relay: GitRelay, + ) -> None: + # Fail when the server is built rather than on every request, because no grant could ever verify. + if not secrets or not all(secrets): + raise ValueError("GitServer needs at least one secret, and none may be empty.") + self.secrets = secrets + self.fetch_repository = fetch_repository + self.fetch_provider = fetch_provider + self.get_token = get_token + self.record_count = record_count + self.emit_error = emit_error + self.relay = relay + + def handle( + self, + *, + method: str, + repository_id: RepositoryId, + route: str, + query: str, + headers: Mapping[str, str], + body: Readable | None, + ) -> StreamResponse: + """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. + """ + counted_body = _CountingReader(body) if body is not None else None + context = {"repository_id": str(repository_id)} + referrer_tag: dict[str, str] = {} + try: + grant = verify_grant(self.secrets, CaseInsensitiveDict(headers).get(GRANT_HEADER, "")) + 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 + provider = initialize_provider( + grant.organization_id, + repository_id, + fetch_repository=self.fetch_repository, + fetch_provider=self.fetch_provider, + ) + repository = provider.repository + context["provider"] = repository["provider_name"] + if not isinstance(provider, GetGitRemoteProtocol): + raise GitProviderNotSupported(detail=f"The {repository['provider_name']} provider has no git remote.") + remote = provider.get_git_remote() + # The host and port only: a URL can carry credentials before the host. + context["remote_host"] = urlsplit(remote["url"]).netloc.rpartition("@")[2] + token = self.get_token(repository, operation) + if token is None: + raise GitTokenUnavailable( + detail=f"Sentry could not get a {repository['provider_name']} token for this repository." + ) + response = self.relay.forward( + remote, token, method=method, route=route, query=query, headers=headers, body=counted_body + ) + if response.status_code == 401: + # The provider rejected the token Sentry supplied. Passed on, the 401 would make git ask the client + # for credentials, which it must never have and which would not help. + response.content.close() + raise GitTokenRejected(detail="The provider rejected Sentry's credentials for this repository.") + except SCMCodedError as e: + 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) + return _error_response(e) + except Exception: + self.record_count("sentry.scm.git.request.failed", 1, {"code": "unhandled_exception"} | referrer_tag) + raise + + tags: dict[str, str] = {"operation": operation, "provider": repository["provider_name"]} | referrer_tag + + def finish(failure: str | None, bytes_sent: int) -> None: + if failure is None and not 200 <= response.status_code < 300: + # The provider refused the request itself, e.g. a 404 for a repository the token cannot see. git + # shows the client the provider's message, but did not get what it asked for. + failure = "provider_error" + if failure is None: + self.record_count("sentry.scm.git.request.succeeded", 1, tags | {"status": str(response.status_code)}) + else: + self.record_count( + "sentry.scm.git.request.failed", 1, tags | {"status": str(response.status_code), "code": failure} + ) + if counted_body is not None and counted_body.bytes_read: + self.record_count("sentry.scm.git.request_size", counted_body.bytes_read, tags) + if bytes_sent: + self.record_count("sentry.scm.git.response_size", bytes_sent, tags) + + return StreamResponse( + status_code=response.status_code, + headers=response.headers, + content=_MeteredStream(response.content, finish), + ) + + +class _CountingReader: + """The client's request body, counting the bytes the relay reads from it.""" + + def __init__(self, body: Readable) -> None: + self.body = body + self.bytes_read = 0 + + def read(self, size: int = -1, /) -> bytes: + data = self.body.read(size) + self.bytes_read += len(data) + return data + + +class _MeteredStream: + """The relay's response stream, reporting once how it ended and how many bytes it passed on.""" + + def __init__(self, content: ResponseBody, finish: Callable[[str | None, int], None]) -> None: + self.content = content + self.finish = finish + self.bytes_sent = 0 + self.finished = False + + def __iter__(self) -> "_MeteredStream": + return self + + def __next__(self) -> bytes: + try: + chunk = next(self.content) + except StopIteration: + self._end(None) + raise + except Exception: + self._end("stream_interrupted") + raise + self.bytes_sent += len(chunk) + return chunk + + def close(self) -> None: + # The server calls this after the last chunk too, when the stream has already ended. + self._end("client_closed_request") + # Closing the relay's stream before it is read to the end drops the upstream connection. + self.content.close() + + def _end(self, failure: str | None) -> None: + if not self.finished: + self.finished = True + self.finish(failure, self.bytes_sent) + + +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) + # When its first request fails, git shows a text/plain body to the user as "remote: ". + content = f"{error.detail}\n".encode() + return StreamResponse( + status_code=status, + headers={"Content-Type": "text/plain; charset=utf-8", "Content-Length": str(len(content))}, + content=iter([content]), + ) diff --git a/src/scm/git/types.py b/src/scm/git/types.py new file mode 100644 index 0000000..fd503ac --- /dev/null +++ b/src/scm/git/types.py @@ -0,0 +1,58 @@ +from collections.abc import Callable +from typing import Literal, Protocol + +import msgspec + +from scm.types import Referrer, Repository, RepositoryId + +type GitOperation = Literal["fetch", "push"] +"""What a grant lets its holder do. ``scm.git.relay`` maps each to its git routes.""" + +GRANT_VERSION: Literal[1] = 1 + +GRANT_HEADER = "X-Scm-Git-Grant" +"""The request header that carries a signed grant to the git endpoint.""" + +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 400 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.""" + + +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. + + 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] = GRANT_VERSION + """The format version. A verifier refuses a version it does not know.""" + + +type GetToken = Callable[[Repository, GitOperation], str | None] +"""Return a provider token that can run ``operation`` on ``repository``, or None if there is none.""" + + +class Readable(Protocol): + """A request body the relay pulls from as it sends upstream.""" + + def read(self, size: int = -1, /) -> bytes: ... diff --git a/tests/unit/git_builders.py b/tests/unit/git_builders.py new file mode 100644 index 0000000..fab2c16 --- /dev/null +++ b/tests/unit/git_builders.py @@ -0,0 +1,54 @@ +"""Builders shared by the git module's tests.""" + +from unittest.mock import MagicMock + +from scm.git.types import Grant +from scm.providers.cursor_origin.provider import CursorOriginProvider +from scm.providers.github.provider import GitHubProvider +from scm.providers.gitlab.provider import GitLabProvider +from scm.types import ApiClient, Provider, Repository + +# Unix seconds long after any test runs, for grants a test doesn't expect to expire. +FAR_FUTURE = 2**40 + + +def make_repository(**overrides) -> Repository: + defaults: Repository = { + "id": 5, + "external_id": "123", + "integration_id": 1, + "is_active": True, + "name": "getsentry/sentry", + "organization_id": 1, + "provider_name": "github", + "web_base_url": None, + "installation_id": None, + } + return {**defaults, **overrides} # type: ignore[typeddict-item] + + +def make_provider(organization_id: int, repository: Repository) -> Provider | None: + """Build the repository's provider the way Sentry's ``fetch_provider`` does, with a client nothing calls.""" + client = MagicMock(spec=ApiClient) + match repository["provider_name"]: + case "github" | "github_enterprise": + return GitHubProvider(client, organization_id, repository) + case "gitlab": + return GitLabProvider(client, organization_id, repository) + case "cursor_origin": + return CursorOriginProvider(client, organization_id, repository, installation_id="1") + case _: + return None + + +def make_grant(**overrides) -> Grant: + """A 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 Grant(**fields) # type: ignore[arg-type] diff --git a/tests/unit/git_upstream.py b/tests/unit/git_upstream.py new file mode 100644 index 0000000..71cdb30 --- /dev/null +++ b/tests/unit/git_upstream.py @@ -0,0 +1,201 @@ +"""A local HTTPS server standing in for a provider's git endpoint in relay tests. + +The relay only sends tokens over https, so the server uses a self-signed certificate made for the test run. A relay +trusts it with ``GitRelay(ca_bundle=ca_bundle())``. +""" + +import base64 +import datetime +import functools +import ipaddress +import ssl +import sys +import tempfile +import threading +from collections.abc import Callable, Iterator +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID +from requests.adapters import HTTPAdapter +from requests.structures import CaseInsensitiveDict + + +@dataclass +class ReceivedRequest: + method: str + path: str + headers: CaseInsensitiveDict[str] + body: bytes + + +type Handler = Callable[["UpstreamHandler"], None] + + +@dataclass +class Upstream: + url: str + ca_bundle: str + requests: list[ReceivedRequest] = field(default_factory=list) + connections: int = 0 + handler: Handler | None = None + + +class UpstreamHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server: "_Server" + + def setup(self) -> None: + super().setup() + self.server.upstream.connections += 1 + + def do_GET(self) -> None: + self._dispatch() + + def do_POST(self) -> None: + self._dispatch() + + def _dispatch(self) -> None: + handler = self.server.upstream.handler or echo + handler(self) + + def body_chunks(self) -> Iterator[bytes]: + """Yield the request body as it arrives, one HTTP chunk or one read at a time.""" + if self.headers.get("Transfer-Encoding") == "chunked": + while (size := int(self._chunk_size_line(), 16)) > 0: + yield self.rfile.read(size) + self.rfile.readline() + self.rfile.readline() + else: + remaining = int(self.headers.get("Content-Length", "0")) + while remaining > 0: + chunk = self.rfile.read1(remaining) # type: ignore[attr-defined] + remaining -= len(chunk) + yield chunk + + def _chunk_size_line(self) -> bytes: + line = self.rfile.readline() + if not line: + # The relay gave up on the request partway through its body, as some tests make it do. + raise ConnectionResetError("the client closed the connection mid-body") + return line.strip() + + def record(self, body: bytes) -> None: + self.server.upstream.requests.append( + ReceivedRequest( + method=self.command, path=self.path, headers=CaseInsensitiveDict(self.headers.items()), body=body + ) + ) + + def respond(self, status: int, body: bytes, headers: dict[str, str] | None = None) -> None: + self.send_response(status) + for name, value in (headers or {}).items(): + self.send_header(name, value) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def start_chunked(self, status: int, headers: dict[str, str] | None = None) -> None: + self.send_response(status) + for name, value in (headers or {}).items(): + self.send_header(name, value) + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + + def write_chunk(self, data: bytes) -> None: + self.wfile.write(f"{len(data):x}\r\n".encode() + data + b"\r\n") + self.wfile.flush() + + def log_message(self, format: str, *args: object) -> None: + pass + + +class _Server(ThreadingHTTPServer): + daemon_threads = True + upstream: Upstream + + def handle_error(self, request: object, client_address: object) -> None: + # Some tests have the relay drop the connection mid-request on purpose (a timeout, a short body), and the + # assertions are on the relay's side, so a reset here is expected rather than worth a traceback. + if isinstance(sys.exc_info()[1], ConnectionError): + return + super().handle_error(request, client_address) # type: ignore[arg-type] + + +class ClosingAdapter(HTTPAdapter): + """An adapter that records whether it was closed, which closes every connection it opened.""" + + def __init__(self) -> None: + super().__init__() + self.closed = False + + def close(self) -> None: + self.closed = True + super().close() + + +def echo(handler: UpstreamHandler) -> None: + body = b"".join(handler.body_chunks()) + handler.record(body) + handler.respond(200, body, {"Content-Type": "application/x-git-upload-pack-result"}) + + +def basic_auth(username: str, password: str) -> str: + return "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode() + + +@functools.cache +def _certificate() -> tuple[str, str]: + """Return the paths of a self-signed certificate for 127.0.0.1 and its key, made once per test run.""" + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")]) + now = datetime.datetime.now(datetime.UTC) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(key, hashes.SHA256()) + ) + directory = Path(tempfile.mkdtemp(prefix="scm-git-upstream-")) + cert_path, key_path = directory / "cert.pem", directory / "key.pem" + cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes( + key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + ) + return str(cert_path), str(key_path) + + +def ca_bundle() -> str: + """The path of the certificate every fake upstream in this test run serves.""" + return _certificate()[0] + + +class running_upstream: + """Context manager that serves an ``Upstream`` on a free local port.""" + + def __enter__(self) -> Upstream: + cert_path, key_path = _certificate() + context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.load_cert_chain(cert_path, key_path) + self._server = _Server(("127.0.0.1", 0), UpstreamHandler) + self._server.socket = context.wrap_socket(self._server.socket, server_side=True) + host, port = self._server.server_address[:2] + self._server.upstream = Upstream(url=f"https://{host!s}:{port}", ca_bundle=cert_path) + self._thread = threading.Thread(target=self._server.serve_forever, args=(0.01,), daemon=True) + self._thread.start() + return self._server.upstream + + def __exit__(self, *exc: object) -> None: + self._server.shutdown() + self._server.server_close() diff --git a/tests/unit/test_git_grant.py b/tests/unit/test_git_grant.py new file mode 100644 index 0000000..cea9fd3 --- /dev/null +++ b/tests/unit/test_git_grant.py @@ -0,0 +1,234 @@ +import base64 +import hashlib +import time + +import jwt +import msgspec +import pytest + +from scm.errors import GitGrantExpired, GitInvalidGrant +from scm.git.grant import GRANT_AUDIENCE, sign_grant, verify_grant +from scm.git.types import GRANT_MAX_LENGTH, Grant +from tests.unit.git_builders import FAR_FUTURE, make_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 +# HS256 secrets of at least 32 bytes, as PyJWT recommends. +SECRET = "test-secret-0000000000000000000000" +OLD_SECRET = "test-secret-old-000000000000000000" +NEW_SECRET = "test-secret-new-000000000000000000" + + +def key_id(secret: str) -> str: + return hashlib.sha256(secret.encode()).hexdigest()[:8] + + +def claims_of(grant: Grant) -> dict: + claims = msgspec.to_builtins(grant) + claims["exp"] = claims.pop("expires_at") + return claims | {"iat": NOW, "aud": GRANT_AUDIENCE} + + +def sign_claims(claims: dict, secret: str = SECRET, algorithm: str = "HS256", kid: str | None = None) -> str: + """Sign arbitrary claims the way sign_grant does, to reach the checks that run after the signature.""" + return jwt.encode(claims, secret, algorithm=algorithm, headers={"kid": kid or key_id(secret)}) + + +def assert_invalid(token: str, detail: str, secrets: list[str] | None = None) -> None: + with pytest.raises(GitInvalidGrant) as exc_info: + verify_grant(secrets or [SECRET], token, now=NOW) + assert detail in exc_info.value.detail + + +class TestRoundTrip: + def test_verifies_what_it_signed(self): + grant = make_grant() + assert verify_grant([SECRET], sign_grant(SECRET, grant, now=NOW), now=NOW) == grant + + def test_push_grant(self): + grant = make_grant(operation="push") + assert verify_grant([SECRET], sign_grant(SECRET, grant, now=NOW), now=NOW).operation == "push" + + def test_token_is_a_jwt_with_the_grants_claims(self): + token = sign_grant(SECRET, make_grant(), now=NOW) + assert jwt.get_unverified_header(token) == {"alg": "HS256", "typ": "JWT", "kid": key_id(SECRET)} + 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, + } + + +class TestExpiry: + def test_expired(self): + with pytest.raises(GitGrantExpired, match="expired"): + verify_grant([SECRET], sign_grant(SECRET, make_grant(expires_at=NOW - 1), now=NOW), now=NOW) + + def test_expires_at_its_expiry_time(self): + with pytest.raises(GitGrantExpired): + verify_grant([SECRET], sign_grant(SECRET, make_grant(expires_at=NOW), now=NOW), now=NOW) + + def test_forged_grant_is_invalid_even_when_expired(self): + # Expiry is checked only once the signature matches, so an expired grant is always a genuine one. + token = sign_grant("other-secret-0000000000000000000000", make_grant(expires_at=NOW - 1), now=NOW) + assert_invalid(token, "not signed with a known secret") + + def test_valid_until_its_expiry_time(self): + token = sign_grant(SECRET, make_grant(expires_at=NOW + 1), now=NOW) + assert verify_grant([SECRET], token, now=NOW).expires_at == NOW + 1 + + # PyJWT checks iat against the real clock, so these sign against it too. + def test_issued_in_the_future(self): + assert_invalid(sign_grant(SECRET, make_grant(), now=time.time() + 600), "not yet valid (iat)") + + def test_issued_slightly_in_the_future(self): + # The signer's clock may run a little ahead of the verifier's. + assert verify_grant([SECRET], sign_grant(SECRET, make_grant(), now=time.time() + 30), now=NOW) == make_grant() + + @pytest.mark.parametrize("iat", [float(NOW), NOW + 0.5]) + def test_iat_may_be_a_float(self, iat): + assert verify_grant([SECRET], sign_claims(claims_of(make_grant()) | {"iat": iat}), now=NOW) == make_grant() + + def test_iat_must_be_a_number(self): + assert_invalid(sign_claims(claims_of(make_grant()) | {"iat": "yesterday"}), "iat") + + @pytest.mark.parametrize("claim", ["exp", "iat", "aud"]) + def test_jwt_claims_are_required(self, claim): + claims = claims_of(make_grant()) + del claims[claim] + assert_invalid(sign_claims(claims), claim) + + +class TestTampering: + def test_changed_payload(self): + header, _, signature = sign_grant(SECRET, make_grant(), now=NOW).split(".") + forged = base64.urlsafe_b64encode(msgspec.json.encode(claims_of(make_grant(organization_id=2)))) + assert_invalid(f"{header}.{forged.rstrip(b'=').decode()}.{signature}", "signature does not match") + + def test_changed_signature(self): + header, payload, signature = sign_grant(SECRET, make_grant(), now=NOW).split(".") + flipped = bytearray(base64.urlsafe_b64decode(signature + "=")) + flipped[0] ^= 1 + resigned = base64.urlsafe_b64encode(bytes(flipped)).rstrip(b"=").decode() + assert_invalid(f"{header}.{payload}.{resigned}", "signature does not match") + + def test_unknown_secret(self): + assert_invalid( + sign_grant("other-secret-0000000000000000000000", make_grant(), now=NOW), "not signed with a known" + ) + + def test_other_secret_claiming_a_known_key_id(self): + token = sign_claims(claims_of(make_grant()), secret="other-secret-0000000000000000000000", kid=key_id(SECRET)) + assert_invalid(token, "signature does not match") + + def test_no_secrets(self): + with pytest.raises(GitInvalidGrant): + verify_grant([], sign_grant(SECRET, make_grant(), now=NOW), now=NOW) + + def test_token_without_an_audience(self): + # A viewer context, for one, is an HS256 JWT signed with a shared secret and carries no audience. + claims = claims_of(make_grant()) + del claims["aud"] + assert_invalid(sign_claims(claims), '"aud"') + + def test_token_for_another_audience(self): + assert_invalid(sign_claims(claims_of(make_grant()) | {"aud": "scm-rpc"}), "Audience doesn't match") + + def test_unsigned_token(self): + token = jwt.encode(claims_of(make_grant()), None, algorithm="none", headers={"kid": key_id(SECRET)}) + assert_invalid(token, "invalid") + + @pytest.mark.filterwarnings("ignore::jwt.warnings.InsecureKeyLengthWarning") + def test_other_algorithm(self): + assert_invalid(sign_claims(claims_of(make_grant()), algorithm="HS512"), "invalid") + + +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)]) + + +class TestLength: + """The grant travels in a request header, and sandbox-router refuses one longer than GRANT_MAX_LENGTH.""" + + def test_too_long_to_sign(self): + with pytest.raises(ValueError, match=f"over GRANT_MAX_LENGTH \\({GRANT_MAX_LENGTH}\\)"): + sign_grant(SECRET, grant_with_repositories(100), now=NOW) + + def test_longest_grant_that_fits_signs(self): + # Add repositories until one more would not fit. + count = 1 + token = sign_grant(SECRET, grant_with_repositories(count), now=NOW) + while True: + try: + longer = sign_grant(SECRET, grant_with_repositories(count + 1), now=NOW) + 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 + assert verify_grant([SECRET], token, now=NOW) == grant_with_repositories(count) + + +class TestSecrets: + # PyJWT signs with an empty key, so an empty secret would let anyone sign grants. + def test_refuses_to_sign_with_an_empty_secret(self): + with pytest.raises(ValueError, match="empty"): + sign_grant("", make_grant(), now=NOW) + + @pytest.mark.filterwarnings("ignore::jwt.warnings.InsecureKeyLengthWarning") + def test_refuses_to_verify_with_an_empty_secret(self): + token = jwt.encode(claims_of(make_grant()), "", algorithm="HS256", headers={"kid": key_id("")}) + with pytest.raises(ValueError, match="empty"): + verify_grant([SECRET, ""], token, now=NOW) + + +class TestRotatedSecrets: + @pytest.mark.parametrize("signer", [OLD_SECRET, NEW_SECRET]) + def test_both_secrets_verify_during_rotation(self, signer): + assert ( + verify_grant([NEW_SECRET, OLD_SECRET], sign_grant(signer, make_grant(), now=NOW), now=NOW) == make_grant() + ) + + def test_old_secret_refused_after_rotation(self): + assert_invalid(sign_grant(OLD_SECRET, make_grant(), now=NOW), "not signed with a known", secrets=[NEW_SECRET]) + + +class TestMalformed: + @pytest.mark.parametrize("token", ["nodot", "a.b.c", "a.b", "...", "é.é.é"]) + def test_malformed_token(self, token): + assert_invalid(token, "malformed") + + def test_missing(self): + assert_invalid("", "missing") + + @pytest.mark.parametrize( + "change", + [ + {"organization_id": True}, + {"organization_id": "1"}, + {"referrer": None}, + {"operation": "delete"}, + {"version": 2}, + {"repositories": [["github"]]}, + {"repositories": [["unknown_provider", "123"]]}, + {"exp": NOW + 600.5}, + {"narrower_than_you_think": True}, + ], + ) + def test_signed_but_invalid_claims(self, change): + assert_invalid(sign_claims(claims_of(make_grant()) | change), "payload is invalid") + + def test_referrer_is_required(self): + # Without it Sentry could not tell a coding job's clone from Seer's own fetch. + claims = claims_of(make_grant()) + del claims["referrer"] + assert_invalid(sign_claims(claims), "referrer") + + def test_no_repositories(self): + assert_invalid(sign_grant(SECRET, make_grant(repositories=[]), now=NOW), "names no repositories") diff --git a/tests/unit/test_git_relay.py b/tests/unit/test_git_relay.py new file mode 100644 index 0000000..6f54b98 --- /dev/null +++ b/tests/unit/test_git_relay.py @@ -0,0 +1,648 @@ +import gzip +import io +import socket +import threading +import time +from collections.abc import Iterator +from unittest.mock import MagicMock + +import pytest +import requests +import urllib3 +from requests.adapters import HTTPAdapter + +from scm.errors import ( + GitOperationNotGranted, + GitRemoteNotHttps, + GitRepositoryNotGranted, + GitRouteNotSupported, + ResourceBadGateway, + ResourceCertificateInvalid, + ResourceGatewayTimeout, + ResourceServiceUnavailable, + SCMCodedError, +) +from scm.git.relay import GitRelay, _UpstreamStream, authorize_request +from scm.types import GitRemote +from tests.unit.git_builders import make_grant +from tests.unit.git_upstream import ClosingAdapter, UpstreamHandler, basic_auth, ca_bundle, running_upstream + +TOKEN = "ghs_secrettoken" +# Long enough that a relay that buffers fails the test instead of hanging it. +WAIT = 5 + + +@pytest.fixture +def upstream(): + with running_upstream() as upstream: + yield upstream + + +@pytest.fixture +def relay(): + return GitRelay(ca_bundle=ca_bundle()) + + +def remote_for(upstream) -> GitRemote: + return GitRemote(url=f"{upstream.url}/getsentry/sentry.git", basic_auth_username="x-access-token") + + +def fetch_refs(relay: GitRelay, upstream=None, headers: dict[str, str] | None = None, *, remote=None): + """Fetch the refs advertisement from the fake upstream, or from ``remote`` when given.""" + return relay.forward( + remote or remote_for(upstream), + TOKEN, + method="GET", + route="info/refs", + query="service=git-upload-pack", + headers=headers or {}, + body=None, + ) + + +def upload_pack(relay: GitRelay, upstream, body, headers: dict[str, str] | None = None): + return relay.forward( + remote_for(upstream), + TOKEN, + method="POST", + route="git-upload-pack", + query="", + headers={"Content-Type": "application/x-git-upload-pack-request", **(headers or {})}, + body=body, + ) + + +class TestRequest: + def test_url_and_auth(self, relay, upstream): + b"".join(fetch_refs(relay, upstream).content) + [request] = upstream.requests + assert request.method == "GET" + assert request.path == "/getsentry/sentry.git/info/refs?service=git-upload-pack" + assert request.headers["Authorization"] == basic_auth("x-access-token", TOKEN) + + def test_git_headers_pass_through(self, relay, upstream): + body = gzip.compress(b"0000") + response = upload_pack( + relay, + upstream, + io.BytesIO(body), + headers={ + "Git-Protocol": "version=2", + "Content-Encoding": "gzip", + "Accept": "application/x-git-upload-pack-result", + "Accept-Encoding": "deflate, gzip", + "User-Agent": "git/2.47.0", + "Content-Length": str(len(body)), + }, + ) + b"".join(response.content) + [request] = upstream.requests + assert request.headers["Git-Protocol"] == "version=2" + assert request.headers["Content-Encoding"] == "gzip" + assert request.headers["Content-Type"] == "application/x-git-upload-pack-request" + assert request.headers["Accept"] == "application/x-git-upload-pack-result" + assert request.headers["Accept-Encoding"] == "deflate, gzip" + assert request.headers["User-Agent"] == "git/2.47.0" + # The body is passed on as it came, still compressed. + assert request.body == body + + def test_other_headers_stay_behind(self, relay, upstream): + b"".join( + fetch_refs( + relay, + upstream, + headers={ + "Authorization": "Bearer router-credential", + "X-Scm-Git-Grant": "grant", + "Cookie": "session=1", + "X-Forwarded-For": "10.0.0.1", + "Host": "sentry.io", + }, + ).content + ) + [request] = upstream.requests + assert request.headers["Authorization"] == basic_auth("x-access-token", TOKEN) + assert request.headers["Host"] == upstream.url.removeprefix("https://") + for name in ("X-Scm-Git-Grant", "Cookie", "X-Forwarded-For"): + assert name not in request.headers + + def test_header_names_are_case_insensitive(self, relay, upstream): + b"".join(fetch_refs(relay, upstream, headers={"git-protocol": "version=2", "accept-encoding": "gzip"}).content) + [request] = upstream.requests + assert request.headers["Git-Protocol"] == "version=2" + assert request.headers["Accept-Encoding"] == "gzip" + + def test_asks_for_an_unencoded_response_unless_the_client_says_otherwise(self, relay, upstream): + b"".join(fetch_refs(relay, upstream).content) + assert upstream.requests[0].headers["Accept-Encoding"] == "identity" + + def test_refuses_plain_http(self, relay, upstream): + remote = GitRemote( + url=upstream.url.replace("https://", "http://") + "/repo.git", basic_auth_username="x-access-token" + ) + with pytest.raises(GitRemoteNotHttps, match="not an https URL"): + fetch_refs(relay, remote=remote) + assert upstream.requests == [] + + def test_refuses_routes_that_are_not_git(self, relay, upstream): + with pytest.raises(GitRouteNotSupported): + relay.forward( + remote_for(upstream), + TOKEN, + method="POST", + route="info/lfs/objects/batch", + query="", + headers={}, + body=None, + ) + assert upstream.requests == [] + + def test_get_sends_no_body(self, relay, upstream): + # A view passes its request stream for every method; a GET's must not go upstream as an empty chunked body. + body = io.BytesIO(b"") + b"".join( + relay.forward( + remote_for(upstream), + TOKEN, + method="GET", + route="info/refs", + query="service=git-upload-pack", + headers={}, + body=body, + ).content + ) + [request] = upstream.requests + assert "Transfer-Encoding" not in request.headers + assert "Content-Length" not in request.headers + + def test_known_length_body_is_sent_with_its_length(self, relay, upstream): + b"".join(upload_pack(relay, upstream, io.BytesIO(b"0000"), headers={"Content-Length": "4"}).content) + [request] = upstream.requests + assert request.headers["Content-Length"] == "4" + assert "Transfer-Encoding" not in request.headers + assert request.body == b"0000" + + def test_unknown_length_body_is_sent_chunked(self, relay, upstream): + b"".join(upload_pack(relay, upstream, io.BytesIO(b"0000")).content) + [request] = upstream.requests + assert request.headers["Transfer-Encoding"] == "chunked" + assert request.body == b"0000" + + def test_empty_body_is_sent_as_one(self, relay, upstream): + b"".join(upload_pack(relay, upstream, io.BytesIO(b""), headers={"Content-Length": "0"}).content) + [request] = upstream.requests + assert request.headers["Content-Length"] == "0" + assert "Transfer-Encoding" not in request.headers + assert request.body == b"" + + def test_reads_no_further_than_the_declared_length(self, relay, upstream): + body = io.BytesIO(b"0000trailing") + b"".join(upload_pack(relay, upstream, body, headers={"Content-Length": "4"}).content) + assert upstream.requests[0].body == b"0000" + assert body.read() == b"trailing" + + def test_body_shorter_than_its_length(self, relay, upstream): + with pytest.raises(SCMCodedError) as exc_info: + upload_pack(relay, upstream, io.BytesIO(b"00"), headers={"Content-Length": "4"}) + assert exc_info.value.code == "client_closed_request" + + @pytest.mark.parametrize("known_length", [True, False]) + @pytest.mark.parametrize("error", [ConnectionResetError, BrokenPipeError]) + def test_client_resetting_mid_body_is_reported_at_once(self, upstream, known_length, error): + # urllib3 ignores ECONNRESET and EPIPE while sending a body, taking them for the provider closing the + # connection, and then waits for a response to the cut-off request until its read timeout. + upstream.handler = lambda h: (b"".join(h.body_chunks()), h.respond(200, b"ok")) + + class ResettingClient: + def __init__(self) -> None: + self.reads = 0 + + def read(self, size: int = -1, /) -> bytes: + self.reads += 1 + if self.reads > 1: + raise error() + return b"first" + + headers = {"Content-Length": "100"} if known_length else {} + relay = GitRelay(ca_bundle=ca_bundle(), timeout=(1, 2)) + with pytest.raises(SCMCodedError) as exc_info: + upload_pack(relay, upstream, ResettingClient(), headers=headers) + assert exc_info.value.code == "client_closed_request" + + @pytest.mark.parametrize("content_length", ["abc", "-1", "1.5", "²", ""]) + def test_malformed_content_length(self, relay, upstream, content_length): + with pytest.raises(SCMCodedError) as exc_info: + upload_pack(relay, upstream, io.BytesIO(b"0000"), headers={"Content-Length": content_length}) + assert exc_info.value.code == "rpc_malformed_request_headers" + assert upstream.requests == [] + + +class TestResponse: + def test_status_body_and_headers(self, relay, upstream): + def handler(h: UpstreamHandler) -> None: + h.record(b"") + h.respond( + 200, + b"001e# service=git-upload-pack\n0000", + { + "Content-Type": "application/x-git-upload-pack-advertisement", + "Cache-Control": "no-cache", + "Set-Cookie": "_gh_sess=abc", + "X-GitHub-Request-Id": "ABC", + "WWW-Authenticate": 'Basic realm="GitHub"', + }, + ) + + upstream.handler = handler + response = fetch_refs(relay, upstream) + assert response.status_code == 200 + assert b"".join(response.content) == b"001e# service=git-upload-pack\n0000" + assert response.headers == { + "Content-Type": "application/x-git-upload-pack-advertisement", + "Cache-Control": "no-cache", + "Content-Length": "34", + } + + def test_error_status_passes_through(self, relay, upstream): + upstream.handler = lambda h: h.respond(404, b"Repository not found.", {"Content-Type": "text/plain"}) + response = fetch_refs(relay, upstream) + assert response.status_code == 404 + assert b"".join(response.content) == b"Repository not found." + + def test_encoded_body_passes_through_undecoded(self, relay, upstream): + compressed = gzip.compress(b"0008NAK\n" * 100) + upstream.handler = lambda h: h.respond(200, compressed, {"Content-Encoding": "gzip"}) + response = fetch_refs(relay, upstream, headers={"Accept-Encoding": "gzip"}) + assert response.headers["Content-Encoding"] == "gzip" + assert response.headers["Content-Length"] == str(len(compressed)) + assert b"".join(response.content) == compressed + + def test_redirect_is_not_followed(self, relay, upstream): + def handler(h: UpstreamHandler) -> None: + h.record(b"") + h.respond(301, b"", {"Location": "https://elsewhere.example.com/repo.git/info/refs"}) + + upstream.handler = handler + response = fetch_refs(relay, upstream) + assert response.status_code == 301 + assert "Location" not in response.headers + b"".join(response.content) + assert len(upstream.requests) == 1 + + def test_redirect_body_streams(self, relay, upstream): + # requests reads a redirect's whole body into memory to prepare the next request, even when it will not + # follow it. + body = b"x" * 1024 * 1024 + upstream.handler = lambda h: h.respond(302, body, {"Location": "https://elsewhere.example.com/"}) + response = fetch_refs(relay, upstream) + assert response.content.upstream._content is False # type: ignore[attr-defined] + assert b"".join(response.content) == body + + +class TestStreaming: + def test_response_streams_before_the_upstream_finishes(self, relay, upstream): + client_got_first_chunk = threading.Event() + upstream_waited = [] + + def handler(h: UpstreamHandler) -> None: + h.record(b"".join(h.body_chunks())) + h.start_chunked(200, {"Content-Type": "application/x-git-upload-pack-result"}) + h.write_chunk(b"first") + upstream_waited.append(client_got_first_chunk.wait(WAIT)) + h.write_chunk(b"second") + h.write_chunk(b"") + + upstream.handler = handler + content = upload_pack(relay, upstream, io.BytesIO(b"0000"), headers={"Content-Length": "4"}).content + assert next(content) == b"first" + client_got_first_chunk.set() + assert b"".join(content) == b"second" + assert upstream_waited == [True] + + @pytest.mark.parametrize("known_length", [True, False]) + def test_request_streams_before_the_client_finishes(self, relay, upstream, known_length): + upstream_got_first_chunk = threading.Event() + received: list[bytes] = [] + + def handler(h: UpstreamHandler) -> None: + chunks = h.body_chunks() + received.append(next(chunks)) + upstream_got_first_chunk.set() + received.extend(chunks) + h.respond(200, b"ok") + + class SlowClient: + """Holds the rest of its body back until the upstream has the first part.""" + + def __init__(self) -> None: + self.parts: Iterator[bytes] = iter([b"first", b"second"]) + self.waited: list[bool] = [] + + def read(self, size: int = -1, /) -> bytes: + part = next(self.parts, b"") + if part == b"second": + self.waited.append(upstream_got_first_chunk.wait(WAIT)) + return part + + upstream.handler = handler + client = SlowClient() + headers = {"Content-Length": str(len(b"firstsecond"))} if known_length else {} + response = upload_pack(relay, upstream, client, headers=headers) + assert b"".join(response.content) == b"ok" + assert client.waited == [True] + assert b"".join(received) == b"firstsecond" + + +class TestConnections: + def test_opens_a_connection_per_request(self, relay, upstream): + b"".join(fetch_refs(relay, upstream).content) + b"".join(upload_pack(relay, upstream, io.BytesIO(b"0000"), headers={"Content-Length": "4"}).content) + b"".join(upload_pack(relay, upstream, io.BytesIO(b"0000")).content) + assert len(upstream.requests) == 3 + assert upstream.connections == 3 + + def test_closes_the_connection_when_the_response_ends(self, upstream): + adapters: list[ClosingAdapter] = [] + + def adapter() -> ClosingAdapter: + adapters.append(ClosingAdapter()) + return adapters[-1] + + response = fetch_refs(GitRelay(adapter=adapter, ca_bundle=ca_bundle()), upstream) + assert [a.closed for a in adapters] == [False] + b"".join(response.content) + assert [a.closed for a in adapters] == [True] + + def test_closes_the_connection_when_an_unread_response_is_closed(self, upstream): + # As GitServer does with a 401 it replaces. + adapters: list[ClosingAdapter] = [] + + def adapter() -> ClosingAdapter: + adapters.append(ClosingAdapter()) + return adapters[-1] + + upstream.handler = lambda h: h.respond(401, b"Bad credentials") + response = fetch_refs(GitRelay(adapter=adapter, ca_bundle=ca_bundle()), upstream) + response.content.close() # type: ignore[attr-defined] + assert [a.closed for a in adapters] == [True] + + def test_closes_the_connection_when_the_request_fails(self): + adapters: list[ClosingAdapter] = [] + + def adapter() -> ClosingAdapter: + adapters.append(ClosingAdapter()) + return adapters[-1] + + remote = GitRemote(url="https://127.0.0.1:1/getsentry/sentry.git", basic_auth_username="x-access-token") + with pytest.raises(ResourceServiceUnavailable): + fetch_refs(GitRelay(adapter=adapter, timeout=(1, 1)), remote=remote) + assert [a.closed for a in adapters] == [True] + + def test_does_not_carry_cookies_between_requests(self, relay, upstream): + def handler(h: UpstreamHandler) -> None: + h.record(b"") + h.respond(200, b"", {"Set-Cookie": "_gh_sess=abc; Path=/"}) + + upstream.handler = handler + b"".join(fetch_refs(relay, upstream).content) + b"".join(fetch_refs(relay, upstream).content) + assert [r.headers.get("Cookie") for r in upstream.requests] == [None, None] + + +class TestAdapter: + def test_uses_the_adapter_it_is_given(self, upstream): + # Sentry passes an adapter that refuses internal addresses; the relay must send through it. + class HostRefused(Exception): + pass + + class RefusingAdapter(HTTPAdapter): + def send(self, request, *args, **kwargs): + raise HostRefused(request.url) + + with pytest.raises(HostRefused): + fetch_refs(GitRelay(adapter=RefusingAdapter, ca_bundle=ca_bundle()), upstream) + assert upstream.requests == [] + + +class TestEnvironment: + # Behind a proxy, an adapter that refuses internal addresses would check the proxy's address rather than the + # provider's, so the relay takes nothing from the environment. + @pytest.mark.parametrize("variable", ["HTTPS_PROXY", "https_proxy", "ALL_PROXY"]) + def test_ignores_proxy_settings(self, relay, upstream, monkeypatch, variable): + monkeypatch.setenv(variable, "http://127.0.0.1:1") + b"".join(fetch_refs(relay, upstream).content) + assert len(upstream.requests) == 1 + + def test_ignores_ca_settings(self, upstream, monkeypatch): + monkeypatch.setenv("REQUESTS_CA_BUNDLE", upstream.ca_bundle) + with pytest.raises(ResourceCertificateInvalid): + fetch_refs(GitRelay(), upstream) + + +class TestUpstreamFailures: + def test_connection_refused(self, relay): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + remote = GitRemote(url=f"https://127.0.0.1:{port}/getsentry/sentry.git", basic_auth_username="x-access-token") + + with pytest.raises(ResourceServiceUnavailable) as exc_info: + fetch_refs(relay, remote=remote) + assert_token_not_in(exc_info.value) + # The client has no use for the provider's address. + assert "127.0.0.1" not in exc_info.value.detail + + def test_untrusted_certificate(self, upstream): + with pytest.raises(ResourceCertificateInvalid, match="TLS certificate") as exc_info: + fetch_refs(GitRelay(), upstream) + assert_token_not_in(exc_info.value) + assert upstream.requests == [] + + def test_read_timeout(self, upstream): + release = threading.Event() + + def handler(h: UpstreamHandler) -> None: + release.wait(WAIT) + h.respond(200, b"") + + upstream.handler = handler + try: + with pytest.raises(ResourceGatewayTimeout) as exc_info: + fetch_refs(GitRelay(ca_bundle=ca_bundle(), timeout=(1, 0.1)), upstream) + assert_token_not_in(exc_info.value) + finally: + release.set() + + @pytest.mark.parametrize( + "make_error", + [ + pytest.param( + lambda request: requests.exceptions.InvalidHeader("bad header", request=request), id="requests" + ), + # HTTPAdapter.send re-raises the urllib3 errors it does not translate. + pytest.param(lambda request: urllib3.exceptions.HTTPError("bad response"), id="urllib3"), + ], + ) + def test_other_request_failures(self, upstream, make_error): + class FailingAdapter(HTTPAdapter): + def send(self, request, *args, **kwargs): + raise make_error(request) + + with pytest.raises(ResourceBadGateway) as exc_info: + fetch_refs(GitRelay(adapter=FailingAdapter, ca_bundle=ca_bundle()), upstream) + assert_token_not_in(exc_info.value) + + +@pytest.mark.parametrize( + ("basic_auth_username", "token"), + [("x-access-token", "ghs_tok\u20acn"), ("x-acc\u00e9ss-token", TOKEN)], + ids=["token", "basic_auth_username"], +) +def test_refuses_credentials_that_are_not_ascii(upstream, basic_auth_username, token): + remote = GitRemote(url=f"{upstream.url}/getsentry/sentry.git", basic_auth_username=basic_auth_username) + with pytest.raises(ValueError, match="not ASCII") as exc_info: + GitRelay(ca_bundle=ca_bundle()).forward( + remote, + token, + method="GET", + route="info/refs", + query="service=git-upload-pack", + headers={}, + body=None, + ) + assert token not in str(exc_info.value) + assert exc_info.value.__context__ is None + assert not upstream.requests + + +def assert_token_not_in(error: Exception) -> None: + # The requests exception holds the prepared request and its Authorization header, so it must not be chained. + assert error.__cause__ is None + assert error.__suppress_context__ + assert TOKEN not in str(error) + assert basic_auth("x-access-token", TOKEN) not in repr(error) + + +def upstream_stream(*chunks: bytes | Exception) -> tuple[_UpstreamStream, MagicMock, MagicMock]: + """An _UpstreamStream over a response whose body yields ``chunks``, raising any that is an exception.""" + + def stream(chunk_size, decode_content): + for chunk in chunks: + if isinstance(chunk, Exception): + raise chunk + yield chunk + + session, response = MagicMock(spec=requests.Session), MagicMock(spec=requests.Response) + response.raw = MagicMock(stream=stream) + return _UpstreamStream(session, response, 1024), session, response + + +class TestUpstreamStream: + def test_skips_empty_chunks(self): + # An empty chunk carries nothing, so it is not passed on. + stream, _, _ = upstream_stream(b"a", b"", b"", b"b") + assert list(stream) == [b"a", b"b"] + + def test_closes_the_response_and_session_when_the_body_ends(self): + stream, session, response = upstream_stream(b"a") + assert list(stream) == [b"a"] + response.close.assert_called_once() + session.close.assert_called_once() + + def test_closes_the_response_and_session_when_the_provider_stream_breaks(self): + stream, session, response = upstream_stream(b"a", urllib3.exceptions.ProtocolError("connection broken")) + assert next(stream) == b"a" + response.close.assert_not_called() + with pytest.raises(urllib3.exceptions.ProtocolError): + next(stream) + response.close.assert_called_once() + session.close.assert_called_once() + + def test_close_before_reading(self): + # A response GitServer replaces, such as a 401, is closed without being read. + stream, session, response = upstream_stream(b"a") + stream.close() + response.close.assert_called_once() + session.close.assert_called_once() + + +class TestClose: + def test_closing_an_unread_response_drops_the_upstream_connection(self, relay, upstream): + upstream_saw_close = threading.Event() + + def handler(h: UpstreamHandler) -> None: + h.start_chunked(200) + try: + for _ in range(1000): + h.write_chunk(b"x" * 64 * 1024) + time.sleep(0.005) + except OSError: + h.close_connection = True + upstream_saw_close.set() + + upstream.handler = handler + 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", "")] + + +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( + "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): + 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) + + 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")]) + 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_listed_repository(self): + grant = make_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 new file mode 100644 index 0000000..332221c --- /dev/null +++ b/tests/unit/test_git_server.py @@ -0,0 +1,510 @@ +import io +import time +from collections.abc import Generator +from types import SimpleNamespace +from unittest.mock import MagicMock +from urllib.parse import urlsplit + +import pytest + +from scm.errors import ClientClosedRequest, RateLimitExceeded +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.rpc.types import StreamResponse +from scm.types import GitRemote, Provider, Repository, RepositoryId +from tests.unit.git_builders import make_grant, make_provider, make_repository +from tests.unit.git_upstream import ClosingAdapter, basic_auth, running_upstream + +SECRET = "test-secret-0000000000000000000000" +TOKEN = "ghs_secrettoken" +REFERRER = make_grant().referrer + + +def signed_grant(**overrides) -> str: + return sign_grant(SECRET, make_grant(**overrides)) + + +class Recorder: + def __init__(self, repository: Repository | None = None, token: str | None = TOKEN) -> None: + self.repository = make_repository() if repository is None else repository + self.token = token + self.repository_lookups: list[tuple[int, RepositoryId]] = [] + self.provider_lookups: list[tuple[int, Repository]] = [] + self.token_lookups: list[tuple[Repository, GitOperation]] = [] + self.counts: list[tuple[str, dict[str, str]]] = [] + self.errors: list[tuple[Exception, dict[str, str]]] = [] + self.bytes: dict[tuple[str, str], int] = {} + + def fetch_repository(self, organization_id: int, repository_id: RepositoryId) -> Repository | None: + self.repository_lookups.append((organization_id, repository_id)) + return self.repository + + def fetch_provider(self, organization_id: int, repository: Repository) -> Provider | None: + self.provider_lookups.append((organization_id, repository)) + return make_provider(organization_id, repository) + + def get_token(self, repository: Repository, operation: GitOperation) -> str | None: + self.token_lookups.append((repository, operation)) + return self.token + + def record_count(self, name: str, value: int, tags: dict[str, str]) -> None: + if name.endswith("_size"): + key = (name, tags["operation"]) + self.bytes[key] = self.bytes.get(key, 0) + value + else: + self.counts.append((name, tags)) + + def emit_error(self, error: Exception, context: dict[str, str]) -> None: + self.errors.append((error, context)) + + @property + def error_codes(self) -> list[str]: + return [getattr(error, "code", "") for error, _ in self.errors] + + +def chunks(*parts: bytes) -> Generator[bytes]: + """A provider response body, which, like the relay's, can be closed.""" + yield from parts + + +def relay_returning(content: ResponseBody, status_code: int = 200) -> GitRelay: + """A stand-in relay whose every forward returns ``content`` as the provider's response.""" + relay = MagicMock(spec=GitRelay) + relay.forward.return_value = RelayResponse(status_code=status_code, headers={}, content=content) + return relay + + +def make_server(recorder: Recorder, relay: GitRelay | None = None) -> GitServer: + if relay is None: + relay = relay_returning(chunks(b"refs")) + return GitServer( + secrets=[SECRET], + fetch_repository=recorder.fetch_repository, + fetch_provider=recorder.fetch_provider, + get_token=recorder.get_token, + record_count=recorder.record_count, + emit_error=recorder.emit_error, + relay=relay, + ) + + +def request( + server: GitServer, + grant: str | None, + *, + method: str = "GET", + route: str = "info/refs", + query: str = "service=git-upload-pack", + repository_id: RepositoryId = 5, + headers: dict[str, str] | None = None, + body: Readable | None = None, +) -> StreamResponse: + """Send a request to the server, an info/refs fetch unless told otherwise.""" + grant_headers = {GRANT_HEADER: grant} if grant is not None else {} + return server.handle( + method=method, + repository_id=repository_id, + route=route, + query=query, + headers=grant_headers | (headers or {}), + body=body, + ) + + +def push(server: GitServer, grant: str) -> StreamResponse: + return request(server, grant, method="POST", route="git-receive-pack", query="", body=io.BytesIO(b"0000")) + + +def assert_refused(response: StreamResponse, status: int, detail: str) -> None: + assert response.status_code == status + assert response.headers["Content-Type"] == "text/plain; charset=utf-8" + body = b"".join(response.content).decode() + assert detail in body + assert int(response.headers["Content-Length"]) == len(body.encode()) + + +def test_requires_a_relay(): + # A default relay would connect to internal addresses, so the caller has to choose its adapter. + with pytest.raises(TypeError, match="relay"): + GitServer( # type: ignore[call-arg] + secrets=[SECRET], + fetch_repository=lambda organization_id, repository_id: None, + fetch_provider=lambda organization_id, repository: None, + get_token=lambda repository, operation: None, + record_count=lambda name, value, tags: None, + emit_error=lambda error, context: None, + ) + + +@pytest.mark.parametrize("secrets", [[], [SECRET, ""]]) +def test_requires_non_empty_secrets(secrets): + with pytest.raises(ValueError, match="secret"): + GitServer( + secrets=secrets, + fetch_repository=lambda organization_id, repository_id: None, + fetch_provider=lambda organization_id, repository: None, + get_token=lambda repository, operation: None, + record_count=lambda name, value, tags: None, + emit_error=lambda error, context: None, + relay=MagicMock(spec=GitRelay), + ) + + +class TestRefusedBeforeTheProvider: + """Nothing the grant does not allow may look up a repository or a token.""" + + @pytest.mark.parametrize( + "grant,detail,code", + [ + (None, "missing", "git_invalid_grant"), + ("garbage", "malformed", "git_invalid_grant"), + ( + sign_grant("other-secret-0000000000000000000000", make_grant()), + "not signed with a known secret", + "git_invalid_grant", + ), + (signed_grant(expires_at=int(time.time()) - 1), "expired", "git_grant_expired"), + ], + ) + def test_invalid_grant(self, grant, detail, code): + recorder = Recorder() + server = make_server(recorder) + assert_refused(request(server, grant), 403, detail) + assert recorder.repository_lookups == [] + assert recorder.provider_lookups == [] + assert recorder.token_lookups == [] + server.relay.forward.assert_not_called() # type: ignore[attr-defined] + assert recorder.counts == [("sentry.scm.git.request.failed", {"code": code})] + assert recorder.error_codes == [code] + # Only the requested repository is known before the grant is verified. + assert recorder.errors[0][1] == {"repository_id": "5"} + + def test_route_that_is_not_git(self): + recorder = Recorder() + response = request( + make_server(recorder), signed_grant(), method="POST", route="info/lfs/objects/batch", query="" + ) + assert_refused(response, 404, "not a git fetch or push route") + assert recorder.repository_lookups == [] + assert recorder.counts == [ + ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_route_not_supported"}) + ] + + 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 recorder.repository_lookups == [] + assert recorder.token_lookups == [] + assert recorder.counts == [ + ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_operation_not_granted"}) + ] + assert recorder.errors[0][1] == {"repository_id": "5", "organization_id": "1", "referrer": REFERRER} + + 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") + assert recorder.repository_lookups == [] + assert recorder.counts == [ + ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_repository_not_granted"}) + ] + + def test_grant_header_name_is_case_insensitive(self): + response = request(make_server(Recorder()), None, headers={GRANT_HEADER.lower(): signed_grant()}) + assert response.status_code == 200 + + +class TestRepository: + def test_looked_up_under_the_grants_organization(self): + recorder = Recorder() + request(make_server(recorder), signed_grant(organization_id=1)) + assert recorder.repository_lookups == [(1, 5)] + assert recorder.provider_lookups == [(1, make_repository())] + + def test_provider_without_a_git_remote(self): + recorder = Recorder() + recorder.fetch_provider = lambda organization_id, repository: SimpleNamespace( # type: ignore[method-assign] + organization_id=organization_id, repository=repository + ) + assert_refused(request(make_server(recorder), signed_grant()), 404, "github provider has no git remote") + assert recorder.token_lookups == [] + + def test_remote_comes_from_the_provider(self): + repository = make_repository(provider_name="gitlab", external_id="gitlab.example.com:7", path="group/project") + recorder = Recorder(repository=repository) + server = make_server(recorder) + request(server, signed_grant()) + remote = server.relay.forward.call_args.args[0] # type: ignore[attr-defined] + assert remote == GitRemote(url="https://gitlab.example.com/group/project.git", basic_auth_username="oauth2") + + @pytest.mark.parametrize( + "repository,status,detail", + [ + (None, 404, "could not be found"), + (make_repository(organization_id=2), 404, "did not belong"), + (make_repository(is_active=False), 404, "inactive"), + (make_repository(provider_name="bitbucket"), 404, "unsupported integration provider"), + (make_repository(provider_name="gitlab", external_id="gitlab.com:7"), 500, "has no path"), + ], + ) + def test_refused_before_the_token(self, repository, status, detail): + recorder = Recorder() + recorder.repository = repository + assert_refused(request(make_server(recorder), signed_grant()), status, detail) + assert recorder.token_lookups == [] + + +class TestRelay: + def test_no_token(self): + recorder = Recorder(token=None) + response = request(make_server(recorder), signed_grant()) + assert_refused(response, 502, "could not get a github token") + assert recorder.counts == [ + ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_token_unavailable"}) + ] + + def test_token_asked_for_the_grants_operation(self): + recorder = Recorder() + push(make_server(recorder), signed_grant(operation="push")) + assert recorder.token_lookups == [(make_repository(), "push")] + + def test_forwards_with_remote_and_token(self): + recorder = Recorder() + server = make_server(recorder) + grant = signed_grant() + response = request(server, grant, headers={"Git-Protocol": "version=2"}) + assert b"".join(response.content) == b"refs" + server.relay.forward.assert_called_once_with( # type: ignore[attr-defined] + GitRemote(url="https://github.com/getsentry/sentry.git", basic_auth_username="x-access-token"), + TOKEN, + method="GET", + route="info/refs", + query="service=git-upload-pack", + headers={GRANT_HEADER: grant, "Git-Protocol": "version=2"}, + body=None, + ) + assert recorder.counts == [ + ( + "sentry.scm.git.request.succeeded", + {"referrer": REFERRER, "operation": "fetch", "provider": "github", "status": "200"}, + ) + ] + + def test_counted_when_the_response_ends_not_when_it_starts(self): + recorder = Recorder() + response = request(make_server(recorder), signed_grant()) + assert recorder.counts == [] + assert b"".join(response.content) == b"refs" + response.content.close() # type: ignore[attr-defined] + assert recorder.counts == [ + ( + "sentry.scm.git.request.succeeded", + {"referrer": REFERRER, "operation": "fetch", "provider": "github", "status": "200"}, + ) + ] + assert recorder.bytes == {("sentry.scm.git.response_size", "fetch"): 4} + + def test_provider_stream_breaking_is_a_failure(self): + def broken_stream(): + yield b"half a pack" + raise ConnectionError("provider went away") + + recorder = Recorder() + content = request(make_server(recorder, relay_returning(broken_stream())), signed_grant()).content + assert next(content) == b"half a pack" + with pytest.raises(ConnectionError): + next(content) + assert recorder.counts == [ + ( + "sentry.scm.git.request.failed", + { + "referrer": REFERRER, + "operation": "fetch", + "provider": "github", + "status": "200", + "code": "stream_interrupted", + }, + ) + ] + assert recorder.bytes == {("sentry.scm.git.response_size", "fetch"): 11} + + def test_client_leaving_early_is_a_failure_and_closes_the_provider_stream(self): + closed = [] + + def provider_stream(): + try: + yield b"first" + yield b"second" + finally: + closed.append(True) + + recorder = Recorder() + content = request(make_server(recorder, relay_returning(provider_stream())), signed_grant()).content + assert next(content) == b"first" + content.close() # type: ignore[attr-defined] + assert closed == [True] + assert recorder.counts == [ + ( + "sentry.scm.git.request.failed", + { + "referrer": REFERRER, + "operation": "fetch", + "provider": "github", + "status": "200", + "code": "client_closed_request", + }, + ) + ] + + def test_upstream_unavailable(self): + recorder = Recorder( + repository=make_repository(provider_name="github_enterprise", web_base_url="https://127.0.0.1:1") + ) + response = request(make_server(recorder, GitRelay(timeout=(1, 1))), signed_grant()) + assert_refused(response, 503, "temporarily unavailable") + assert "127.0.0.1" not in b"".join(response.content).decode() + assert TOKEN not in b"".join(response.content).decode() + + def test_reported_remote_host_leaves_out_credentials_in_the_url(self): + recorder = Recorder( + repository=make_repository(provider_name="github_enterprise", web_base_url="https://user:pw@127.0.0.1:1") + ) + request(make_server(recorder, GitRelay(timeout=(1, 1))), signed_grant()) + assert recorder.error_codes == ["resource_service_unavailable"] + assert recorder.errors[0][1]["remote_host"] == "127.0.0.1:1" + + def test_plain_http_remote(self): + # A self-managed instance served over http; the token must not cross the network in the clear. + recorder = Recorder( + repository=make_repository(provider_name="github_enterprise", web_base_url="http://127.0.0.1:1") + ) + response = request(make_server(recorder, GitRelay()), signed_grant()) + assert_refused(response, 502, "not an https URL") + assert recorder.counts == [ + ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_remote_not_https"}) + ] + + def test_provider_rejecting_the_token(self): + # Passed on, a 401 would make git ask the sandboxed client for credentials. + adapters: list[ClosingAdapter] = [] + + def adapter() -> ClosingAdapter: + adapters.append(ClosingAdapter()) + return adapters[-1] + + with running_upstream() as upstream: + upstream.handler = lambda h: h.respond(401, b"Bad credentials") + recorder = Recorder( + repository=make_repository(provider_name="github_enterprise", web_base_url=upstream.url) + ) + relay = GitRelay(adapter=adapter, ca_bundle=upstream.ca_bundle) + response = request(make_server(recorder, relay), signed_grant()) + assert_refused(response, 502, "rejected Sentry's credentials") + # The provider's unread response is closed with its connection. + assert [a.closed for a in adapters] == [True] + assert recorder.counts == [ + ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "git_token_rejected"}) + ] + assert recorder.error_codes == ["git_token_rejected"] + assert recorder.errors[0][1] == { + "repository_id": "5", + "organization_id": "1", + "referrer": REFERRER, + "operation": "fetch", + "provider": "github_enterprise", + "remote_host": urlsplit(upstream.url).netloc, + } + + @pytest.mark.parametrize("status", [301, 403, 404, 500]) + def test_provider_refusing_the_request_is_passed_on_and_counted_as_failed(self, status): + # e.g. GitHub's 404 for a repository the installation cannot see; git shows the client its message. + recorder = Recorder() + response = request( + make_server(recorder, relay_returning(chunks(b"Repository not found."), status)), signed_grant() + ) + assert response.status_code == status + assert b"".join(response.content) == b"Repository not found." + response.content.close() # type: ignore[attr-defined] + assert recorder.counts == [ + ( + "sentry.scm.git.request.failed", + { + "referrer": REFERRER, + "operation": "fetch", + "provider": "github", + "status": str(status), + "code": "provider_error", + }, + ) + ] + + def test_client_closing_its_request_is_counted_but_not_reported(self): + relay = MagicMock(spec=GitRelay) + relay.forward.side_effect = ClientClosedRequest() + recorder = Recorder() + assert_refused(request(make_server(recorder, relay), signed_grant()), 499, "") + assert recorder.counts == [ + ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "client_closed_request"}) + ] + assert recorder.errors == [] + + def test_unexpected_error_code_is_a_server_error(self): + relay = MagicMock(spec=GitRelay) + relay.forward.side_effect = RateLimitExceeded() + assert_refused(request(make_server(Recorder(), relay), signed_grant()), 500, "") + + def test_no_refusal_is_a_401(self): + # On a 401 git asks the sandboxed client for credentials. + assert 401 not in _STATUS.values() + + def test_other_errors_reach_the_caller(self): + # Sentry's view answers these the way it answers them elsewhere, e.g. a refused host as ApiHostError. + class HostRefused(Exception): + pass + + relay = MagicMock(spec=GitRelay) + relay.forward.side_effect = HostRefused() + recorder = Recorder() + with pytest.raises(HostRefused): + request(make_server(recorder, relay), signed_grant()) + assert recorder.counts == [ + ("sentry.scm.git.request.failed", {"referrer": REFERRER, "code": "unhandled_exception"}) + ] + # The caller reports it. + assert recorder.errors == [] + + def test_counts_bytes_each_way_end_to_end(self): + with running_upstream() as upstream: + recorder = Recorder( + repository=make_repository(provider_name="github_enterprise", web_base_url=upstream.url) + ) + relay = GitRelay(ca_bundle=upstream.ca_bundle) + response = request( + make_server(recorder, relay), + signed_grant(operation="push"), + method="POST", + route="git-receive-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, + } + + def test_relays_end_to_end(self): + with running_upstream() as upstream: + recorder = Recorder( + repository=make_repository(provider_name="github_enterprise", web_base_url=upstream.url) + ) + relay = GitRelay(ca_bundle=upstream.ca_bundle) + response = request(make_server(recorder, relay), signed_grant()) + assert response.status_code == 200 + b"".join(response.content) + [received] = upstream.requests + assert received.path == "/getsentry/sentry.git/info/refs?service=git-upload-pack" + assert received.headers["Authorization"] == basic_auth("x-access-token", TOKEN) + assert GRANT_HEADER not in received.headers diff --git a/uv.lock b/uv.lock index e66c7ba..06accd1 100644 --- a/uv.lock +++ b/uv.lock @@ -545,11 +545,14 @@ version = "1.10.0" source = { editable = "." } dependencies = [ { name = "msgspec" }, + { name = "pyjwt" }, { name = "requests" }, + { name = "urllib3" }, ] [package.dev-dependencies] dev = [ + { name = "cryptography" }, { name = "mypy" }, { name = "prek" }, { name = "pyjwt", extra = ["crypto"] }, @@ -562,11 +565,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "msgspec", specifier = ">=0.19.0" }, + { name = "pyjwt", specifier = ">=2.9.0" }, { name = "requests", specifier = ">=2.32.4" }, + { name = "urllib3", specifier = ">=2.8.0" }, ] [package.metadata.requires-dev] dev = [ + { name = "cryptography", specifier = ">=48.0.1" }, { name = "mypy", specifier = ">=1.20.0" }, { name = "prek", specifier = ">=0.3.6" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9.0" }, @@ -599,9 +605,9 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.8.0" source = { registry = "https://sfw.security.sentry.io/pypi/simple" } -sdist = { url = "https://sfw.security.sentry.io/pypi/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://sfw.security.sentry.io/pypi/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://sfw.security.sentry.io/pypi/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://sfw.security.sentry.io/pypi/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ]