Two defense-in-depth fixes surfaced by /sh-security-review (both were unverified — no exploit — but cheaply strengthen the credential contract): - dispatcher: wrap the requests.post/get in app_workflow_dispatcher and app_run_locator in try/except that re-raises DispatcherError with the exception TYPE only (`from None`). The no-token-in-a-propagating-exception guarantee is now enforced by code, not by requests' incidental behavior. - github_app: parse expires_at BEFORE caching the token and raise GitHubAppError (scrubbed) on a malformed value, so a parse failure fails closed without leaving a half-written cache (token set, expiry None) behind a bare ValueError. Tests: +3 (transport-error scrub for both HTTP seams; malformed-expiry fail-closed with no half-written cache). Full suite 1526 passing; ruff clean.
295 lines
9.9 KiB
Python
295 lines
9.9 KiB
Python
"""Unit tests for agent_team.github_app — App-JWT mint + TokenProvider cache.
|
|
|
|
These tests generate a throwaway RSA-2048 key with ``cryptography``, sign a real
|
|
App JWT, and intercept the mint HTTP call with a fake client so no network and no
|
|
real GitHub App is touched. They assert: the JWT claims/alg are correct, a clean
|
|
``201`` returns the ``{token, expires_at}`` mapping, the provider caches and
|
|
re-mints around the refresh margin, and — critically — that no token or JWT
|
|
material ever appears in a raised error's message (secret hygiene).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import jwt
|
|
import pytest
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
from agent_team.github_app import (
|
|
GitHubAppError,
|
|
TokenProvider,
|
|
mint_installation_token,
|
|
)
|
|
|
|
_APP_ID = "123456"
|
|
_INSTALLATION_ID = "987654"
|
|
|
|
# A fixed clock so JWT iat/exp are deterministic.
|
|
_FIXED_NOW = datetime(2026, 6, 24, 12, 0, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
@pytest.fixture
|
|
def rsa_keypair():
|
|
"""Generate a throwaway RSA-2048 keypair; return (private_pem, public_obj)."""
|
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
private_pem = private_key.private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.PKCS8,
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
).decode("ascii")
|
|
return private_pem, private_key.public_key()
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, status: int, body):
|
|
self.status_code = status
|
|
self._body = body
|
|
|
|
def json(self):
|
|
return self._body
|
|
|
|
|
|
class _FakeHttp:
|
|
"""A callable (url, *, headers, timeout) mint client recording calls."""
|
|
|
|
def __init__(self, response=None, raise_exc=None):
|
|
self._response = response
|
|
self._raise = raise_exc
|
|
self.calls: list[dict] = []
|
|
|
|
def __call__(self, url, *, headers=None, timeout=None):
|
|
self.calls.append({"url": url, "headers": headers, "timeout": timeout})
|
|
if self._raise is not None:
|
|
raise self._raise
|
|
return self._response
|
|
|
|
@property
|
|
def call_count(self) -> int:
|
|
return len(self.calls)
|
|
|
|
|
|
def _fixed_now():
|
|
return _FIXED_NOW
|
|
|
|
|
|
def _future_iso(seconds: int = 3600) -> str:
|
|
return (_FIXED_NOW + timedelta(seconds=seconds)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# JWT claims / algorithm #
|
|
# --------------------------------------------------------------------------- #
|
|
def test_mint_signs_jwt_with_expected_claims(rsa_keypair):
|
|
private_pem, public_key = rsa_keypair
|
|
http = _FakeHttp(_FakeResponse(201, {"token": "tok", "expires_at": _future_iso()}))
|
|
|
|
mint_installation_token(
|
|
app_id=_APP_ID,
|
|
private_key_pem=private_pem,
|
|
installation_id=_INSTALLATION_ID,
|
|
_http=http,
|
|
_now=_fixed_now,
|
|
)
|
|
|
|
auth = http.calls[0]["headers"]["Authorization"]
|
|
assert auth.startswith("Bearer ")
|
|
token = auth.split(" ", 1)[1]
|
|
|
|
assert jwt.get_unverified_header(token)["alg"] == "RS256"
|
|
# Verify the signature and claims, but not exp/iat against the real wall
|
|
# clock: the JWT is minted from the fixed test clock (_fixed_now), so its
|
|
# short-lived exp is in the past relative to the real time the suite runs.
|
|
claims = jwt.decode(
|
|
token,
|
|
public_key,
|
|
algorithms=["RS256"],
|
|
options={"verify_aud": False, "verify_exp": False, "verify_iat": False},
|
|
)
|
|
assert claims["iss"] == _APP_ID
|
|
now_ts = int(_FIXED_NOW.timestamp())
|
|
assert claims["iat"] <= now_ts
|
|
assert claims["exp"] - claims["iat"] <= 600
|
|
|
|
|
|
def test_mint_targets_installation_token_url(rsa_keypair):
|
|
private_pem, _ = rsa_keypair
|
|
http = _FakeHttp(_FakeResponse(201, {"token": "tok", "expires_at": _future_iso()}))
|
|
|
|
mint_installation_token(
|
|
app_id=_APP_ID,
|
|
private_key_pem=private_pem,
|
|
installation_id=_INSTALLATION_ID,
|
|
_http=http,
|
|
_now=_fixed_now,
|
|
)
|
|
|
|
assert http.calls[0]["url"].endswith(
|
|
f"/app/installations/{_INSTALLATION_ID}/access_tokens"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Mint success #
|
|
# --------------------------------------------------------------------------- #
|
|
def test_mint_success_returns_token_and_expiry(rsa_keypair):
|
|
private_pem, _ = rsa_keypair
|
|
expires_at = _future_iso()
|
|
http = _FakeHttp(_FakeResponse(201, {"token": "tok", "expires_at": expires_at}))
|
|
|
|
result = mint_installation_token(
|
|
app_id=_APP_ID,
|
|
private_key_pem=private_pem,
|
|
installation_id=_INSTALLATION_ID,
|
|
_http=http,
|
|
_now=_fixed_now,
|
|
)
|
|
|
|
assert result == {"token": "tok", "expires_at": expires_at}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TokenProvider caching / re-mint #
|
|
# --------------------------------------------------------------------------- #
|
|
def test_provider_caches_token_across_calls(rsa_keypair):
|
|
private_pem, _ = rsa_keypair
|
|
http = _FakeHttp(
|
|
_FakeResponse(201, {"token": "tok", "expires_at": _future_iso(86400)})
|
|
)
|
|
provider = TokenProvider(
|
|
app_id=_APP_ID,
|
|
private_key_pem=private_pem,
|
|
installation_id=_INSTALLATION_ID,
|
|
_http=http,
|
|
_now=_fixed_now,
|
|
)
|
|
|
|
assert provider.token() == "tok"
|
|
assert provider.token() == "tok"
|
|
assert http.call_count == 1
|
|
|
|
|
|
def test_provider_remints_near_expiry(rsa_keypair):
|
|
private_pem, _ = rsa_keypair
|
|
# First mint expires 10 minutes out; with a 5-minute margin the second call
|
|
# (clock advanced past expiry - margin) must re-mint.
|
|
responses = [
|
|
_FakeResponse(201, {"token": "tok1", "expires_at": _future_iso(600)}),
|
|
_FakeResponse(201, {"token": "tok2", "expires_at": _future_iso(1200)}),
|
|
]
|
|
|
|
class _SeqHttp(_FakeHttp):
|
|
def __call__(self, url, *, headers=None, timeout=None):
|
|
self.calls.append({"url": url})
|
|
return responses[len(self.calls) - 1]
|
|
|
|
http = _SeqHttp()
|
|
|
|
clock = {"now": _FIXED_NOW}
|
|
|
|
def _moving_now():
|
|
return clock["now"]
|
|
|
|
provider = TokenProvider(
|
|
app_id=_APP_ID,
|
|
private_key_pem=private_pem,
|
|
installation_id=_INSTALLATION_ID,
|
|
_http=http,
|
|
_now=_moving_now,
|
|
refresh_margin_s=300,
|
|
)
|
|
|
|
assert provider.token() == "tok1"
|
|
assert http.call_count == 1
|
|
|
|
# Advance past (expiry - margin) = +300s -> re-mint.
|
|
clock["now"] = _FIXED_NOW + timedelta(seconds=400)
|
|
assert provider.token() == "tok2"
|
|
assert http.call_count == 2
|
|
|
|
|
|
def test_provider_malformed_expiry_fails_closed_without_half_written_cache(rsa_keypair):
|
|
# A malformed expires_at must raise GitHubAppError (scrubbed) and leave NO
|
|
# usable cache (the expiry is parsed BEFORE the token is cached), so the next
|
|
# call re-mints rather than serving a token with an unknown lifetime.
|
|
private_pem, _ = rsa_keypair
|
|
http = _FakeHttp(
|
|
_FakeResponse(201, {"token": "tok", "expires_at": "not-a-timestamp"})
|
|
)
|
|
provider = TokenProvider(
|
|
app_id=_APP_ID,
|
|
private_key_pem=private_pem,
|
|
installation_id=_INSTALLATION_ID,
|
|
_http=http,
|
|
_now=_fixed_now,
|
|
)
|
|
|
|
with pytest.raises(GitHubAppError) as excinfo:
|
|
provider.token()
|
|
assert "tok" not in str(excinfo.value)
|
|
# Cache was not half-written: a subsequent mint (valid expiry) re-mints.
|
|
http._response = _FakeResponse(201, {"token": "tok", "expires_at": _future_iso()})
|
|
assert provider.token() == "tok"
|
|
assert http.call_count == 2
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Secret hygiene #
|
|
# --------------------------------------------------------------------------- #
|
|
def test_mint_failure_status_is_scrubbed(rsa_keypair):
|
|
private_pem, _ = rsa_keypair
|
|
# Even on a failure GitHub may echo the (bad) token; ensure nothing leaks.
|
|
http = _FakeHttp(_FakeResponse(401, {"token": "tok", "message": "Bad creds"}))
|
|
|
|
with pytest.raises(GitHubAppError) as exc:
|
|
mint_installation_token(
|
|
app_id=_APP_ID,
|
|
private_key_pem=private_pem,
|
|
installation_id=_INSTALLATION_ID,
|
|
_http=http,
|
|
_now=_fixed_now,
|
|
)
|
|
|
|
message = str(exc.value)
|
|
assert "tok" not in message
|
|
# No JWT material (RS256 JWTs start with the base64 header "eyJ").
|
|
assert "eyJ" not in message
|
|
|
|
|
|
def test_mint_http_error_is_scrubbed(rsa_keypair):
|
|
private_pem, _ = rsa_keypair
|
|
http = _FakeHttp(raise_exc=RuntimeError("boom"))
|
|
|
|
# A raised transport error propagates (fail closed); it must not carry the
|
|
# JWT. We do not catch a specific type here — only assert no JWT leaks if it
|
|
# were ever wrapped.
|
|
with pytest.raises(Exception) as exc: # noqa: PT011
|
|
mint_installation_token(
|
|
app_id=_APP_ID,
|
|
private_key_pem=private_pem,
|
|
installation_id=_INSTALLATION_ID,
|
|
_http=http,
|
|
_now=_fixed_now,
|
|
)
|
|
|
|
assert "eyJ" not in str(exc.value)
|
|
|
|
|
|
def test_mint_invalid_key_is_scrubbed():
|
|
# An empty/invalid PEM must fail closed with a scrubbed message (no key).
|
|
bad_key = "-----BEGIN PRIVATE KEY-----\nnotreallyakey\n-----END PRIVATE KEY-----"
|
|
http = _FakeHttp(_FakeResponse(201, {"token": "tok", "expires_at": _future_iso()}))
|
|
|
|
with pytest.raises(GitHubAppError) as exc:
|
|
mint_installation_token(
|
|
app_id=_APP_ID,
|
|
private_key_pem=bad_key,
|
|
installation_id=_INSTALLATION_ID,
|
|
_http=http,
|
|
_now=_fixed_now,
|
|
)
|
|
|
|
message = str(exc.value)
|
|
assert "could not build app JWT" in message
|
|
assert "notreallyakey" not in message
|