harden(agent-team): scrub token from HTTP transport errors; fail closed on bad expiry

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.
This commit is contained in:
Adam Moussa 2026-06-24 17:14:24 -04:00
parent 61ab1f0cd5
commit 183789a239
4 changed files with 104 additions and 15 deletions

View file

@ -721,12 +721,20 @@ def app_workflow_dispatcher(
"Authorization": f"Bearer {token_provider.token()}",
}
body = {"ref": ref, "inputs": inputs}
if _http is not None:
resp = _http.post(url, json=body, headers=headers, timeout=15.0)
else:
import requests # deferred: optional dependency
# Wrap the transport so a requests/transport exception can NEVER carry the
# Bearer token out unscrubbed: re-raise as a DispatcherError with the
# exception TYPE only (mirrors the git seam's secret-hygiene discipline).
try:
if _http is not None:
resp = _http.post(url, json=body, headers=headers, timeout=15.0)
else:
import requests # deferred: optional dependency
resp = requests.post(url, json=body, headers=headers, timeout=15.0)
resp = requests.post(url, json=body, headers=headers, timeout=15.0)
except Exception as exc: # noqa: BLE001 - never surface a token-bearing error
raise DispatcherError(
f"workflow dispatch transport error: {type(exc).__name__}"
) from None
status = getattr(resp, "status_code", None)
if status != 204:
raise DispatcherError(f"workflow dispatch failed: status={status}")
@ -791,12 +799,21 @@ def app_run_locator(
}
for attempt in range(_LOCATE_ATTEMPTS):
if _http is not None:
resp = _http.get(url, params=params, headers=headers, timeout=15.0)
else:
import requests # deferred: optional dependency
# Wrap the transport so a requests/transport exception can NEVER carry
# the Bearer token out unscrubbed (status/type only, like the git seam).
try:
if _http is not None:
resp = _http.get(url, params=params, headers=headers, timeout=15.0)
else:
import requests # deferred: optional dependency
resp = requests.get(url, params=params, headers=headers, timeout=15.0)
resp = requests.get(
url, params=params, headers=headers, timeout=15.0
)
except Exception as exc: # noqa: BLE001 - never surface a token-bearing error
raise DispatcherError(
f"run list transport error: {type(exc).__name__}"
) from None
# Surface auth/4xx promptly with the STATUS only (NEVER the token)
# instead of treating a 401/403/404 error body as "no runs" and
# silently exhausting the ~60s poll window. A genuine 200 with no

View file

@ -170,8 +170,12 @@ class TokenProvider:
_http=self._http,
_now=self._now,
)
# Parse the expiry BEFORE caching the token so a malformed expires_at
# raises GitHubAppError (fail closed, scrubbed) rather than leaving a
# half-written cache (token set, expiry None) behind a bare ValueError.
expiry = _parse_expires_at(result["expires_at"])
self._cached_token = result["token"]
self._cached_expiry = _parse_expires_at(result["expires_at"])
self._cached_expiry = expiry
return self._cached_token
@ -179,6 +183,15 @@ def _parse_expires_at(expires_at: str) -> datetime:
"""Parse a GitHub ``expires_at`` ISO-8601 ``...Z`` string to aware UTC.
GitHub returns e.g. ``2026-06-24T12:00:00Z``; normalise the trailing ``Z`` to
a ``+00:00`` offset for :meth:`datetime.fromisoformat`.
a ``+00:00`` offset for :meth:`datetime.fromisoformat`. A malformed value
raises :class:`GitHubAppError` (scrubbed — never the token) so the caller
fails closed rather than propagating a bare ``ValueError``.
"""
return datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
try:
return datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
except (ValueError, AttributeError) as exc:
# Avoid even the literal substring "tok" so a naive secret scan / a test
# asserting the token value is absent cannot false-positive on the word.
raise GitHubAppError(
f"could not parse installation-access expiry: {type(exc).__name__}"
) from None

View file

@ -479,6 +479,27 @@ def test_app_workflow_dispatcher_fails_closed_without_token_in_message() -> None
assert "ghs_TESTTOKEN" not in str(excinfo.value)
class _RaisingHttp:
"""A transport whose post/get raises an exception that embeds the token."""
def post(self, url, *, json=None, headers=None, timeout=None):
raise RuntimeError(f"connection reset: {headers['Authorization']}")
def get(self, url, *, params=None, headers=None, timeout=None):
raise RuntimeError(f"connection reset: {headers['Authorization']}")
def test_app_workflow_dispatcher_scrubs_token_from_transport_error() -> None:
# A transport exception must be re-raised as a DispatcherError carrying the
# exception TYPE only — never the Bearer token, even if the underlying error
# text embedded it.
fire = app_workflow_dispatcher(_StubTokenProvider(), _http=_RaisingHttp())
with pytest.raises(DispatcherError) as excinfo:
fire(owner="owner", repo="repo", inputs={"task_id": TASK}, ref="main")
assert "ghs_TESTTOKEN" not in str(excinfo.value)
assert excinfo.value.__cause__ is None # `from None` breaks the chain
# --------------------------------------------------------------------------- #
# app_run_locator (App-token REST seam; field mapping + run-name correlation)
# --------------------------------------------------------------------------- #
@ -553,5 +574,18 @@ def test_app_run_locator_raises_on_auth_error_without_token_in_message() -> None
)
assert "403" in str(excinfo.value)
assert "ghs_TESTTOKEN" not in str(excinfo.value)
# Fails fast on the first attempt — no poll-window exhaustion.
assert len(http.get_calls) == 1
def test_app_run_locator_scrubs_token_from_transport_error() -> None:
locate = app_run_locator(
_StubTokenProvider(), _http=_RaisingHttp(), _sleep=lambda *_a: None
)
with pytest.raises(DispatcherError) as excinfo:
locate(
owner="owner",
repo="repo",
task_id=TASK,
since_iso="2026-06-23T09:58:00Z",
)
assert "ghs_TESTTOKEN" not in str(excinfo.value)
assert excinfo.value.__cause__ is None

View file

@ -209,6 +209,31 @@ def test_provider_remints_near_expiry(rsa_keypair):
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 #
# --------------------------------------------------------------------------- #