From df0bcc98616e6f4e6f112ecf51166d8c1075fbf6 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Fri, 9 Oct 2026 20:00:25 -0400 Subject: [PATCH 1/4] Red: no frame holds the token with a given client; the host's errors never quote their argument The token scanner now runs every publish path with a client the caller gives as well as with the client publish builds, and checks each traceback with frame locals when an exception escapes the HTTP client (from the transport, an event hook, an undecodable body, or an interrupt). GitHubPagesHost's argument errors are checked for the value given and for the token= given beside it, in str, repr and the traceback with locals. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey --- tests/publish_support.py | 12 +++++- tests/test_publish_token.py | 82 +++++++++++++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/tests/publish_support.py b/tests/publish_support.py index 28cd389..411faf8 100644 --- a/tests/publish_support.py +++ b/tests/publish_support.py @@ -142,5 +142,15 @@ def github( return FakeGitHub(files, token=TOKEN) -def host(gh: FakeGitHub, **kwargs: Any) -> ts.GitHubPagesHost: +#: How a test host reaches the fake API: through a client publish builds (``transport=``), or +#: through a client the caller gives (``client=``). +HOST_MODES = ("transport", "client") + + +def host(gh: FakeGitHub, mode: str = "transport", **kwargs: Any) -> ts.GitHubPagesHost: + if mode == "client": + import httpx + + given = httpx.Client(transport=gh.transport(), event_hooks=kwargs.pop("event_hooks", None) or {}) + return ts.GitHubPagesHost(gh.repository, token=TOKEN, client=given, **kwargs) return ts.GitHubPagesHost(gh.repository, token=TOKEN, transport=gh.transport(), **kwargs) diff --git a/tests/test_publish_token.py b/tests/test_publish_token.py index 14c6784..e26b970 100644 --- a/tests/test_publish_token.py +++ b/tests/test_publish_token.py @@ -30,7 +30,7 @@ import httpx import pytest from github_stub import FakeGitHub # publish_support puts scripts/ on the path -from publish_support import TOKEN, Docs, github, host +from publish_support import HOST_MODES, TOKEN, Docs, github, host import typedstandards as ts @@ -73,7 +73,7 @@ def captured(call: Callable[[], Any]) -> list[tuple[str, str]]: warnings.simplefilter("always") try: texts.append(("return value", repr(call()))) - except Exception as error: # every exception's text is scanned + except (Exception, KeyboardInterrupt) as error: # every exception's text is scanned texts.append(("exception str", str(error))) texts.append(("exception repr", repr(error))) texts.append(("traceback", "".join(traceback.format_exception(error)))) @@ -98,7 +98,7 @@ def leaks(token: str, texts: list[tuple[str, str]]) -> list[str]: # --- every publish path, scanned ------------------------------------------------------------- -def publish_paths(docs: Docs) -> dict[str, Callable[[], Any]]: +def publish_paths(docs: Docs, mode: str = "transport") -> dict[str, Callable[[], Any]]: """Each path of acceptance 1 as a call, on a fresh fake host per call.""" def run(listed: dict | None = None, setup: Callable[[FakeGitHub], None] | None = None, **kwargs: Any): @@ -108,8 +108,8 @@ def call() -> Any: setup(gh) target = kwargs.pop("_target", "publish") if target == "attestation": - return ts.publish_attestation(docs.withdrawal, host=host(gh), name="dog-licensing") - return ts.publish(kwargs.pop("_signed", docs.first), host=host(gh), **kwargs) + return ts.publish_attestation(docs.withdrawal, host=host(gh, mode), name="dog-licensing") + return ts.publish(kwargs.pop("_signed", docs.first), host=host(gh, mode), **kwargs) return call @@ -136,9 +136,11 @@ def call() -> Any: } -def test_no_output_holds_the_token_on_any_publish_path(docs: Docs) -> None: +@pytest.mark.parametrize("mode", HOST_MODES) +def test_no_output_holds_the_token_on_any_publish_path(docs: Docs, mode: str) -> None: + """Each path, with the client publish builds and with a client the caller gives.""" found = {} - for label, call in publish_paths(docs).items(): + for label, call in publish_paths(docs, mode).items(): texts = captured(call) assert texts, label if leaks(TOKEN, texts): @@ -336,3 +338,69 @@ def test_publishing_reads_the_token_variable_and_never_the_whole_environment( assert ts.TOKEN_VARIABLE in recorder.keys_read assert SEED_VARIABLE not in recorder.keys_read assert recorder.read_all is False + + +# --- an exception that escapes the HTTP client, with either client ------------------------------ + + +def _raises_inside(request: httpx.Request) -> httpx.Response: + raise RuntimeError("a transport failed with an error that is not a transport error") + + +def _interrupted(request: httpx.Request) -> httpx.Response: + raise KeyboardInterrupt + + +def _undecodable(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, headers={"content-encoding": "gzip"}, content=b"not gzip data") + + +def _failing_hook(request: httpx.Request) -> None: + raise RuntimeError("an event hook failed") + + +ESCAPES = { + "an error raised inside the transport": (_raises_inside, None), + "an interrupt inside the transport": (_interrupted, None), + "a body that does not decode": (_undecodable, None), + "an error raised by an event hook": (None, _failing_hook), +} + + +@pytest.mark.parametrize("mode", HOST_MODES) +@pytest.mark.parametrize("escape", ESCAPES) +def test_no_frame_holds_the_token_when_an_exception_escapes_the_client(docs: Docs, mode: str, escape: str) -> None: + """No frame a traceback shows, publish's or httpx's, holds the token as a plain value, with a + given client or publish's own, whatever escapes the request.""" + handler, hook = ESCAPES[escape] + if hook is not None and mode == "transport": + pytest.skip("an event hook belongs to a client the caller gives") + + def call() -> Any: + gh = github(docs) + if handler is not None: + gh.hook = handler + kwargs = {"event_hooks": {"request": [hook]}} if hook is not None else {} + return ts.publish(docs.first, host=host(gh, mode, **kwargs), name="dog-licensing", title="T") + + texts = captured(call) + assert any(where == "traceback with locals" for where, _ in texts), texts + assert leaks(TOKEN, texts) == [] + + +# --- GitHubPagesHost's own errors ----------------------------------------------------------------- + + +@pytest.mark.parametrize("argument", ["repository", "branch", "api_url"]) +def test_the_hosts_errors_never_quote_their_argument(argument: str) -> None: + """A token-shaped value given as the wrong argument stays out of the error's str, repr and + traceback with locals, and so does the token= given beside it.""" + value = "github_pat_TESTONLY_given_as_the_wrong_argument" + kwargs: dict[str, Any] = {"token": TOKEN} + if argument == "repository": + texts = captured(lambda: ts.GitHubPagesHost(value, **kwargs)) + else: + texts = captured(lambda: ts.GitHubPagesHost("example-owner/example-host", **{argument: value}, **kwargs)) + assert any(where == "exception repr" and text.startswith("ValueError(") for where, text in texts), texts + assert leaks(value, texts) == [] + assert leaks(TOKEN, texts) == [] From e061ad8d50a245a6b7c490a702251598dbc4b6fa Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Fri, 9 Oct 2026 20:25:54 -0400 Subject: [PATCH 2/4] No frame of a publish holds the token as a plain value; the host's errors never quote their argument The token reaches each request through an httpx.Auth that holds it, as a _Token, in a slot and withholds it from its repr, with a client the caller gives and with the client publish builds. No header dict, the client's or a request's, carries it. GitHubPagesHost checks its arguments in a helper whose messages quote none of them, and its constructor keeps no argument as a frame local when it raises. A repository or branch shaped like a GitHub token is refused. The test of the host's errors calls through module constants, so the only frames it checks are the package's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey --- CHANGELOG.md | 8 +++ README.md | 8 +-- src/typedstandards/_publish.py | 96 +++++++++++++++++++++++++++------- tests/test_publish_token.py | 22 +++++--- 4 files changed, 103 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 021140c..be2634e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- No frame of a publish holds the token as a plain value, with a given `client=` or the client the + call builds: the token reaches each request through an `httpx.Auth` that holds it and withholds + it from its `repr`, and no header dict carries it. +- `GitHubPagesHost`'s errors never quote their argument, and a `repository` or `branch` shaped + like a GitHub token is refused. + ## 0.2.0 — 2026-10-09 - `publish(signed, *, host, title, name=None, notebook=None, role="notebook", revises=None)` and diff --git a/README.md b/README.md index 85c22cc..38594fd 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,8 @@ ts.publish_attestation(withdrawal, host=host, name=receipt["name"]) ``` - **`GitHubPagesHost(repository, *, branch="main", token=None, ...)`**: the repository as - `owner/name`. Its `repr` shows the repository and the branch only. The HTTP client a call builds + `owner/name`. Its `repr` shows the repository and the branch only, and its errors never quote + an argument; a repository or branch shaped like a GitHub token is refused. The HTTP client a call builds ignores proxy and certificate environment variables; pass `client=` (an `httpx.Client`) for those. - **`publish(signed, *, host, title, name=None, notebook=None, role="notebook", revises=None)`** @@ -328,8 +329,9 @@ message names the request and GitHub's own message. A fine-grained personal access token, for the one publishing repository, with **Contents: read and write** and nothing else (enough for a public repository; a private one is unmeasured). Give it an expiry. It comes from `token=`, else from `TYPEDSTANDARDS_GITHUB_TOKEN`, read when a publish -runs; it is sent only as the `Authorization` header of the client the call builds and closes, and -it is in no message, log record, receipt or file. Never write it as a literal in a cell. Locally, +runs; it is added to each request's `Authorization` header by an `httpx.Auth` that holds it and +withholds it from its `repr`, whether the call builds its client or uses one given as `client=`. It +is in no message, log record, receipt, file, or frame local of a traceback. Never write it as a literal in a cell. Locally, set it with the seed, from the secret store that starts the kernel (`op run --env-file=… -- jupyter lab`); in a hosted notebook, from the hosting service's secret store, as for the seed below. diff --git a/src/typedstandards/_publish.py b/src/typedstandards/_publish.py index 9a81b9e..ce3a407 100644 --- a/src/typedstandards/_publish.py +++ b/src/typedstandards/_publish.py @@ -18,6 +18,7 @@ from __future__ import annotations import copy +import functools import json import logging import os @@ -111,14 +112,13 @@ def __init__( transport: httpx.BaseTransport | None = None, timeout: float = 30.0, ) -> None: - if not isinstance(repository, str) or not _REPOSITORY.match(repository): - raise ValueError(f"repository must be owner/name, as in https://github.com/owner/name: {repository!r}") - if not isinstance(branch, str) or not _BRANCH.match(branch) or ".." in branch: - raise ValueError(f"branch must be a branch name: {branch!r}") - if not isinstance(api_url, str) or not api_url.startswith("https://") or api_url.endswith("/"): - raise ValueError("api_url must be an https:// URL with no trailing /") - if token is not None and not isinstance(token, str): - raise TypeError("token must be a string") + problem = _host_argument_problem(repository, branch, api_url, token) + if problem is not None: + # The error never quotes an argument, and no argument stays a local of this frame, + # which a traceback with frame locals would print. + del repository, branch, token, api_url, client, transport + kind, message = problem + raise kind(message) self.repository = repository self.branch = branch self.api_url = api_url @@ -128,7 +128,7 @@ def __init__( self._transport = transport def __repr__(self) -> str: - return f"GitHubPagesHost({self.repository!r}, branch={self.branch!r})" + return f"GitHubPagesHost({getattr(self, 'repository', '?')!r}, branch={getattr(self, 'branch', '?')!r})" __str__ = __repr__ @@ -166,6 +166,29 @@ def _token_problem(value: str | None, source: str) -> str | None: return None +#: The prefixes of GitHub's token formats: a value that starts with one is a token, not a name. +_TOKEN_PREFIXES = ("github_pat_", "ghp_", "gho_", "ghu_", "ghs_", "ghr_") + + +def _host_argument_problem(repository: Any, branch: Any, api_url: Any, token: Any) -> tuple[type, str] | None: + """Why ``GitHubPagesHost`` refuses its arguments, in words that quote none of them.""" + for label, value in (("repository", repository), ("branch", branch)): + if isinstance(value, str) and any(part.startswith(_TOKEN_PREFIXES) for part in value.split("/")): + return ValueError, ( + f"{label} looks like a GitHub token (the value is not shown): pass a token as token=, or set " + f"{TOKEN_VARIABLE}" + ) + if not isinstance(repository, str) or not _REPOSITORY.match(repository): + return ValueError, "repository must be owner/name, as in https://github.com/owner/name (the value is not shown)" + if not isinstance(branch, str) or not _BRANCH.match(branch) or ".." in branch: + return ValueError, "branch must be a branch name (the value is not shown)" + if not isinstance(api_url, str) or not api_url.startswith("https://") or api_url.endswith("/"): + return ValueError, "api_url must be an https:// URL with no trailing / (the value is not shown)" + if token is not None and not isinstance(token, str): + return TypeError, "token must be a string" + return None + + def _refuse(message: str) -> NoReturn: raise PublishRefusedError(message) @@ -185,13 +208,16 @@ def __init__(self, host: GitHubPagesHost) -> None: self.host = host self.base = f"{host.api_url}/repos/{host.repository}" self._own = host._client is None - # The headers are built inside each call below, so no frame holds them as a local. + # The token reaches each request through an httpx.Auth that holds it in a slot and withholds + # it from its repr, on both paths: no header dict of the client's or of a request carries + # it, so no frame, publish's or httpx's, holds it as a plain value. + self._auth = _bearer_auth_class()(_Token(host._resolve_token())) if host._client is None: # trust_env=False: building the client does not iterate the environment (which holds the # signing seed) for proxy settings, and reads no .netrc. A caller who needs a proxy or a # certificate bundle passes client=. self._http = httpx.Client( - headers=_headers(host), + headers=_HEADERS, transport=host._transport, timeout=host.timeout, follow_redirects=False, @@ -200,7 +226,7 @@ def __init__(self, host: GitHubPagesHost) -> None: self._headers: dict[str, str] = {} else: self._http = host._client - self._headers = _headers(host) + self._headers = dict(_HEADERS) def close(self) -> None: if self._own: @@ -212,7 +238,12 @@ def _request_headers(self, accept: str | None) -> dict[str, str]: def _send(self, method: str, path: str, body: Any = None, *, accept: str | None = None) -> httpx.Response: try: response = self._http.request( - method, self.base + path, json=body, headers=self._request_headers(accept), follow_redirects=False + method, + self.base + path, + json=body, + headers=self._request_headers(accept), + auth=self._auth, + follow_redirects=False, ) except self._httpx.TransportError as error: _log.info("publish: %s %s -> no response (%s)", method, path, type(error).__name__) @@ -260,13 +291,38 @@ def head(self) -> str: return _object_id(sha, f"the head of {self.host.branch}") -def _headers(host: GitHubPagesHost) -> dict[str, str]: - return { - "Authorization": f"Bearer {host._resolve_token()}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "typedstandards-python", - } +#: The headers each request carries. The token is not one of them: ``_bearer_auth_class`` adds it. +_HEADERS = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "typedstandards-python", +} + + +@functools.cache +def _bearer_auth_class() -> type: + """An ``httpx.Auth`` that sets ``Authorization: Bearer `` on the request it sends. It + holds the token as a ``_Token`` in a slot; its ``repr`` withholds it, and it is not pickled. + httpx is imported here, so importing the package does not load it.""" + import httpx + + class BearerAuth(httpx.Auth): + __slots__ = ("_token",) + + def __init__(self, token: _Token) -> None: + self._token = token + + def __repr__(self) -> str: + return "" + + def __reduce__(self) -> NoReturn: + raise TypeError("a token is not pickled") + + def auth_flow(self, request: httpx.Request) -> Any: + request.headers["Authorization"] = "Bearer " + self._token.reveal() + yield request + + return BearerAuth class _ApiError(PublishError): diff --git a/tests/test_publish_token.py b/tests/test_publish_token.py index e26b970..1c9130d 100644 --- a/tests/test_publish_token.py +++ b/tests/test_publish_token.py @@ -391,16 +391,22 @@ def call() -> Any: # --- GitHubPagesHost's own errors ----------------------------------------------------------------- -@pytest.mark.parametrize("argument", ["repository", "branch", "api_url"]) +#: A token-shaped value given as the wrong argument. Module constants, so the calls below hold no +#: local of their own that a traceback with frame locals would print: only the package's frames +#: are under test. +WRONG_ARGUMENT = "github_pat_TESTONLY_given_as_the_wrong_argument" +MISPLACED = { + "repository": lambda: ts.GitHubPagesHost(WRONG_ARGUMENT, token=TOKEN), + "branch": lambda: ts.GitHubPagesHost("example-owner/example-host", branch=WRONG_ARGUMENT, token=TOKEN), + "api_url": lambda: ts.GitHubPagesHost("example-owner/example-host", api_url=WRONG_ARGUMENT, token=TOKEN), +} + + +@pytest.mark.parametrize("argument", MISPLACED) def test_the_hosts_errors_never_quote_their_argument(argument: str) -> None: """A token-shaped value given as the wrong argument stays out of the error's str, repr and traceback with locals, and so does the token= given beside it.""" - value = "github_pat_TESTONLY_given_as_the_wrong_argument" - kwargs: dict[str, Any] = {"token": TOKEN} - if argument == "repository": - texts = captured(lambda: ts.GitHubPagesHost(value, **kwargs)) - else: - texts = captured(lambda: ts.GitHubPagesHost("example-owner/example-host", **{argument: value}, **kwargs)) + texts = captured(MISPLACED[argument]) assert any(where == "exception repr" and text.startswith("ValueError(") for where, text in texts), texts - assert leaks(value, texts) == [] + assert leaks(WRONG_ARGUMENT, texts) == [] assert leaks(TOKEN, texts) == [] From 9d45109228bc1874e640320833c1bd8113cb26c3 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Fri, 9 Oct 2026 23:10:10 -0400 Subject: [PATCH 3/4] Red: no frame holds the token on httpx's real transport; only token-shaped names are refused tests/test_publish_transport.py drives httpx.HTTPTransport, so each request head is written through httpcore and h11, over a replaced sync network backend whose stream opens no socket and whose write fails: an interrupt, an error that is not a transport error, or a transport error. With publish's own client and with a given one, it walks every frame local of the exception that escapes, of its __cause__ and __context__, and the traceback formatted with frame locals. A control test shows the walk reads httpcore's frames. GitHubPagesHost accepts a short repository or branch name that starts with a token prefix, and refuses a token-shaped one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey --- tests/test_publish_token.py | 31 ++++++ tests/test_publish_transport.py | 179 ++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 tests/test_publish_transport.py diff --git a/tests/test_publish_token.py b/tests/test_publish_token.py index 1c9130d..06185c5 100644 --- a/tests/test_publish_token.py +++ b/tests/test_publish_token.py @@ -410,3 +410,34 @@ def test_the_hosts_errors_never_quote_their_argument(argument: str) -> None: assert any(where == "exception repr" and text.startswith("ValueError(") for where, text in texts), texts assert leaks(WRONG_ARGUMENT, texts) == [] assert leaks(TOKEN, texts) == [] + + +#: A token-shaped part: a token prefix and a run of token characters as long as a token's. Built at +#: run time, so this file holds no literal of a token's shape. +TOKEN_SHAPED = "ghp_" + "TESTONLY" * 5 + + +@pytest.mark.parametrize( + "kwargs", + [ + {"repository": "example-owner/ghp_notes"}, + {"repository": "example-owner/gho_tools"}, + {"repository": "example-owner/github_pat_docs"}, + {"repository": "example-owner/example-host", "branch": "ghs_release-notes"}, + ], + ids=["a ghp_ repository", "a gho_ repository", "a github_pat_ repository", "a ghs_ branch"], +) +def test_a_name_that_starts_with_a_token_prefix_is_accepted(kwargs: dict[str, str]) -> None: + """Only a token-shaped part is refused: a short name with a token prefix is a name.""" + made = ts.GitHubPagesHost(**kwargs) + assert made.repository == kwargs["repository"] + + +@pytest.mark.parametrize("argument", ["repository", "branch"]) +def test_a_token_shaped_repository_or_branch_is_refused(argument: str) -> None: + kwargs = {"repository": "example-owner/example-host", argument: f"example-owner/{TOKEN_SHAPED}"} + if argument == "branch": + kwargs["branch"] = TOKEN_SHAPED + with pytest.raises(ValueError, match="looks like a GitHub token") as caught: + ts.GitHubPagesHost(**kwargs) + assert TOKEN_SHAPED not in str(caught.value) and TOKEN_SHAPED not in repr(caught.value) diff --git a/tests/test_publish_transport.py b/tests/test_publish_transport.py new file mode 100644 index 0000000..3250fb6 --- /dev/null +++ b/tests/test_publish_transport.py @@ -0,0 +1,179 @@ +"""The token on httpx's real transport (typedstandards#141 P5): ``httpx.HTTPTransport``, so each +request's head is written through httpcore and h11, as it is outside the tests. + +httpcore's sync network backend is replaced, for one test at a time, by one whose ``connect_tcp`` +returns a stream that opens no socket (the autouse offline guard stays on) and whose ``write`` +fails. The rule checked: when an exception escapes a publish's request, with publish's own client +or a given one, no frame it carries holds the token, in the exception, its ``__cause__`` or its +``__context__``, and the formatted traceback with frame locals does not show it either. + +Locked versions: httpx 0.28.1, httpcore 1.0.9, h11 0.16.0 (``uv.lock``). +""" + +from __future__ import annotations + +import traceback +from collections.abc import Iterator +from typing import Any + +import httpcore +import httpx +import pytest +from httpcore._backends.base import NetworkStream +from httpcore._backends.sync import SyncBackend +from httpcore._exceptions import ReadError, WriteError, map_exceptions +from publish_support import TOKEN, Docs + +import typedstandards as ts + +SECRET_TAIL = TOKEN[len("github_pat_") :] + + +def _write_interrupted() -> None: + raise KeyboardInterrupt + + +def _write_failed() -> None: + raise RuntimeError("the stream failed while writing") + + +def _write_os_error() -> None: + # As httpcore's own SyncStream.write maps a socket's OSError. httpcore's HTTP/1.1 connection + # passes over a WriteError from the request head and reads the response, so the transport + # error that escapes is the read's (_read_os_error). + with map_exceptions({OSError: WriteError}): + raise OSError("the connection was reset while writing") + + +def _read_os_error() -> bytes: + with map_exceptions({OSError: ReadError}): + raise OSError("the connection was reset while reading") + + +#: How the stream fails: (its write, its read). The read is reached only after a WriteError. +FAILURES = { + "an interrupt": (_write_interrupted, None), + "an error that is not a transport error": (_write_failed, None), + "a transport error": (_write_os_error, _read_os_error), +} + + +class FailingStream(NetworkStream): + """A stream with no socket: TLS is a no-op, and the first write fails one way.""" + + def __init__(self, fail: Any) -> None: + self.fail_write, self.fail_read = fail + self.written = 0 + + def read(self, max_bytes: int, timeout: float | None = None) -> bytes: + return self.fail_read() if self.fail_read is not None else b"" + + def write(self, buffer: bytes, timeout: float | None = None) -> None: + self.written += len(buffer) + self.fail_write() + + def close(self) -> None: + pass + + def start_tls(self, ssl_context: Any, server_hostname: str | None = None, timeout: float | None = None) -> Any: + return self + + def get_extra_info(self, info: str) -> Any: + return None + + +@pytest.fixture +def failing_network(monkeypatch: pytest.MonkeyPatch) -> Iterator[dict[str, Any]]: + """Make httpcore's sync backend hand out a FailingStream; the test sets ``fail``.""" + state: dict[str, Any] = {"fail": None, "streams": []} + + def connect_tcp(self: SyncBackend, host: str, port: int, **kwargs: Any) -> NetworkStream: + stream = FailingStream(state["fail"]) + state["streams"].append(stream) + return stream + + monkeypatch.setattr(SyncBackend, "connect_tcp", connect_tcp) + yield state + + +def _exceptions(error: BaseException) -> Iterator[BaseException]: + """The exception and every exception chained to it, as ``__cause__`` or ``__context__``.""" + seen: set[int] = set() + pending = [error] + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + yield current + pending += [e for e in (current.__cause__, current.__context__) if e is not None] + + +def _frame_texts(error: BaseException) -> list[tuple[str, str]]: + """The repr of every local of every frame each chained exception's traceback carries, and each + exception's traceback formatted with frame locals.""" + texts = [] + for current in _exceptions(error): + tb = current.__traceback__ + while tb is not None: + frame = tb.tb_frame + where = f"{frame.f_code.co_filename.rsplit('/', 1)[-1]}:{tb.tb_lineno} {frame.f_code.co_name}" + for name, value in frame.f_locals.items(): + try: + texts.append((f"{where} local {name}", repr(value))) + except Exception: # a repr that fails holds nothing to read + pass + tb = tb.tb_next + formatted = traceback.TracebackException.from_exception(current, capture_locals=True) + texts.append((f"{type(current).__name__} formatted with locals", "".join(formatted.format()))) + texts.append((f"{type(current).__name__} str and repr", f"{current!s} {current!r}")) + return texts + + +def _host(mode: str) -> ts.GitHubPagesHost: + if mode == "client": + return ts.GitHubPagesHost( + "example-owner/example-host", token=TOKEN, client=httpx.Client(transport=httpx.HTTPTransport()) + ) + return ts.GitHubPagesHost("example-owner/example-host", token=TOKEN, transport=httpx.HTTPTransport()) + + +@pytest.mark.parametrize("mode", ["transport", "client"]) +@pytest.mark.parametrize("failure", FAILURES) +def test_no_frame_holds_the_token_when_the_real_transport_fails( + docs: Docs, failing_network: dict[str, Any], mode: str, failure: str +) -> None: + """With publish's own client and with a given one, whatever escapes the request head's write + on httpx's real transport, no frame it carries holds the token.""" + failing_network["fail"] = FAILURES[failure] + try: + ts.publish(docs.first, host=_host(mode), name="dog-licensing", title="T") + except BaseException as error: # KeyboardInterrupt included: the test reads what escaped + escaped = error + else: + pytest.fail("the publish did not fail") + # The request head reached the stream, through httpcore and h11. + assert failing_network["streams"] and failing_network["streams"][0].written > 0 + if failure == "an interrupt": + assert type(escaped) is KeyboardInterrupt + elif failure == "a transport error": + assert isinstance(escaped, ts.PublishError) + assert any(isinstance(e, httpcore.ReadError) for e in _exceptions(escaped)) + else: + assert type(escaped) is RuntimeError + texts = _frame_texts(escaped) + found = sorted({where for where, text in texts if TOKEN in text or SECRET_TAIL in text}) + assert found == [] + + +def test_the_frame_walk_finds_a_token_held_below_it(failing_network: dict[str, Any]) -> None: + """The walk above reads httpcore's frames: a request whose header dict carries the token, sent + without publish, shows it there.""" + failing_network["fail"] = (_write_failed, None) + client = httpx.Client(transport=httpx.HTTPTransport()) + try: + client.get("https://api.example.org/", headers={"Authorization": "Bearer " + TOKEN}) + except RuntimeError as error: + escaped = error + found = {where for where, text in _frame_texts(escaped) if SECRET_TAIL in text} + assert any("http11.py" in where for where in found), sorted(found) From 003a7d32b0ebbbb22a23cde06e812875e2b20334 Mon Sep 17 00:00:00 2001 From: Nathan Storey Date: Fri, 9 Oct 2026 23:11:25 -0400 Subject: [PATCH 4/4] Whatever escapes a publish's request carries no frame that holds the token; only token-shaped names are refused _Api._send clears the locals of every finished frame carried by an exception that escapes Client.request, and by the exceptions chained to it, for every exception kind: a transport error before it becomes publish's own error, and anything else, an interrupt included, before it is re-raised as itself. httpcore and h11 hold the request head while they write it; those frames are among the cleared. Frames still executing are left as they are. Nothing in publish catches such an exception before _send. GitHubPagesHost refuses a repository or branch part that is token-shaped: a GitHub token prefix followed by 30 or more token characters, below the 36 of the shortest documented token. A short name with a token prefix is accepted. The README, the CHANGELOG and the comment at _Api.__init__ state what the tests check, on httpx's mock transport and on its real transport over a failing stream. The token scanner's frame-local offender now raises from a step after the request, since publish clears the frames below one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LJBCtCpvKX2vdg53xqw8ji Signed-off-by: Nathan Storey --- CHANGELOG.md | 13 ++++++++----- README.md | 9 +++++++-- src/typedstandards/_publish.py | 34 ++++++++++++++++++++++++++++++---- tests/test_publish_token.py | 18 ++++++++++++++++-- 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be2634e..d7a6e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,14 @@ ## Unreleased -- No frame of a publish holds the token as a plain value, with a given `client=` or the client the - call builds: the token reaches each request through an `httpx.Auth` that holds it and withholds - it from its `repr`, and no header dict carries it. -- `GitHubPagesHost`'s errors never quote their argument, and a `repository` or `branch` shaped - like a GitHub token is refused. +- The token reaches each request through an `httpx.Auth` that holds it and withholds it from its + `repr`, with a given `client=` or the client the call builds; no header dict carries it. When an + exception escapes a request, the locals of the frames it carries (httpx's, httpcore's and h11's + included) are cleared before it propagates, so a traceback with frame locals does not show the + token. Tested with httpx's mock transport and with its real transport over a failing stream. +- `GitHubPagesHost`'s errors never quote their argument. A `repository` or `branch` with a part + shaped like a GitHub token (a token prefix, then 30 or more token characters) is refused; a + short name with a token prefix is accepted. ## 0.2.0 — 2026-10-09 diff --git a/README.md b/README.md index 38594fd..7a6a21e 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,9 @@ ts.publish_attestation(withdrawal, host=host, name=receipt["name"]) - **`GitHubPagesHost(repository, *, branch="main", token=None, ...)`**: the repository as `owner/name`. Its `repr` shows the repository and the branch only, and its errors never quote - an argument; a repository or branch shaped like a GitHub token is refused. The HTTP client a call builds + an argument. A repository or branch with a part shaped like a GitHub token (a token prefix such + as `ghp_` or `github_pat_`, then 30 or more token characters) is refused; a short name with such + a prefix, such as `ghp_notes`, is accepted. The HTTP client a call builds ignores proxy and certificate environment variables; pass `client=` (an `httpx.Client`) for those. - **`publish(signed, *, host, title, name=None, notebook=None, role="notebook", revises=None)`** @@ -331,7 +333,10 @@ and write** and nothing else (enough for a public repository; a private one is u it an expiry. It comes from `token=`, else from `TYPEDSTANDARDS_GITHUB_TOKEN`, read when a publish runs; it is added to each request's `Authorization` header by an `httpx.Auth` that holds it and withholds it from its `repr`, whether the call builds its client or uses one given as `client=`. It -is in no message, log record, receipt, file, or frame local of a traceback. Never write it as a literal in a cell. Locally, +is in no message, log record, receipt or file. When an exception escapes a request, the locals of +the frames it carries, httpx's, httpcore's and h11's included, are cleared before it propagates, +so a traceback with frame locals does not show the token; the tests check this with httpx's mock +transport and with its real transport over a failing stream. Never write it as a literal in a cell. Locally, set it with the seed, from the secret store that starts the kernel (`op run --env-file=… -- jupyter lab`); in a hosted notebook, from the hosting service's secret store, as for the seed below. diff --git a/src/typedstandards/_publish.py b/src/typedstandards/_publish.py index ce3a407..9b85090 100644 --- a/src/typedstandards/_publish.py +++ b/src/typedstandards/_publish.py @@ -166,14 +166,16 @@ def _token_problem(value: str | None, source: str) -> str | None: return None -#: The prefixes of GitHub's token formats: a value that starts with one is a token, not a name. -_TOKEN_PREFIXES = ("github_pat_", "ghp_", "gho_", "ghu_", "ghs_", "ghr_") +#: A token-shaped part of a name: one of GitHub's token prefixes, then 30 or more token +#: characters. The shortest documented token runs 36 after its prefix (a 40-character classic +#: token); a short name with a prefix, such as ``ghp_notes``, is a name. +_TOKEN_SHAPED = re.compile(r"^(?:github_pat_|ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9_.-]{30,}$") def _host_argument_problem(repository: Any, branch: Any, api_url: Any, token: Any) -> tuple[type, str] | None: """Why ``GitHubPagesHost`` refuses its arguments, in words that quote none of them.""" for label, value in (("repository", repository), ("branch", branch)): - if isinstance(value, str) and any(part.startswith(_TOKEN_PREFIXES) for part in value.split("/")): + if isinstance(value, str) and any(_TOKEN_SHAPED.match(part) for part in value.split("/")): return ValueError, ( f"{label} looks like a GitHub token (the value is not shown): pass a token as token=, or set " f"{TOKEN_VARIABLE}" @@ -210,7 +212,9 @@ def __init__(self, host: GitHubPagesHost) -> None: self._own = host._client is None # The token reaches each request through an httpx.Auth that holds it in a slot and withholds # it from its repr, on both paths: no header dict of the client's or of a request carries - # it, so no frame, publish's or httpx's, holds it as a plain value. + # it. httpcore and h11 hold the request head while they write it; _send clears the locals + # of those frames from whatever escapes the request. Tested on MockTransport and on + # httpx.HTTPTransport with a failing stream (tests/test_publish_transport.py). self._auth = _bearer_auth_class()(_Token(host._resolve_token())) if host._client is None: # trust_env=False: building the client does not iterate the environment (which holds the @@ -246,8 +250,12 @@ def _send(self, method: str, path: str, body: Any = None, *, accept: str | None follow_redirects=False, ) except self._httpx.TransportError as error: + _clear_frames(error) _log.info("publish: %s %s -> no response (%s)", method, path, type(error).__name__) raise _NoResponse(f"{method} {path}: no response ({type(error).__name__})") from None + except BaseException as error: # KeyboardInterrupt included; it is re-raised as itself + _clear_frames(error) + raise _log.info("publish: %s %s -> %s", method, path, response.status_code) return response @@ -325,6 +333,24 @@ def auth_flow(self, request: httpx.Request) -> Any: return BearerAuth +def _clear_frames(error: BaseException) -> None: + """Clear the locals of every finished frame the exception and the exceptions chained to it + (``__cause__``, ``__context__``) carry. Below ``Client.request``, httpcore and h11 hold the + request head, Authorization line included, while they write it; a traceback with frame locals + would print those frames. Frames still executing, publish's own among them, are skipped.""" + import traceback + + seen: set[int] = set() + pending: list[BaseException | None] = [error] + while pending: + current = pending.pop() + if current is None or id(current) in seen: + continue + seen.add(id(current)) + traceback.clear_frames(current.__traceback__) + pending += [current.__cause__, current.__context__] + + class _ApiError(PublishError): def __init__(self, status: int, message: str) -> None: super().__init__(message) diff --git a/tests/test_publish_token.py b/tests/test_publish_token.py index 06185c5..aa1b6ab 100644 --- a/tests/test_publish_token.py +++ b/tests/test_publish_token.py @@ -227,10 +227,24 @@ def leak(request: httpx.Request) -> None: warnings.warn(f"sent {value}", stacklevel=1) elif how == "exception": raise RuntimeError(f"sent {value}") - elif how == "frame local": - raise RuntimeError("a frame holding the header as a local raised") return None + if how == "frame local": + # A step after the request (the receipt) whose frame holds the token and raises. publish + # clears the frames below a request, so the offending frame is one it does not clear. + from typedstandards import _publish + + real = _publish._receipt + + def leaky_receipt(*args: Any, **kwargs: Any) -> Any: + held = TOKEN # noqa: F841 (the offending local) + raise RuntimeError("a frame holding the token as a local raised") + + _publish._receipt = leaky_receipt + try: + return ts.publish(docs.first, host=host(gh), name="dog-licensing", title="T") + finally: + _publish._receipt = real gh.hook = leak return ts.publish(docs.first, host=host(gh), name="dog-licensing", title="T")