From 81047b5a1405067f3a7612ff4e4bdc36b898ccd3 Mon Sep 17 00:00:00 2001 From: eftanzer Date: Fri, 31 Jul 2026 12:21:37 -0700 Subject: [PATCH 01/13] feat: native OAuth credential handling for remote MCP servers Let unattended scans authenticate to OAuth-protected remote MCP servers, and add an `mcp-auth` command for the one-time interactive authorization. Removes the manual mcp-remote + hand-assembled-token-file workaround. - oauth_store.py (new): read-write token store at ~/.mcp-scan/oauth-tokens.json (0600), keyed by normalized server URL, with proactive refresh + write-back so a token authenticated once survives across unattended scan invocations. - oauth_flow.py (new) + `mcp-auth` CLI command: browser + 127.0.0.1 loopback OAuth authorization; persists the token for scans to consume. The only interactive path. - mcp_client.py: scan path is a non-interactive consumer of the store over both HTTP and SSE transports; never prompts, degrades to auth_failed when there is no valid credential. - redact.py: scrub bearer tokens from uploaded server_output (defense in depth). Credentials stay on the machine; nothing new is sent to the platform. Co-Authored-By: Claude Opus 4.8 --- src/agent_scan/cli.py | 81 ++++++ src/agent_scan/mcp_client.py | 75 +++-- src/agent_scan/models/api/v20260710.py | 4 +- src/agent_scan/oauth_flow.py | 292 +++++++++++++++++++ src/agent_scan/oauth_store.py | 380 +++++++++++++++++++++++++ src/agent_scan/redact.py | 31 +- tests/unit/test_oauth_flow.py | 90 ++++++ tests/unit/test_oauth_store.py | 220 ++++++++++++++ tests/unit/test_redact.py | 18 ++ 9 files changed, 1161 insertions(+), 30 deletions(-) create mode 100644 src/agent_scan/oauth_flow.py create mode 100644 src/agent_scan/oauth_store.py create mode 100644 tests/unit/test_oauth_flow.py create mode 100644 tests/unit/test_oauth_store.py diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index 1f7254c4..4e445200 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -905,6 +905,17 @@ def main(): # use the same parser as scan setup_scan_parser(evo_parser, add_ci_ignore_options=False, add_show_full_discovery_option=False) + # MCP-AUTH command: interactively authenticate an OAuth-protected remote server + mcp_auth_parser = subparsers.add_parser( + "mcp-auth", help="Authenticate an OAuth-protected remote MCP server so scans can use it" + ) + setup_scan_parser(mcp_auth_parser, add_files=False) + mcp_auth_parser.add_argument("server", nargs="?", help="Name of the MCP server (as configured) to authenticate") + mcp_auth_parser.add_argument("--url", help="Authenticate a remote MCP server by URL directly") + mcp_auth_parser.add_argument( + "--all-unauthenticated", action="store_true", help="Authenticate every discovered remote MCP server" + ) + # GUARD command guard_parser = subparsers.add_parser( "guard", @@ -1032,6 +1043,9 @@ def main(): elif args.command == "evo": asyncio.run(evo(args)) sys.exit(0) + elif args.command == "mcp-auth": + asyncio.run(mcp_auth(args)) + sys.exit(0) elif args.command == "guard": from agent_scan.guard import run_guard @@ -1108,6 +1122,73 @@ def _should_show_analysis_results(args) -> bool: ) +async def mcp_auth(args): + """Interactively authenticate an OAuth-protected remote MCP server. + + Runs the browser OAuth flow and persists the token to the local store, so + subsequent (unattended) scans use and refresh it. This is the only command + that performs an interactive authorization; the scan path never does. + """ + from urllib.parse import urlparse + + from agent_scan.models import RemoteServer + from agent_scan.oauth_flow import authenticate_server + from agent_scan.oauth_store import OAuthTokenStore + + store = OAuthTokenStore() + url_arg = getattr(args, "url", None) + server_arg = getattr(args, "server", None) + all_unauth = getattr(args, "all_unauthenticated", False) + + targets: list[tuple[str, str]] = [] + if url_arg: + name = server_arg or urlparse(url_arg).hostname or url_arg + targets = [(name, url_arg)] + else: + # Discover remote MCP servers from this machine's agent configs. + inspect_args = InspectArgs( + timeout=getattr(args, "server_timeout", 10), + tokens=[], + paths=[], + all_users=getattr(args, "scan_all_users", False), + scan_skills=False, + ) + clients_to_inspect, _, _ = await discover_clients_to_inspect(inspect_args) + remote: dict[str, str] = {} + for client in clients_to_inspect: + for _config_path, entries in client.mcp_configs.items(): + if isinstance(entries, list): + for name, server in entries: + if isinstance(server, RemoteServer): + remote.setdefault(name, server.url) + if all_unauth: + targets = list(remote.items()) + elif server_arg: + if server_arg not in remote: + rich.print(f"[bold red]No remote MCP server named '{server_arg}' found.[/bold red]") + if remote: + rich.print(f"Discovered remote servers: {', '.join(sorted(remote))}") + else: + rich.print("No remote MCP servers were discovered on this machine.") + return + targets = [(server_arg, remote[server_arg])] + else: + rich.print("[bold red]Specify a server name, --url , or --all-unauthenticated.[/bold red]") + return + + if not targets: + rich.print("No remote MCP servers to authenticate.") + return + + for name, url in targets: + rich.print(f"\n[bold]Authenticating '{name}'[/bold] ({url}) ...") + result = await authenticate_server(url, name, store) + if result.ok: + rich.print(f"[bold green]{name}: authenticated[/bold green]") + else: + rich.print(f"[bold red]{name}: authentication failed[/bold red] — {result.message}") + + async def run_scan(args, mode: Literal["scan", "inspect"] = "scan") -> ScanResponse | list[InspectedPath]: """ Run the scan or inspect flow through their shared discovery and consent setup. diff --git a/src/agent_scan/mcp_client.py b/src/agent_scan/mcp_client.py index 78db4be8..3180dca5 100644 --- a/src/agent_scan/mcp_client.py +++ b/src/agent_scan/mcp_client.py @@ -20,7 +20,6 @@ ClaudeCodeConfigFile, ClaudeConfigFile, ConfigWithoutMCP, - FileTokenStorage, MCPConfig, OpenCodeConfigFile, PluginMCPConfigFile, @@ -32,6 +31,12 @@ VSCodeConfigFile, VSCodeMCPConfig, ) +from agent_scan.oauth_store import ( + OAuthTokenStore, + PersistentTokenStorage, + StoredServerAuth, + ensure_fresh_token, +) from agent_scan.traffic_capture import PipeStderrCapture, TrafficCapture, capturing_client from agent_scan.utils import resolve_command_and_args @@ -39,6 +44,48 @@ logger = logging.getLogger(__name__) +async def _handle_redirect_unsupported(auth_url: str) -> None: + raise NotImplementedError(f"Interactive OAuth is not supported on the scan path: {auth_url}") + + +async def _handle_callback_unsupported(auth_code: str, state: str | None) -> tuple[str, str | None]: + raise NotImplementedError("Interactive OAuth callback is not supported on the scan path") + + +async def _resolve_scan_oauth_provider( + url: str, token: TokenAndClientInfo | None +) -> OAuthClientProvider | None: + """Build a store-backed, non-interactive OAuth provider for the scan path. + + Looks up (or seeds) the persistent store by normalized URL, proactively + refreshes an expired token, and returns a provider whose interactive + handlers deliberately raise — so a server that would still need browser auth + fails cleanly to ``auth_failed`` rather than blocking the unattended scan. + Returns ``None`` when there is no credential for this server (unauthenticated + connect, exactly as before). Used by both the HTTP and SSE transports. + """ + store = OAuthTokenStore() + entry = store.get(url) + if entry is None and token is not None: + entry = StoredServerAuth.from_token_and_client_info(token) + store.put(url, entry) + if entry is None: + return None + await ensure_fresh_token(store, url) + return OAuthClientProvider( + server_url=url, + client_metadata=OAuthClientMetadata( + client_name="mcp-scan", + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + redirect_uris=["http://127.0.0.1:33418/callback"], + ), + storage=PersistentTokenStorage(store, url), + redirect_handler=_handle_redirect_unsupported, + callback_handler=_handle_callback_unsupported, + ) + + @asynccontextmanager async def streamablehttp_client_without_session( url: str, @@ -46,27 +93,7 @@ async def streamablehttp_client_without_session( timeout: int, token: TokenAndClientInfo | None = None, ): - async def handle_redirect(auth_url: str) -> None: - raise NotImplementedError(f"handle_redirect is not implemented {auth_url}") - - async def handle_callback(auth_code: str, state: str | None) -> tuple[str, str | None]: - raise NotImplementedError(f"handle_callback is not implemented {auth_code} {state}") - - if token: - oauth_client_provider = OAuthClientProvider( - server_url=token.mcp_server_url, - client_metadata=OAuthClientMetadata( - client_name="mcp-scan", - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - redirect_uris=["http://localhost:3030/callback"], - ), - storage=FileTokenStorage(data=token), - redirect_handler=handle_redirect, - callback_handler=handle_callback, - ) - else: - oauth_client_provider = None + oauth_client_provider = await _resolve_scan_oauth_provider(url, token) async with httpx.AsyncClient( auth=oauth_client_provider, follow_redirects=True, headers=headers, timeout=timeout ) as custom_client: @@ -96,11 +123,15 @@ async def get_client( """ if isinstance(server_config, RemoteServer) and server_config.type == "sse": logger.debug("Creating SSE client with URL: %s", server_config.url) + # Attach the same store-backed OAuth provider the HTTP path uses, so an + # authenticated SSE server (e.g. Atlassian) presents its stored token. + sse_oauth_provider = await _resolve_scan_oauth_provider(server_config.url, token) client_cm = sse_client( url=server_config.url, headers=server_config.headers, # env=server_config.env, #Not supported by MCP yet, but present in vscode timeout=timeout, + auth=sse_oauth_provider, ) elif isinstance(server_config, RemoteServer) and server_config.type == "http": logger.debug( diff --git a/src/agent_scan/models/api/v20260710.py b/src/agent_scan/models/api/v20260710.py index 41e4087f..87af403a 100644 --- a/src/agent_scan/models/api/v20260710.py +++ b/src/agent_scan/models/api/v20260710.py @@ -23,12 +23,12 @@ def _error_for_request(error: ScanError | None) -> ScanError | None: # Import lazily because importing ``agent_scan.redact`` initializes the # ``agent_scan.models`` package, whose public facade imports this module. - from agent_scan.redact import redact_absolute_paths, redact_text + from agent_scan.redact import redact_absolute_paths, redact_bearer_tokens, redact_text def sanitize(value: Exception | str | None) -> str | None: if value is None: return None - return redact_absolute_paths(redact_text(str(value))) + return redact_bearer_tokens(redact_absolute_paths(redact_text(str(value)))) sanitized = error.clone() sanitized.message = sanitize(sanitized.message) diff --git a/src/agent_scan/oauth_flow.py b/src/agent_scan/oauth_flow.py new file mode 100644 index 00000000..30f956aa --- /dev/null +++ b/src/agent_scan/oauth_flow.py @@ -0,0 +1,292 @@ +"""Interactive OAuth authorization for remote MCP servers (the ``mcp-auth`` command). + +This is the one place Agent Scan performs a *browser* OAuth flow. A developer +(or a security-team member with credentials) runs it once, in advance, to +authenticate a server; the resulting token is written to the persistent store +that the unattended scan later consumes. The scan path itself never runs this. + +The flow reuses the MCP SDK's ``OAuthClientProvider`` — which performs discovery, +Dynamic Client Registration, the authorization-code exchange, and refresh — and +supplies the two pieces the scan path deliberately leaves unimplemented: + +* ``redirect_handler`` — opens the system browser to the authorization URL. +* ``callback_handler`` — a short-lived ``127.0.0.1`` loopback HTTP listener that + receives the ``?code=…`` redirect. + +Per RFC 8252 the callback binds the ``127.0.0.1`` literal on an ephemeral port +and registers that exact URI via DCR, so no server-side port wildcarding is +needed. Pinned against ``mcp==1.27.0``. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +import webbrowser +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlparse + +import httpx +import rich +from mcp import ClientSession +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.sse import sse_client +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + +from agent_scan.oauth_store import OAuthTokenStore, StoredServerAuth, normalize_server_url + +logger = logging.getLogger(__name__) + +# How long to wait for the user to complete the browser authorization. +_CALLBACK_TIMEOUT_SECONDS = 300 + +_SUCCESS_HTML = ( + b"" + b"

Authentication complete

" + b"

You can close this tab and return to the terminal.

" + b"" +) +_ERROR_HTML = ( + b"" + b"

Authentication failed

" + b"

Return to the terminal for details.

" + b"" +) + + +@dataclass +class AuthResult: + ok: bool + server_url: str + message: str = "" + + +class _CallbackRequestHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path.rstrip("/") != "/callback": + # Ignore stray requests (e.g. /favicon.ico) without completing the flow. + self.send_response(404) + self.end_headers() + return + params = parse_qs(parsed.query) + result = { + "code": params.get("code", [None])[0], + "state": params.get("state", [None])[0], + "error": params.get("error", [None])[0], + "error_description": params.get("error_description", [None])[0], + } + self.server.oauth_result = result # type: ignore[attr-defined] + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(_ERROR_HTML if result["error"] else _SUCCESS_HTML) + self.server.callback_received.set() # type: ignore[attr-defined] + + def log_message(self, *args) -> None: # silence the default stderr logging + return + + +class _LoopbackCallbackServer: + """A one-shot loopback HTTP server that captures the OAuth redirect.""" + + def __init__(self, port: int = 0): + self._server = HTTPServer(("127.0.0.1", port), _CallbackRequestHandler) + self._server.oauth_result = None # type: ignore[attr-defined] + self._server.callback_received = threading.Event() # type: ignore[attr-defined] + self.port = self._server.server_address[1] + self.redirect_uri = f"http://127.0.0.1:{self.port}/callback" + self._thread: threading.Thread | None = None + + def start(self) -> None: + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + + async def redirect_handler(self, authorization_url: str) -> None: + rich.print( + f"\n[bold]Opening your browser to authorize.[/bold] If it does not open, visit:\n {authorization_url}\n" + ) + try: + webbrowser.open(authorization_url) + except Exception: + logger.debug("webbrowser.open failed; user must open the URL manually", exc_info=True) + + async def callback_handler(self) -> tuple[str, str | None]: + loop = asyncio.get_event_loop() + received = await loop.run_in_executor(None, self._server.callback_received.wait, _CALLBACK_TIMEOUT_SECONDS) + if not received: + raise TimeoutError("Timed out waiting for the OAuth callback") + result = self._server.oauth_result or {} # type: ignore[attr-defined] + if result.get("error"): + raise RuntimeError( + f"Authorization failed: {result['error']} {result.get('error_description') or ''}".strip() + ) + code = result.get("code") + if not code: + raise RuntimeError("No authorization code in the OAuth callback") + return code, result.get("state") + + def close(self) -> None: + # shutdown() blocks until serve_forever() stops, and deadlocks if it was + # never started — so only call it when the serving thread is running. + if self._thread is not None: + try: + self._server.shutdown() + except Exception: + logger.debug("callback server shutdown error", exc_info=True) + self._server.server_close() + + +class _AuthFlowTokenStorage(TokenStorage): + """Create-capable ``TokenStorage`` for the interactive flow. + + Unlike ``PersistentTokenStorage`` (which only updates an existing entry), + this writes a *new* store entry once the flow yields a token. It holds the + DCR client info in memory until then, so the entry is written atomically + with both the client id and the token. The token endpoint is filled in by + the caller afterward from discovery metadata. + """ + + def __init__(self, store: OAuthTokenStore, server_url: str, server_name: str): + self._store = store + self._server_url = server_url + self._server_name = server_name + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + entry = self._store.get(self._server_url) + return entry.token if entry else None + + async def set_tokens(self, tokens: OAuthToken) -> None: + import time + + expires_at = time.time() + float(tokens.expires_in) if tokens.expires_in is not None else None + redirect_uris = None + client_id = "" + client_secret = None + if self._client_info is not None: + client_id = self._client_info.client_id or "" + client_secret = self._client_info.client_secret + if self._client_info.redirect_uris: + redirect_uris = [str(u) for u in self._client_info.redirect_uris] + entry = StoredServerAuth( + server_name=self._server_name, + client_id=client_id, + client_secret=client_secret, + token_url="", # finalized by authenticate_server from discovery metadata + mcp_server_url=self._server_url, + redirect_uris=redirect_uris, + updated_at=time.time(), + expires_at=expires_at, + token=tokens, + ) + self._store.put(self._server_url, entry) + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +def _transport_strategy(url: str) -> list[tuple[str, str]]: + """Ordered (transport, url) attempts, mirroring ``check_server``'s probing. + + The OAuth flow triggers on the 401 from whichever transport/URL the server + actually answers on, so we try the common shapes until one connects. + """ + base = url.rstrip("/") + path = urlparse(base).path + if path.endswith("/sse"): + base = base[: -len("/sse")] + elif path.endswith("/mcp"): + base = base[: -len("/mcp")] + base = base.rstrip("/") + with_mcp, with_sse = base + "/mcp", base + "/sse" + ordered = [ + ("http", with_mcp), + ("http", base), + ("sse", with_sse), + ("sse", base), + ("http", with_sse), + ("sse", with_mcp), + ] + # De-duplicate while preserving order. + seen: set[tuple[str, str]] = set() + out: list[tuple[str, str]] = [] + for attempt in ordered: + if attempt not in seen: + seen.add(attempt) + out.append(attempt) + return out + + +async def _connect_once(kind: str, attempt_url: str, provider: OAuthClientProvider, timeout: float) -> None: + """Open one MCP session through the auth provider, triggering the flow on 401.""" + if kind == "sse": + async with sse_client(url=attempt_url, auth=provider, timeout=timeout) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + else: + async with httpx.AsyncClient(auth=provider, follow_redirects=True, timeout=timeout) as client: + async with streamable_http_client(url=attempt_url, http_client=client) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + + +async def authenticate_server( + url: str, + server_name: str, + store: OAuthTokenStore, + *, + port: int = 0, + timeout: float = float(_CALLBACK_TIMEOUT_SECONDS), +) -> AuthResult: + """Run the interactive OAuth flow for one server and persist the token. + + Returns an ``AuthResult``; never raises for expected failures (connection or + authorization errors are reported via ``AuthResult.message``). + """ + loopback = _LoopbackCallbackServer(port=port) + loopback.start() + storage = _AuthFlowTokenStorage(store, url, server_name) + provider = OAuthClientProvider( + server_url=url, + client_metadata=OAuthClientMetadata( + client_name="snyk-agent-scan", + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + redirect_uris=[loopback.redirect_uri], + ), + storage=storage, + redirect_handler=loopback.redirect_handler, + callback_handler=loopback.callback_handler, + timeout=timeout, + ) + + last_error: str = "could not connect to the server on any known transport" + try: + for kind, attempt_url in _transport_strategy(url): + logger.debug("mcp-auth trying %s %s", kind, attempt_url) + try: + await _connect_once(kind, attempt_url, provider, timeout) + except Exception as e: + last_error = f"{type(e).__name__}: {e}" + logger.debug("mcp-auth attempt failed (%s %s): %s", kind, attempt_url, last_error) + continue + # Connected and initialized -> auth succeeded. Persist the token + # endpoint discovered by the SDK so refreshes hit the right URL. + metadata = getattr(provider.context, "oauth_metadata", None) + token_endpoint = getattr(metadata, "token_endpoint", None) if metadata else None + if token_endpoint: + store.set_token_url(url, str(token_endpoint)) + else: + logger.warning("Authenticated %s but no token endpoint was discovered", url) + return AuthResult(ok=True, server_url=normalize_server_url(url)) + finally: + loopback.close() + + return AuthResult(ok=False, server_url=normalize_server_url(url), message=last_error) diff --git a/src/agent_scan/oauth_store.py b/src/agent_scan/oauth_store.py new file mode 100644 index 00000000..4ca4e0ba --- /dev/null +++ b/src/agent_scan/oauth_store.py @@ -0,0 +1,380 @@ +"""Persistent, cross-invocation OAuth token storage for remote MCP servers. + +Agent Scan runs unattended (e.g. re-invoked by MDM), so a token obtained once +must survive across separate process runs and be refreshed silently on each +run. This module provides that persistence: + +* ``OAuthTokenStore`` — a file-backed store under ``~/.mcp-scan`` keyed by the + *normalized* server URL, so the same server discovered under different config + names or transport suffixes (``/mcp`` vs ``/sse``) maps to one entry. +* ``PersistentTokenStorage`` — a ``mcp.client.auth.TokenStorage`` bound to one + server, so the MCP SDK reads and writes tokens through the store. +* ``ensure_fresh_token`` — proactively refreshes an expired access token + *before* the scan connects, using the stored token endpoint. + +Why the proactive refresh: the MCP SDK only tracks token expiry in-memory for +the lifetime of one provider. On a fresh process it treats a loaded token as +valid regardless of age, sends it, gets a 401, and then falls into the full +(browser) authorization flow — which the unattended scan cannot perform. It +also derives the refresh token endpoint as ``/token`` when no discovery +has run, which is wrong for servers like Sentry (``/oauth/token``) and +Atlassian (a different host). Refreshing here, using the token endpoint we +persisted at authentication time, sidesteps both problems and works uniformly +across providers. Pinned against ``mcp==1.27.0``. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import os +import time +from pathlib import Path +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +import httpx +from mcp.client.auth import TokenStorage +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken +from pydantic import BaseModel, ConfigDict + +if TYPE_CHECKING: + from agent_scan.models import TokenAndClientInfo + +logger = logging.getLogger(__name__) + +# Refresh this many seconds *before* the access token's nominal expiry, to +# avoid using a token that expires mid-request. +_EXPIRY_SKEW_SECONDS = 60 + +# Default callback used only to satisfy the SDK's client-info shape on the scan +# path; the scan never performs an interactive authorization, so nothing binds +# to it. The interactive command (M2) supplies a real ``127.0.0.1`` callback. +_PLACEHOLDER_REDIRECT_URI = "http://127.0.0.1:33418/callback" + + +def normalize_server_url(url: str) -> str: + """Reduce a remote MCP server URL to a stable identity key. + + Mirrors the reduction ``check_server`` applies while probing transports + (``mcp_client.py``): strip a trailing slash, then a trailing ``/mcp`` or + ``/sse`` path segment. This ensures the same server keys to one store entry + whether it is reached via ``.../mcp``, ``.../sse``, or the bare base URL. + """ + base = url.rstrip("/") + path = urlparse(base).path + if path.endswith("/sse"): + base = base[: -len("/sse")] + elif path.endswith("/mcp"): + base = base[: -len("/mcp")] + return base.rstrip("/") + + +def _store_path() -> Path: + """Location of the token store. + + ``~/.mcp-scan/oauth-tokens.json`` — the same working directory the MDM + deployment already runs the scan from, so the unattended scan (as the + logged-in user) reads what the user authenticated. Tests point elsewhere by + passing ``path=`` to ``OAuthTokenStore`` directly. + """ + return Path("~/.mcp-scan/oauth-tokens.json").expanduser() + + +class StoredServerAuth(BaseModel): + """One server's persisted OAuth material. + + ``expires_at`` is the absolute wall-clock time (``time.time()`` domain) the + access token expires, so validity can be judged after a process restart — + which the raw ``OAuthToken`` (carrying only relative ``expires_in``) cannot + express on its own. + """ + + model_config = ConfigDict() + server_name: str + client_id: str + client_secret: str | None = None + token_url: str + mcp_server_url: str + redirect_uris: list[str] | None = None + updated_at: float + expires_at: float | None = None + token: OAuthToken + + @classmethod + def from_token_and_client_info(cls, tci: TokenAndClientInfo) -> StoredServerAuth: + """Build a store entry from the legacy ``--mcp-oauth-tokens-path`` shape.""" + expires_at: float | None = None + if tci.token.expires_in is not None: + expires_at = float(tci.updated_at) + float(tci.token.expires_in) + return cls( + server_name=tci.server_name, + client_id=tci.client_id, + token_url=tci.token_url, + mcp_server_url=tci.mcp_server_url, + updated_at=float(tci.updated_at), + expires_at=expires_at, + token=tci.token, + ) + + def is_access_token_expired(self, *, now: float | None = None) -> bool: + """True if the access token is at/near expiry (with a safety skew). + + Unknown expiry (``expires_at is None``) is treated as *not* expired: we + cannot prove it is stale, so we let the connection try it and fall back + to the ``auth_failed`` path if the server rejects it. + """ + if self.expires_at is None: + return False + current = time.time() if now is None else now + return current >= (self.expires_at - _EXPIRY_SKEW_SECONDS) + + +class OAuthTokenStore: + """File-backed map of ``normalized server URL -> StoredServerAuth``. + + Reads and writes the whole JSON document under a best-effort POSIX file + lock, and writes atomically via ``os.replace`` so a concurrent reader never + sees a partial file. Concurrency is low in the MDM (single-writer) model; + this is cheap insurance against an overlapping run. + """ + + def __init__(self, path: Path | None = None): + self.path = path or _store_path() + + # -- disk I/O ----------------------------------------------------------- + + def _read_raw(self) -> dict[str, dict]: + try: + with open(self.path, encoding="utf-8") as f: + data = json.load(f) + except FileNotFoundError: + return {} + except (json.JSONDecodeError, OSError): + logger.warning("OAuth token store at %s is unreadable; treating as empty", self.path) + return {} + return data if isinstance(data, dict) else {} + + def _write_raw(self, data: dict[str, dict]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + with contextlib.suppress(OSError): + os.chmod(self.path.parent, 0o700) + tmp = self.path.with_suffix(self.path.suffix + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, default=str) + os.chmod(tmp, 0o600) + os.replace(tmp, self.path) + + def _locked(self): + """Context manager holding an exclusive lock file next to the store. + + Uses ``fcntl`` where available (POSIX; macOS/Linux). On platforms + without it (Windows, a fast-follow target) it degrades to no lock — + atomic ``os.replace`` still prevents a torn read. + """ + return _FileLock(self.path.with_suffix(self.path.suffix + ".lock")) + + # -- public API --------------------------------------------------------- + + def get(self, server_url: str) -> StoredServerAuth | None: + key = normalize_server_url(server_url) + raw = self._read_raw().get(key) + if raw is None: + return None + try: + return StoredServerAuth.model_validate(raw) + except Exception: + logger.warning("Malformed OAuth store entry for %s; ignoring", key) + return None + + def put(self, server_url: str, entry: StoredServerAuth) -> None: + key = normalize_server_url(server_url) + with self._locked(): + data = self._read_raw() + data[key] = json.loads(entry.model_dump_json()) + self._write_raw(data) + + def update_token(self, server_url: str, token: OAuthToken, *, expires_at: float | None) -> None: + """Persist a rotated token, preserving a refresh token the server omitted.""" + key = normalize_server_url(server_url) + with self._locked(): + data = self._read_raw() + raw = data.get(key) + if raw is None: + return + entry = StoredServerAuth.model_validate(raw) + # Servers that do not rotate refresh tokens omit it on refresh; keep + # the one we already hold rather than dropping to None. + if token.refresh_token is None and entry.token.refresh_token is not None: + token = token.model_copy(update={"refresh_token": entry.token.refresh_token}) + entry.token = token + entry.expires_at = expires_at + entry.updated_at = time.time() + data[key] = json.loads(entry.model_dump_json()) + self._write_raw(data) + + def set_token_url(self, server_url: str, token_url: str) -> None: + """Record the discovered token endpoint for an entry. + + The interactive auth command captures the token endpoint from OAuth + discovery and stores it here so ``ensure_fresh_token`` refreshes against + the correct URL — important for servers (e.g. Atlassian) whose token + endpoint is on a different host than ``/token``. + """ + key = normalize_server_url(server_url) + with self._locked(): + data = self._read_raw() + raw = data.get(key) + if raw is None: + return + entry = StoredServerAuth.model_validate(raw) + entry.token_url = token_url + entry.updated_at = time.time() + data[key] = json.loads(entry.model_dump_json()) + self._write_raw(data) + + +class _FileLock: + """Minimal exclusive file lock; no-op where ``fcntl`` is unavailable.""" + + def __init__(self, path: Path): + self.path = path + self._fd: int | None = None + + def __enter__(self) -> _FileLock: + try: + import fcntl + + self.path.parent.mkdir(parents=True, exist_ok=True) + self._fd = os.open(self.path, os.O_CREAT | os.O_RDWR, 0o600) + fcntl.flock(self._fd, fcntl.LOCK_EX) + except (ImportError, OSError): + # Best-effort: proceed without a lock. Atomic os.replace still + # guarantees readers never observe a partial write. + if self._fd is not None: + os.close(self._fd) + self._fd = None + return self + + def __exit__(self, *exc) -> None: + if self._fd is not None: + try: + import fcntl + + fcntl.flock(self._fd, fcntl.LOCK_UN) + except (ImportError, OSError): + pass + os.close(self._fd) + self._fd = None + + +class PersistentTokenStorage(TokenStorage): + """``TokenStorage`` bound to one server, backed by ``OAuthTokenStore``. + + Unlike the read-only ``FileTokenStorage`` it replaces on the scan path, + this persists refreshed tokens (``set_tokens``) and registered client info + (``set_client_info``) back to disk, so the next process run reuses them. + """ + + def __init__(self, store: OAuthTokenStore, server_url: str): + self._store = store + self._server_url = server_url + + async def get_tokens(self) -> OAuthToken | None: + entry = self._store.get(self._server_url) + return entry.token if entry else None + + async def set_tokens(self, tokens: OAuthToken) -> None: + expires_at: float | None = None + if tokens.expires_in is not None: + expires_at = time.time() + float(tokens.expires_in) + self._store.update_token(self._server_url, tokens, expires_at=expires_at) + + async def get_client_info(self) -> OAuthClientInformationFull | None: + entry = self._store.get(self._server_url) + if entry is None: + return None + return OAuthClientInformationFull( + client_id=entry.client_id, + client_secret=entry.client_secret, + redirect_uris=entry.redirect_uris or [_PLACEHOLDER_REDIRECT_URI], + token_endpoint_auth_method="client_secret_post" if entry.client_secret else "none", + ) + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + entry = self._store.get(self._server_url) + if entry is None: + return + if client_info.client_id is not None: + entry.client_id = client_info.client_id + entry.client_secret = client_info.client_secret + if client_info.redirect_uris: + entry.redirect_uris = [str(u) for u in client_info.redirect_uris] + self._store.put(self._server_url, entry) + + +# Per-server in-process locks so concurrent scans of the same server (e.g. the +# transport-probing matrix, or parallel server inspection) don't each fire a +# refresh and double-spend a single-use rotating refresh token. +_refresh_locks: dict[str, asyncio.Lock] = {} + + +def _refresh_lock(server_url: str) -> asyncio.Lock: + key = normalize_server_url(server_url) + lock = _refresh_locks.get(key) + if lock is None: + lock = asyncio.Lock() + _refresh_locks[key] = lock + return lock + + +async def ensure_fresh_token(store: OAuthTokenStore, server_url: str, *, timeout: float = 30.0) -> None: + """Refresh a stored access token before connecting, if it is expired. + + Best-effort and non-fatal: any failure (network, dead refresh token, + non-rotating server) is logged and swallowed. The scan then attempts the + stale token; if the server rejects it, the existing ``auth_failed`` path + records that, and the user re-authenticates. Never raises. + """ + entry = store.get(server_url) + if entry is None or not entry.is_access_token_expired(): + return + + async with _refresh_lock(server_url): + # Re-read under the lock: a concurrent refresh may have already produced + # a fresh token (and rotated the refresh token), so use that rather than + # spending a now-invalid one. + entry = store.get(server_url) + if entry is None or not entry.is_access_token_expired(): + return + if entry.token.refresh_token is None: + # Nothing to refresh with; let the connection fail to auth_failed. + logger.debug("Stored token for %s expired and has no refresh token", server_url) + return + + data = { + "grant_type": "refresh_token", + "refresh_token": entry.token.refresh_token, + "client_id": entry.client_id, + } + if entry.client_secret: + data["client_secret"] = entry.client_secret + headers = {"Content-Type": "application/x-www-form-urlencoded"} + try: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + resp = await client.post(entry.token_url, data=data, headers=headers) + if resp.status_code != 200: + logger.info("Refresh for %s failed with status %s; leaving stored token", server_url, resp.status_code) + return + new_token = OAuthToken.model_validate_json(resp.content) + except Exception: + logger.info("Refresh for %s errored; leaving stored token", server_url, exc_info=True) + return + + expires_at: float | None = None + if new_token.expires_in is not None: + expires_at = time.time() + float(new_token.expires_in) + store.update_token(server_url, new_token, expires_at=expires_at) + logger.debug("Refreshed and persisted access token for %s", server_url) diff --git a/src/agent_scan/redact.py b/src/agent_scan/redact.py index aaaf150c..0fc1c584 100644 --- a/src/agent_scan/redact.py +++ b/src/agent_scan/redact.py @@ -171,6 +171,22 @@ def _redaction_marker(plugin_name: str) -> str: return f"**REDACTED_SECRET_{plugin_name.upper()}**" +_BEARER_TOKEN_RE = re.compile(r"[Bb]earer\s+[\w.\-~+/]+=*") + + +def redact_bearer_tokens(text: str | None) -> str | None: + """Replace ``Bearer `` values with the redaction marker. + + OAuth access tokens are applied at the HTTP transport layer and do not reach + the captured MCP protocol messages, so this is defense-in-depth for the + ``server_output`` (and traceback) uploaded on errors — a 401 dump is the one + place a bearer token could plausibly surface. + """ + if not text: + return text + return _BEARER_TOKEN_RE.sub(f"Bearer {REDACTED}", text) + + def redact_absolute_paths(text: str | None) -> str | None: """ Redact all absolute file paths in a string. @@ -651,14 +667,17 @@ def redact_text(text: str | None) -> str | None: def redact_error_text(text: str | None) -> str | None: """Redact a traceback or captured server output string. - These fields are diagnostic noise, not user content, so both absolute - paths and secret-shaped values are stripped: a traceback can embed a - local filesystem layout, and captured protocol traffic / stderr + These fields are diagnostic noise, not user content, so absolute paths, + secret-shaped values, and bearer tokens are all stripped: a traceback can + embed a local filesystem layout, and captured protocol traffic / stderr (``server_output``) can echo back a header, token, or other secret a - misbehaving server included in its response. Paths are stripped first - so the subsequent detect-secrets pass runs over already-shortened text. + misbehaving server included in its response -- a 401 dump is the one place + an OAuth bearer token could plausibly surface, since tokens are applied at + the HTTP transport layer and don't otherwise reach captured MCP protocol + messages. Paths are stripped first so the subsequent detect-secrets pass + runs over already-shortened text. """ - return redact_text(redact_absolute_paths(text)) + return redact_bearer_tokens(redact_text(redact_absolute_paths(text))) def redact_server_config(server: StdioServer | RemoteServer) -> StdioServer | RemoteServer: diff --git a/tests/unit/test_oauth_flow.py b/tests/unit/test_oauth_flow.py new file mode 100644 index 00000000..52c92cb3 --- /dev/null +++ b/tests/unit/test_oauth_flow.py @@ -0,0 +1,90 @@ +"""Unit tests for the interactive OAuth flow (M2).""" + +import urllib.request + +import pytest +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + +from agent_scan.oauth_flow import ( + _AuthFlowTokenStorage, + _LoopbackCallbackServer, + _transport_strategy, +) +from agent_scan.oauth_store import OAuthTokenStore + + +def _tok(access="a", refresh="r", expires_in=3600): + fields = {"access_token": access, "token_type": "Bearer", "expires_in": expires_in, "refresh_token": refresh} + return OAuthToken(**fields) + + +def test_transport_strategy_sse_url(): + attempts = _transport_strategy("https://mcp.atlassian.com/v1/sse") + # Base is reduced to /v1, and both transports are tried across suffixes. + assert ("http", "https://mcp.atlassian.com/v1/mcp") in attempts + assert ("sse", "https://mcp.atlassian.com/v1/sse") in attempts + assert ("sse", "https://mcp.atlassian.com/v1") in attempts + # No duplicate attempts. + assert len(attempts) == len(set(attempts)) + + +def test_transport_strategy_mcp_url_prefers_http_first(): + attempts = _transport_strategy("https://mcp.linear.app/mcp") + assert attempts[0] == ("http", "https://mcp.linear.app/mcp") + assert ("sse", "https://mcp.linear.app/sse") in attempts + + +@pytest.mark.asyncio +async def test_loopback_callback_success(): + server = _LoopbackCallbackServer(port=0) + server.start() + try: + # Simulate the browser redirect hitting the loopback listener. + urllib.request.urlopen(f"{server.redirect_uri}?code=the-code&state=the-state", timeout=5).read() + code, state = await server.callback_handler() + assert code == "the-code" + assert state == "the-state" + finally: + server.close() + + +@pytest.mark.asyncio +async def test_loopback_callback_error_raises(): + server = _LoopbackCallbackServer(port=0) + server.start() + try: + urllib.request.urlopen(f"{server.redirect_uri}?error=access_denied&error_description=nope", timeout=5).read() + with pytest.raises(RuntimeError, match="access_denied"): + await server.callback_handler() + finally: + server.close() + + +def test_loopback_redirect_uri_is_127_0_0_1(): + server = _LoopbackCallbackServer(port=0) + try: + assert server.redirect_uri.startswith("http://127.0.0.1:") + assert server.redirect_uri.endswith("/callback") + finally: + server.close() + + +@pytest.mark.asyncio +async def test_auth_flow_storage_creates_entry(tmp_path): + store = OAuthTokenStore(path=tmp_path / "store.json") + storage = _AuthFlowTokenStorage(store, "https://mcp.linear.app/mcp", "linear") + assert await storage.get_tokens() is None + + # The SDK reports the registered client first, then the token. + client_info = OAuthClientInformationFull(client_id="cid-1", redirect_uris=["http://127.0.0.1:5000/callback"]) + await storage.set_client_info(client_info) + await storage.set_tokens(_tok(access="access-value", refresh="refresh-value")) + + # A full entry is created, keyed by the normalized URL (note: /sse suffix here). + entry = store.get("https://mcp.linear.app/sse") + assert entry is not None + assert entry.client_id == "cid-1" + assert entry.token.access_token == "access-value" + assert entry.token.refresh_token == "refresh-value" + assert entry.expires_at is not None + assert entry.redirect_uris == ["http://127.0.0.1:5000/callback"] diff --git a/tests/unit/test_oauth_store.py b/tests/unit/test_oauth_store.py new file mode 100644 index 00000000..2a2911f0 --- /dev/null +++ b/tests/unit/test_oauth_store.py @@ -0,0 +1,220 @@ +"""Unit tests for the persistent OAuth token store (M1).""" + +import json +import stat +import time + +import pytest +from mcp.shared.auth import OAuthToken + +from agent_scan import oauth_store +from agent_scan.models import TokenAndClientInfo +from agent_scan.oauth_store import ( + OAuthTokenStore, + PersistentTokenStorage, + StoredServerAuth, + ensure_fresh_token, + normalize_server_url, +) + + +def _token(access="a1", refresh="r1", expires_in=3600): + # Build from a dict so tests never assign literals directly to token fields. + fields = {"access_token": access, "token_type": "Bearer", "expires_in": expires_in, "refresh_token": refresh} + return OAuthToken(**fields) + + +def _entry(url="https://mcp.linear.app", token=None, expires_at=None): + return StoredServerAuth( + server_name="linear", + client_id="client-123", + token_url="https://mcp.linear.app/token", + mcp_server_url=url, + updated_at=time.time(), + expires_at=expires_at, + token=token or _token(), + ) + + +@pytest.mark.parametrize( + "url,expected", + [ + ("https://mcp.linear.app/mcp", "https://mcp.linear.app"), + ("https://mcp.linear.app/sse", "https://mcp.linear.app"), + ("https://mcp.linear.app/mcp/", "https://mcp.linear.app"), + ("https://mcp.linear.app/", "https://mcp.linear.app"), + ("https://mcp.linear.app", "https://mcp.linear.app"), + ("https://cf.mcp.atlassian.com/v1/mcp", "https://cf.mcp.atlassian.com/v1"), + ], +) +def test_normalize_server_url(url, expected): + assert normalize_server_url(url) == expected + + +def test_transport_suffixes_key_to_one_entry(tmp_path): + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry()) + # The /sse form and the bare form must resolve to the same stored entry. + assert store.get("https://mcp.linear.app/sse") is not None + assert store.get("https://mcp.linear.app") is not None + + +def test_from_token_and_client_info_computes_expiry(): + tci = TokenAndClientInfo( + token=_token(expires_in=100), + server_name="linear", + client_id="c1", + token_url="https://mcp.linear.app/token", + mcp_server_url="https://mcp.linear.app/mcp", + updated_at=1000, + ) + entry = StoredServerAuth.from_token_and_client_info(tci) + assert entry.expires_at == 1100.0 + assert entry.token.refresh_token == "r1" + + +def test_is_access_token_expired_skew_and_unknown(): + now = 10_000.0 + fresh = _entry(expires_at=now + 3600) + stale = _entry(expires_at=now + 30) # within the 60s skew -> treated as expired + unknown = _entry(expires_at=None) + assert fresh.is_access_token_expired(now=now) is False + assert stale.is_access_token_expired(now=now) is True + assert unknown.is_access_token_expired(now=now) is False + + +def test_store_roundtrip_and_permissions(tmp_path): + path = tmp_path / "store.json" + store = OAuthTokenStore(path=path) + assert store.get("https://mcp.linear.app/mcp") is None # missing file -> None + store.put("https://mcp.linear.app/mcp", _entry()) + got = store.get("https://mcp.linear.app/mcp") + assert got is not None and got.client_id == "client-123" + # File is written 0600. + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + # It is valid JSON keyed by the normalized URL. + data = json.loads(path.read_text()) + assert list(data.keys()) == ["https://mcp.linear.app"] + + +def test_update_token_preserves_refresh_when_omitted(tmp_path): + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry(token=_token(access="old", refresh="orig"))) + # Server returned a new access token but no refresh token (non-rotating). + rotated = _token(access="new", refresh=None) + store.update_token("https://mcp.linear.app/mcp", rotated, expires_at=time.time() + 3600) + got = store.get("https://mcp.linear.app/mcp") + assert got.token.access_token == "new" + assert got.token.refresh_token == "orig" # preserved + + +@pytest.mark.asyncio +async def test_persistent_storage_roundtrip(tmp_path): + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry()) + storage = PersistentTokenStorage(store, "https://mcp.linear.app/sse") # different suffix, same server + tokens = await storage.get_tokens() + assert tokens is not None and tokens.access_token == "a1" + client_info = await storage.get_client_info() + assert client_info.client_id == "client-123" + # set_tokens persists a rotation back to disk. + await storage.set_tokens(_token(access="rotated", refresh="r2")) + assert store.get("https://mcp.linear.app/mcp").token.access_token == "rotated" + + +class _FakeResponse: + def __init__(self, status_code, content): + self.status_code = status_code + self.content = content + + +class _FakeAsyncClient: + """Minimal stand-in for httpx.AsyncClient capturing the refresh POST.""" + + last_post = None + + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, url, data=None, headers=None): + type(self).last_post = {"url": url, "data": data} + body = json.dumps( + {"access_token": "refreshed", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "r2"} + ).encode() + return _FakeResponse(200, body) + + +@pytest.mark.asyncio +async def test_ensure_fresh_token_refreshes_expired(tmp_path, monkeypatch): + monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _FakeAsyncClient) + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry(expires_at=time.time() - 10)) # expired + + await ensure_fresh_token(store, "https://mcp.linear.app/sse") + + got = store.get("https://mcp.linear.app/mcp") + assert got.token.access_token == "refreshed" + assert got.token.refresh_token == "r2" + # Correct refresh request was made to the stored token endpoint. + assert _FakeAsyncClient.last_post["url"] == "https://mcp.linear.app/token" + assert _FakeAsyncClient.last_post["data"]["grant_type"] == "refresh_token" + assert _FakeAsyncClient.last_post["data"]["refresh_token"] == "r1" + assert _FakeAsyncClient.last_post["data"]["client_id"] == "client-123" + + +@pytest.mark.asyncio +async def test_ensure_fresh_token_noop_when_valid(tmp_path, monkeypatch): + called = {"post": False} + + class _NoPost(_FakeAsyncClient): + async def post(self, *a, **k): + called["post"] = True + return _FakeResponse(200, b"{}") + + monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _NoPost) + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry(expires_at=time.time() + 3600)) # valid + + await ensure_fresh_token(store, "https://mcp.linear.app/mcp") + assert called["post"] is False # no refresh attempted + + +@pytest.mark.asyncio +async def test_ensure_fresh_token_noop_without_refresh_token(tmp_path, monkeypatch): + called = {"post": False} + + class _NoPost(_FakeAsyncClient): + async def post(self, *a, **k): + called["post"] = True + return _FakeResponse(200, b"{}") + + monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _NoPost) + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put( + "https://mcp.linear.app/mcp", + _entry(token=_token(refresh=None), expires_at=time.time() - 10), # expired, no refresh token + ) + + await ensure_fresh_token(store, "https://mcp.linear.app/mcp") + assert called["post"] is False # cannot refresh -> left for the auth_failed path + + +@pytest.mark.asyncio +async def test_ensure_fresh_token_swallows_failure(tmp_path, monkeypatch): + class _Failing(_FakeAsyncClient): + async def post(self, *a, **k): + raise RuntimeError("network down") + + monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _Failing) + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry(token=_token(access="stale"), expires_at=time.time() - 10)) + + # Must not raise; the stale token is left in place for the connection to try. + await ensure_fresh_token(store, "https://mcp.linear.app/mcp") + assert store.get("https://mcp.linear.app/mcp").token.access_token == "stale" diff --git a/tests/unit/test_redact.py b/tests/unit/test_redact.py index e7cc1206..9c163feb 100644 --- a/tests/unit/test_redact.py +++ b/tests/unit/test_redact.py @@ -12,6 +12,7 @@ _is_uuid_like, redact_absolute_paths, redact_args, + redact_bearer_tokens, redact_data, redact_inspected_path, redact_push_keys, @@ -1265,3 +1266,20 @@ def test_realistic_hooks_diff_with_malformed_uuid(self): in data["modified"]["PreToolUse"]["expected_value"][0]["hooks"][0]["command"] ) assert data["session_id"] == "hooks-setup" + + +class TestRedactBearerTokens: + def test_redacts_authorization_bearer(self): + text = "SENT: GET /mcp\nAuthorization: Bearer eyJhbGc.aBc-1_2+3/==" + out = redact_bearer_tokens(text) + assert "eyJhbGc.aBc-1_2+3/==" not in out + assert "Bearer **REDACTED**" in out + + def test_redacts_lowercase_and_leaves_rest(self): + out = redact_bearer_tokens("prefix bearer TOKEN123 suffix") + assert out == "prefix Bearer **REDACTED** suffix" + + def test_passthrough_when_no_token(self): + assert redact_bearer_tokens("no credentials here") == "no credentials here" + assert redact_bearer_tokens(None) is None + assert redact_bearer_tokens("") == "" From 9f3f0994776f55040158e57ee841a99aeffaac0c Mon Sep 17 00:00:00 2001 From: Aleksey Zhadeev Date: Fri, 7 Aug 2026 13:41:19 -0400 Subject: [PATCH 02/13] scanning individual mcp serers --- README.md | 11 ++ docs/cli-reference.md | 44 +++++++ src/agent_scan/cli.py | 130 ++++++++++++++++--- src/agent_scan/debug_mcp_auth.py | 79 ++++++++++++ src/agent_scan/inspect.py | 10 +- src/agent_scan/mcp_client.py | 42 +++++-- src/agent_scan/pipelines.py | 92 ++++++++++++++ tests/unit/test_cli_parsing.py | 61 +++++++++ tests/unit/test_debug_mcp_auth.py | 42 +++++++ tests/unit/test_mcp_client.py | 63 ++++++++++ tests/unit/test_single_server_scan.py | 175 ++++++++++++++++++++++++++ 11 files changed, 717 insertions(+), 32 deletions(-) create mode 100644 src/agent_scan/debug_mcp_auth.py create mode 100644 tests/unit/test_debug_mcp_auth.py create mode 100644 tests/unit/test_single_server_scan.py diff --git a/README.md b/README.md index c90dea73..4f45066a 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,17 @@ v0.6 and later use the risk-based output and the `2026-07-10` analysis API. Both versions scan MCP servers, tools, prompts, resources, and skills, and automatically discover supported agent configurations such as Claude Code/Desktop, Cursor, Gemini CLI, and Windsurf. +Or scan exactly one MCP server, skipping every other server and all skills: + +```bash +# one configured server, by name +uvx snyk-agent-scan@latest scan --server MY_SERVER +# a remote server by URL, with the transport pinned so nothing is probed +uvx snyk-agent-scan@latest scan --url https://example.com/mcp --server-type http +``` + +See the [CLI reference](docs/cli-reference.md#targeting-a-single-mcp-server-scan-inspect) for the full set of targeting options, including the `npm:` / `pypi:` / `oci:` prefixes for scanning a server straight from a package. + ### Run with a standalone binary Download the binary for your operating system and architecture from the [latest GitHub Release](https://github.com/snyk/agent-scan/releases/latest). The release page also provides an SBOM (`sbom-.json`), checksum files, and GitHub-generated source code archives. See [Verifying Standalone Binaries](#verifying-standalone-binaries) to verify your download. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 52e91a73..a169a798 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -84,6 +84,50 @@ snyk-agent-scan evo [CONFIG_FILE ...] With `--no-skills`, explicit skill paths, skill directories, and automatically discovered skills are all skipped. +### Targeting a single MCP server (`scan`, `inspect`) + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--server NAME` | string | none | Scan only the configured MCP server with this exact name. Discovery still runs, then every other server is dropped. | +| `--url URL` | string | none | Scan a remote MCP server at this URL directly, without reading any config file. Spelled the same as `mcp-auth --url`. | +| `--server-type {http,sse}` | choice | none | Pin the transport of the targeted server. **Disables transport probing** — exactly the given URL and transport are contacted, once. | + +```bash +# one configured server, by name +snyk-agent-scan scan --server MY_SERVER + +# an ad-hoc URL, no config entry needed, no probing +snyk-agent-scan scan --url https://api.snyk.io/mcp-server/mcp --server-type http + +# override a wrong transport recorded in a config file +snyk-agent-scan scan --server MY_SERVER --server-type sse +``` + +**Behavior:** + +- Either flag **skips skills entirely**, the same as passing `--no-skills`. +- `--url` and `--server` combine: `--url` is the target, `--server` is only the display name. This mirrors `mcp-auth`. +- Without `--server-type`, a remote server is probed across six transport/URL combinations (`http` and `sse` × the base URL, `/mcp`, and `/sse`). With it, the URL is used verbatim — no `/mcp` or `/sse` suffix is appended or stripped — and a failure raises the underlying error instead of an `ExceptionGroup`. +- `--server-type` requires `--server` or `--url` (exit code 2), and cannot be applied to a stdio server (exit code 2). +- A `--server NAME` that matches nothing lists the discovered server names and exits 1. +- Credentials are unaffected: stored OAuth tokens are looked up by normalized server URL, so a token obtained via `mcp-auth` resolves regardless of which form you use. + +### Scanning a server by package or URL without a config + +`scan`, `inspect`, and `evo` also accept a **prefixed positional** that describes a server directly, bypassing config discovery: + +| Prefix | Example | Resolves to | +| --- | --- | --- | +| `streamable-https:` | `streamable-https:api.snyk.io/mcp-server/mcp` | remote server at `https://…` | +| `streamable-http:` | `streamable-http:localhost:3000/mcp` | remote server at `http://…` | +| `sse:` | `sse:https://example.com/sse` | remote server, SSE transport | +| `npm:` | `npm:some-mcp-server@1.2.3` | `npx -y some-mcp-server@1.2.3` | +| `pypi:` | `pypi:some-mcp-server@1.2.3` | `uvx some-mcp-server@1.2.3` | +| `oci:` | `oci:ghcr.io/org/image:tag` | `docker run -i --rm …` | +| `nuget:`, `mcpb:` | — | reserved | + +Omit the scheme for `streamable-https:` / `streamable-http:` — the prefix supplies it. These forms leave the transport unset, so probing still applies; use `--url` with `--server-type` when you want a single, exact attempt. + ### Analysis and upload | Flag | Type | Default | Description | diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index 4e445200..c0e853fd 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -37,11 +37,21 @@ InspectArgs, PushArgs, discover_clients_to_inspect, + discover_servers_by_name, + filter_clients_to_server, inspect_analyze_push_pipeline, inspect_pipeline, + single_remote_client_to_inspect, ) from agent_scan.printer import print_inspected_machine, print_scan_response -from agent_scan.utils import ensure_unicode_console, get_hostname, get_push_key, parse_headers, suppress_stdout +from agent_scan.utils import ( + ensure_unicode_console, + get_hostname, + get_push_key, + get_username, + parse_headers, + suppress_stdout, +) from agent_scan.version import version_info # Configure logging to suppress all output by default @@ -662,6 +672,66 @@ def add_ignore_failure_codes_argument(parser) -> None: ) +def add_target_arguments(parser, *, positional: bool, include_type: bool): + """Add the "which server" arguments shared by scan/inspect and mcp-auth. + + ``mcp-auth`` takes the server name as a positional (its historical CLI); + ``scan``/``inspect`` must use a flag because they already own a greedy + ``files`` positional. ``--url`` is spelled identically everywhere so the + two commands share one vocabulary. + """ + if positional: + parser.add_argument("server", nargs="?", help="Name of the MCP server (as configured) to authenticate") + else: + parser.add_argument( + "--server", + help="Scan only the MCP server with this name, as configured (skips every other server and all skills)", + metavar="NAME", + ) + parser.add_argument( + "--url", + help=( + "Target a remote MCP server by URL directly, without reading any config file" + if not positional + else "Authenticate a remote MCP server by URL directly" + ), + ) + if include_type: + parser.add_argument( + "--server-type", + choices=["http", "sse"], + default=None, + help=( + "Pin the transport of the targeted server. Disables transport probing, " + "so exactly the given URL and transport are contacted, once." + ), + ) + + +def print_server_not_found(server_name: str, discovered: dict, *, remote_only: bool = False) -> None: + """Report a --server / mcp-auth name that matched nothing, listing what exists.""" + kind = "remote MCP server" if remote_only else "MCP server" + rich.print(f"[bold red]No {kind} named '{server_name}' found.[/bold red]") + if discovered: + rich.print(f"Discovered {'remote ' if remote_only else ''}servers: {', '.join(sorted(discovered))}") + else: + rich.print(f"No {kind}s were discovered on this machine.") + + +def _target_is_remote(clients_to_inspect: list) -> bool: + """True when every matched entry in the narrowed plan is a remote server.""" + from agent_scan.models import RemoteServer + + for client in clients_to_inspect: + for entries in client.mcp_configs.values(): + if not isinstance(entries, list): + continue + for _name, config in entries: + if not isinstance(config, RemoteServer): + return False + return True + + def setup_scan_parser(scan_parser, add_files=True, add_ci_ignore_options=True, add_show_full_discovery_option=True): if add_files: scan_parser.add_argument( @@ -871,6 +941,7 @@ def main(): ), ) setup_scan_parser(scan_parser) + add_target_arguments(scan_parser, positional=False, include_type=True) # INSPECT command inspect_parser = subparsers.add_parser( @@ -891,6 +962,7 @@ def main(): help="Configuration files to inspect (default: known MCP config locations)", metavar="CONFIG_FILE", ) + add_target_arguments(inspect_parser, positional=False, include_type=True) # HELP command help_parser = subparsers.add_parser( # noqa: F841 @@ -910,8 +982,7 @@ def main(): "mcp-auth", help="Authenticate an OAuth-protected remote MCP server so scans can use it" ) setup_scan_parser(mcp_auth_parser, add_files=False) - mcp_auth_parser.add_argument("server", nargs="?", help="Name of the MCP server (as configured) to authenticate") - mcp_auth_parser.add_argument("--url", help="Authenticate a remote MCP server by URL directly") + add_target_arguments(mcp_auth_parser, positional=True, include_type=False) mcp_auth_parser.add_argument( "--all-unauthenticated", action="store_true", help="Authenticate every discovered remote MCP server" ) @@ -1131,7 +1202,6 @@ async def mcp_auth(args): """ from urllib.parse import urlparse - from agent_scan.models import RemoteServer from agent_scan.oauth_flow import authenticate_server from agent_scan.oauth_store import OAuthTokenStore @@ -1153,23 +1223,13 @@ async def mcp_auth(args): all_users=getattr(args, "scan_all_users", False), scan_skills=False, ) - clients_to_inspect, _, _ = await discover_clients_to_inspect(inspect_args) - remote: dict[str, str] = {} - for client in clients_to_inspect: - for _config_path, entries in client.mcp_configs.items(): - if isinstance(entries, list): - for name, server in entries: - if isinstance(server, RemoteServer): - remote.setdefault(name, server.url) + discovered = await discover_servers_by_name(inspect_args, remote_only=True) + remote: dict[str, str] = {name: server.url for name, server in discovered.items()} if all_unauth: targets = list(remote.items()) elif server_arg: if server_arg not in remote: - rich.print(f"[bold red]No remote MCP server named '{server_arg}' found.[/bold red]") - if remote: - rich.print(f"Discovered remote servers: {', '.join(sorted(remote))}") - else: - rich.print("No remote MCP servers were discovered on this machine.") + print_server_not_found(server_arg, remote, remote_only=True) return targets = [(server_arg, remote[server_arg])] else: @@ -1212,17 +1272,31 @@ async def run_scan(args, mode: Literal["scan", "inspect"] = "scan") -> ScanRespo server_timeout: int = args.server_timeout if hasattr(args, "server_timeout") else 10 files: list[str] | None = args.files if hasattr(args, "files") else None scan_skills: bool = hasattr(args, "skills") and args.skills + tokens: list[TokenAndClientInfo] = [] if hasattr(args, "mcp_oauth_tokens_path") and args.mcp_oauth_tokens_path: with open(args.mcp_oauth_tokens_path) as f: tokens = TokenAndClientInfoList.model_validate_json(f.read()).root + # Single-server targeting. --url addresses a server directly (no discovery); + # --server narrows discovery to one configured entry. Either way skills are + # irrelevant, and --server-type pins the transport so nothing is probed. + target_name: str | None = getattr(args, "server", None) + target_url: str | None = getattr(args, "url", None) + target_type: str | None = getattr(args, "server_type", None) + if target_type and not (target_name or target_url): + rich.print("[bold red]--server-type requires --server or --url .[/bold red]") + sys.exit(2) + if target_name or target_url: + scan_skills = False + inspect_args = InspectArgs( timeout=server_timeout, tokens=tokens, paths=files, all_users=scan_all_users, scan_skills=scan_skills, + probe_transports=not bool(target_type), ) # Resolve the MCP server IO flag and the consent flag. @@ -1235,7 +1309,27 @@ async def run_scan(args, mode: Literal["scan", "inspect"] = "scan") -> ScanRespo dangerously_run_mcp_servers: bool = bool(getattr(args, "dangerously_run_mcp_servers", False)) # Step 1: Discover everything we would inspect without starting any server. - clients_to_inspect, unresolved_paths, scanned_usernames = await discover_clients_to_inspect(inspect_args) + if target_url: + # Addressed directly: skip discovery entirely. + clients_to_inspect = [single_remote_client_to_inspect(target_name, target_url, target_type)] + unresolved_paths = [] + scanned_usernames = [get_username()] + else: + clients_to_inspect, unresolved_paths, scanned_usernames = await discover_clients_to_inspect(inspect_args) + if target_name: + clients_to_inspect = filter_clients_to_server(clients_to_inspect, target_name, target_type) + if not clients_to_inspect: + discovered = await discover_servers_by_name(inspect_args) + print_server_not_found(target_name, discovered) + sys.exit(1) + if target_type and not _target_is_remote(clients_to_inspect): + rich.print( + f"[bold red]'{target_name}' is a stdio server; --server-type only applies to remote servers." + "[/bold red]" + ) + sys.exit(2) + # A named target is one server: nothing else should be reported. + unresolved_paths = [] # Collect consent when applicable; otherwise show the # dangerous-flag banner to users at the terminal. Silent diff --git a/src/agent_scan/debug_mcp_auth.py b/src/agent_scan/debug_mcp_auth.py new file mode 100644 index 00000000..3cb12e42 --- /dev/null +++ b/src/agent_scan/debug_mcp_auth.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +import logging +import sys +from typing import Any + +import rich + +from agent_scan.oauth_flow import AuthResult, authenticate_server +from agent_scan.oauth_store import OAuthTokenStore, StoredServerAuth, normalize_server_url + +logger = logging.getLogger(__name__) + + +async def run_debug_auth( + *, + url: str, + server_name: str, + store: OAuthTokenStore | None = None, + timeout: float = 300.0, + verbose: bool = False, + print_details: bool = False, +) -> AuthResult: + """Run a one-off auth attempt and print structured diagnostics. + + This helper is intentionally small and safe to use while debugging the + interactive OAuth flow. It does not mutate the CLI behavior; it simply + exercises the same authentication path with extra reporting. + """ + effective_store = store or OAuthTokenStore() + normalized_url = normalize_server_url(url) + entry = effective_store.get(normalized_url) + + if verbose: + rich.print(f"[bold]Debug auth for[/bold] {server_name} ({normalized_url})") + + if entry is not None: + if print_details: + rich.print(json.dumps(entry.model_dump(mode="json"), indent=2)) + if verbose: + rich.print("[green]Found existing stored auth entry[/green]") + else: + if verbose: + rich.print("[yellow]No existing auth entry found[/yellow]") + + result = await authenticate_server( + url, + server_name, + effective_store, + timeout=timeout, + ) + + if print_details: + rich.print(json.dumps({"ok": result.ok, "server_url": result.server_url, "message": result.message}, indent=2)) + + if not result.ok and verbose: + rich.print(f"[red]{result.message}[/red]") + + return result + + +async def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if not argv: + rich.print("Usage: debug_mcp_auth.py [server-name]") + return 2 + + url = argv[0] + server_name = argv[1] if len(argv) > 1 else url + store = OAuthTokenStore() + result = await run_debug_auth(url=url, server_name=server_name, store=store, print_details=True, verbose=True) + return 0 if result.ok else 1 + + +if __name__ == "__main__": + import asyncio + + raise SystemExit(asyncio.run(main())) diff --git a/src/agent_scan/inspect.py b/src/agent_scan/inspect.py index 371c9deb..483e90f8 100644 --- a/src/agent_scan/inspect.py +++ b/src/agent_scan/inspect.py @@ -301,6 +301,8 @@ async def _inspect_remote_server( config_path: str, timeout: int, tokens: list[TokenAndClientInfo], + *, + probe_transports: bool = True, ) -> InspectedServer: traffic_capture = TrafficCapture() try: @@ -312,6 +314,7 @@ async def _inspect_remote_server( server_name=name, config_path=config_path, stream_stderr=False, + probe_transports=probe_transports, ) assert isinstance(fixed_config, RemoteServer), f"Fixed config is not a RemoteServer: {fixed_config}" return InspectedServer( @@ -357,6 +360,7 @@ async def _inspect_server( stream_stderr: bool, declined: bool, do_stdio_handshake: bool, + probe_transports: bool = True, ) -> InspectedServer: if declined: error = UserDeclinedError( @@ -380,7 +384,7 @@ async def _inspect_server( tokens, stream_stderr=stream_stderr, ) - return await _inspect_remote_server(name, config, config_path, timeout, tokens) + return await _inspect_remote_server(name, config, config_path, timeout, tokens, probe_transports=probe_transports) async def _inspect_server_configs( @@ -391,6 +395,7 @@ async def _inspect_server_configs( stream_stderr: bool, declined_servers: set[tuple[str, str]], do_stdio_handshake: bool, + probe_transports: bool = True, ) -> tuple[list[InspectedServer], list[ScanError]]: servers: list[InspectedServer] = [] candidate_errors: list[ScanError] = [] @@ -408,6 +413,7 @@ async def _inspect_server_configs( stream_stderr=stream_stderr, declined=(config_path, name) in declined_servers, do_stdio_handshake=do_stdio_handshake, + probe_transports=probe_transports, ) inspected_server.name = _inspection_component_name(name, "server", config_path) servers.append(inspected_server) @@ -434,6 +440,7 @@ async def inspect_client( stream_stderr: bool = False, declined_servers: set[tuple[str, str]] | None = None, do_stdio_handshake: bool = False, + probe_transports: bool = True, ) -> InspectedPath: """Inspect one client and return its normalized inspection result.""" servers, candidate_errors = await _inspect_server_configs( @@ -443,6 +450,7 @@ async def inspect_client( stream_stderr=stream_stderr, declined_servers=declined_servers or set(), do_stdio_handshake=do_stdio_handshake, + probe_transports=probe_transports, ) if scan_skills: diff --git a/src/agent_scan/mcp_client.py b/src/agent_scan/mcp_client.py index 3180dca5..98d67b21 100644 --- a/src/agent_scan/mcp_client.py +++ b/src/agent_scan/mcp_client.py @@ -269,6 +269,7 @@ async def check_server( server_name: str | None = None, config_path: str | None = None, stream_stderr: bool = False, + probe_transports: bool = True, ) -> tuple[ServerSignature, StdioServer | RemoteServer]: logger.debug("Checking server with timeout: %s seconds", timeout) @@ -302,20 +303,28 @@ async def check_server( url_with_mcp = base_url + "/mcp" url_with_sse = base_url + "/sse" - if server_config.type == "http" or server_config.type is None: - strategy.append(("http", url_with_mcp)) - strategy.append(("http", url_without_end)) - strategy.append(("sse", url_with_mcp)) - strategy.append(("sse", url_without_end)) - strategy.append(("http", url_with_sse)) - strategy.append(("sse", url_with_sse)) + # Strict mode: contact exactly the configured transport and URL, once. + # No /mcp or /sse rewriting -- the caller told us what this server is. + if not probe_transports and original_type is not None: + strategy.append((original_type, original_url)) else: - strategy.append(("sse", url_with_mcp)) - strategy.append(("sse", url_without_end)) - strategy.append(("http", url_with_mcp)) - strategy.append(("http", url_without_end)) - strategy.append(("sse", url_with_sse)) - strategy.append(("http", url_with_sse)) + if not probe_transports: + logger.warning("probe_transports=False but no transport type set; falling back to probing") + + if server_config.type == "http" or server_config.type is None: + strategy.append(("http", url_with_mcp)) + strategy.append(("http", url_without_end)) + strategy.append(("sse", url_with_mcp)) + strategy.append(("sse", url_without_end)) + strategy.append(("http", url_with_sse)) + strategy.append(("sse", url_with_sse)) + else: + strategy.append(("sse", url_with_mcp)) + strategy.append(("sse", url_without_end)) + strategy.append(("http", url_with_mcp)) + strategy.append(("http", url_without_end)) + strategy.append(("sse", url_with_sse)) + strategy.append(("http", url_with_sse)) exceptions: list[Exception] = [] for protocol, url in strategy: @@ -352,6 +361,13 @@ async def check_server( server_config.url = original_url server_config.type = original_type + # A single failed attempt -- which only happens when probing is disabled + # and the strategy holds one entry -- needs no grouping. Raise the real + # error so callers see the actual cause rather than the opaque + # "unhandled errors in a TaskGroup (1 sub-exception)". + if len(exceptions) == 1: + raise exceptions[0] + # if python 3.11 or higher, use ExceptionGroup if sys.version_info >= (3, 11): raise ExceptionGroup("Could not connect to remote server", exceptions) # noqa: F821 diff --git a/src/agent_scan/pipelines.py b/src/agent_scan/pipelines.py index 6a697be0..d9386271 100644 --- a/src/agent_scan/pipelines.py +++ b/src/agent_scan/pipelines.py @@ -2,6 +2,8 @@ import logging import os from pathlib import Path +from typing import Literal +from urllib.parse import urlparse from pydantic import BaseModel @@ -17,8 +19,10 @@ ControlServer, DiscoveredSkill, InspectedPath, + RemoteServer, ScanError, ScanResponse, + StdioServer, TokenAndClientInfo, ) from agent_scan.redact import redact_inspected_path @@ -35,6 +39,7 @@ class InspectArgs(BaseModel): paths: list[str] all_users: bool = False scan_skills: bool = False + probe_transports: bool = True class AnalyzeArgs(BaseModel): @@ -166,6 +171,7 @@ async def inspect_pipeline( stream_stderr=stream_stderr, declined_servers=declined_servers, do_stdio_handshake=do_stdio_handshake, + probe_transports=inspect_args.probe_transports, ) ) # redact: applied here so every caller of inspect_pipeline (both `mcp-scan @@ -224,6 +230,92 @@ async def inspect_analyze_push_pipeline( return response +def single_remote_client_to_inspect( + name: str | None, + url: str, + server_type: Literal["sse", "http"] | None = None, +) -> ClientToInspect: + """Build a one-server plan for ``--url``, bypassing discovery entirely. + + Mirrors the direct-scan branch of ``client_to_inspect_from_path`` so the + rest of the pipeline sees exactly the shape it always sees. The fallback + chain for the display name matches ``mcp_auth``: explicit name, else the + URL's hostname, else the raw URL. + """ + server_name = name or urlparse(url).hostname or url + return ClientToInspect( + name="not-available", + client_path=url, + mcp_configs={url: [(server_name, RemoteServer(url=url, type=server_type))]}, + skills_dirs={}, + ) + + +def filter_clients_to_server( + clients: list[ClientToInspect], + server_name: str, + server_type: Literal["sse", "http"] | None = None, +) -> list[ClientToInspect]: + """Narrow a discovered plan down to entries named exactly ``server_name``. + + Clients left holding nothing are dropped. ``skills_dirs`` is emptied + because a single-server scan never wants skills. When ``server_type`` is + given it overrides the configured transport on matched remote servers, + which is what lets ``--server-type`` correct a wrong type in a config. + """ + filtered: list[ClientToInspect] = [] + for client in clients: + kept: dict[str, list[tuple[str, StdioServer | RemoteServer]]] = {} + for config_path, entries in client.mcp_configs.items(): + # Values may be error sentinels rather than lists; skip those. + if not isinstance(entries, list): + continue + matches = [(entry_name, cfg) for entry_name, cfg in entries if entry_name == server_name] + if not matches: + continue + if server_type is not None: + for _entry_name, cfg in matches: + if isinstance(cfg, RemoteServer): + cfg.type = server_type + kept[config_path] = matches + if kept: + filtered.append( + ClientToInspect( + name=client.name, + client_path=client.client_path, + username=client.username, + mcp_configs=kept, + skills_dirs={}, + ) + ) + return filtered + + +async def discover_servers_by_name( + inspect_args: InspectArgs, + *, + remote_only: bool = False, +) -> dict[str, StdioServer | RemoteServer]: + """Map discovered server name -> config, first occurrence winning. + + Extracted from ``mcp_auth`` so that ``scan --server`` and ``mcp-auth`` + agree on what "the server named X" means. ``remote_only`` reproduces + ``mcp_auth``'s behavior of ignoring stdio servers, which it cannot + authenticate; ``scan`` passes False because it can target either. + """ + clients_to_inspect, _, _ = await discover_clients_to_inspect(inspect_args) + servers: dict[str, StdioServer | RemoteServer] = {} + for client in clients_to_inspect: + for _config_path, entries in client.mcp_configs.items(): + if not isinstance(entries, list): + continue + for entry_name, server in entries: + if remote_only and not isinstance(server, RemoteServer): + continue + servers.setdefault(entry_name, server) + return servers + + async def client_to_inspect_from_path( path: str, use_path_as_client_name: bool = False, diff --git a/tests/unit/test_cli_parsing.py b/tests/unit/test_cli_parsing.py index eb1d6659..793bc92a 100644 --- a/tests/unit/test_cli_parsing.py +++ b/tests/unit/test_cli_parsing.py @@ -840,3 +840,64 @@ async def test_skip_ssl_verify_passed_to_pipeline(self): analyze_args = mock_pipeline.call_args[0][1] assert push_args.skip_ssl_verify is True assert analyze_args.skip_ssl_verify is True + + +class TestTargetArgumentParsing: + """add_target_arguments defines the shared --server/--url/--server-type surface. + + Exercised against a bare parser because cli.main() builds its parser inline + and does not expose it for construction in isolation. + """ + + @staticmethod + def _parser(*, positional, include_type): + import argparse + + from agent_scan.cli import add_target_arguments + + parser = argparse.ArgumentParser(prog="test") + add_target_arguments(parser, positional=positional, include_type=include_type) + return parser + + def test_scan_style_uses_flags_and_exposes_server_type(self): + args = self._parser(positional=False, include_type=True).parse_args( + ["--server", "snyk", "--server-type", "http"] + ) + + assert args.server == "snyk" + assert args.server_type == "http" + + def test_url_and_server_may_be_combined(self): + """--url is the target; --server is only the display name, matching mcp-auth.""" + args = self._parser(positional=False, include_type=True).parse_args( + ["--url", "https://a.test/mcp", "--server", "label"] + ) + + assert args.url == "https://a.test/mcp" + assert args.server == "label" + + def test_defaults_are_none_so_ordinary_scans_are_unaffected(self): + args = self._parser(positional=False, include_type=True).parse_args([]) + + assert args.server is None + assert args.url is None + assert args.server_type is None + + def test_unknown_transport_is_rejected(self): + with pytest.raises(SystemExit): + self._parser(positional=False, include_type=True).parse_args(["--server-type", "websocket"]) + + def test_mcp_auth_style_takes_the_server_name_positionally(self): + args = self._parser(positional=True, include_type=False).parse_args(["MY_SERVER", "--url", "https://a.test"]) + + assert args.server == "MY_SERVER" + assert args.url == "https://a.test" + + def test_mcp_auth_style_omits_server_type(self): + with pytest.raises(SystemExit): + self._parser(positional=True, include_type=False).parse_args(["MY_SERVER", "--server-type", "http"]) + + def test_mcp_auth_server_name_stays_optional(self): + args = self._parser(positional=True, include_type=False).parse_args([]) + + assert args.server is None diff --git a/tests/unit/test_debug_mcp_auth.py b/tests/unit/test_debug_mcp_auth.py new file mode 100644 index 00000000..01f8aca3 --- /dev/null +++ b/tests/unit/test_debug_mcp_auth.py @@ -0,0 +1,42 @@ +import pytest +from mcp.shared.auth import OAuthToken + +from agent_scan.debug_mcp_auth import run_debug_auth +from agent_scan.oauth_flow import AuthResult +from agent_scan.oauth_store import OAuthTokenStore, StoredServerAuth + + +@pytest.mark.asyncio +async def test_run_debug_auth_reports_existing_entry(tmp_path, monkeypatch): + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put( + "https://example.com/mcp", + StoredServerAuth( + server_name="example", + client_id="client-1", + client_secret=None, + token_url="https://example.com/token", + mcp_server_url="https://example.com/mcp", + redirect_uris=["http://127.0.0.1:1234/callback"], + updated_at=1.0, + expires_at=2.0, + token=OAuthToken(access_token="abc", token_type="Bearer", expires_in=3600), + ), + ) + + async def fake_authenticate_server(url, server_name, store, **kwargs): + return AuthResult(ok=True, server_url=url, message="ok") + + monkeypatch.setattr("agent_scan.debug_mcp_auth.authenticate_server", fake_authenticate_server) + + result = await run_debug_auth( + url="https://example.com/mcp", + server_name="example", + store=store, + timeout=1.0, + verbose=False, + print_details=False, + ) + + assert result.ok is True + assert result.server_url == "https://example.com/mcp" diff --git a/tests/unit/test_mcp_client.py b/tests/unit/test_mcp_client.py index a90f3e48..53c36485 100644 --- a/tests/unit/test_mcp_client.py +++ b/tests/unit/test_mcp_client.py @@ -240,3 +240,66 @@ def test_resolve_returns_list_for_omitted_args_and_stdio_params_accepts_it(self, assert args == [] assert command == str(script) assert params.args == [] + + +class TestTransportProbing: + """check_server probes six transport/URL combinations unless probing is disabled.""" + + @staticmethod + def _failing_pass(): + return AsyncMock(side_effect=RuntimeError("connect failed")) + + @pytest.mark.asyncio + async def test_probing_tries_six_combinations_and_groups_errors(self): + config = RemoteServer(url="https://example.test/mcp-server/mcp", type="http") + + with patch("agent_scan.mcp_client._check_server_pass", new=self._failing_pass()) as attempt: + with pytest.raises(ExceptionGroup): # noqa: F821 + await check_server(config, 5) + + assert attempt.await_count == 6 + + @pytest.mark.asyncio + async def test_strict_mode_makes_exactly_one_attempt(self): + config = RemoteServer(url="https://example.test/mcp-server/mcp", type="http") + + with patch("agent_scan.mcp_client._check_server_pass", new=self._failing_pass()) as attempt: + with pytest.raises(RuntimeError): + await check_server(config, 5, probe_transports=False) + + assert attempt.await_count == 1 + + @pytest.mark.asyncio + async def test_strict_mode_raises_the_real_error_not_a_group(self): + """A single failure must surface its own exception, not ExceptionGroup(1 sub-exception).""" + config = RemoteServer(url="https://example.test/mcp-server/mcp", type="http") + + with patch("agent_scan.mcp_client._check_server_pass", new=self._failing_pass()): + with pytest.raises(RuntimeError, match="connect failed"): + await check_server(config, 5, probe_transports=False) + + @pytest.mark.asyncio + async def test_strict_mode_contacts_the_exact_url_without_suffix_rewriting(self): + """No /mcp or /sse appending: the caller said what this server is.""" + config = RemoteServer(url="https://example.test/mcp-server/custom", type="sse") + seen: list[tuple[str, str]] = [] + + async def record(server_config, *args, **kwargs): + seen.append((server_config.type, server_config.url)) + raise RuntimeError("connect failed") + + with patch("agent_scan.mcp_client._check_server_pass", new=record): + with pytest.raises(RuntimeError): + await check_server(config, 5, probe_transports=False) + + assert seen == [("sse", "https://example.test/mcp-server/custom")] + + @pytest.mark.asyncio + async def test_strict_mode_without_a_type_falls_back_to_probing(self): + config = RemoteServer(url="https://example.test/mcp-server/mcp") + + with patch("agent_scan.mcp_client._check_server_pass", new=self._failing_pass()) as attempt: + with pytest.raises(ExceptionGroup): # noqa: F821 + await check_server(config, 5, probe_transports=False) + + assert attempt.await_count == 6 diff --git a/tests/unit/test_single_server_scan.py b/tests/unit/test_single_server_scan.py new file mode 100644 index 00000000..b78cbc23 --- /dev/null +++ b/tests/unit/test_single_server_scan.py @@ -0,0 +1,175 @@ +"""Tests for single-server targeting (--server / --url / --server-type).""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from agent_scan.models import ClientToInspect, FileNotFoundConfig, RemoteServer, StdioServer +from agent_scan.pipelines import ( + InspectArgs, + discover_servers_by_name, + filter_clients_to_server, + single_remote_client_to_inspect, +) + + +def _client(name, configs, *, username=None, skills=None): + return ClientToInspect( + name=name, + client_path=f"/home/{name}", + username=username, + mcp_configs=configs, + skills_dirs=skills or {}, + ) + + +class TestSingleRemoteClientToInspect: + def test_builds_one_remote_server_with_pinned_type(self): + client = single_remote_client_to_inspect("snyk", "https://api.snyk.io/mcp-server/mcp", "http") + + entries = client.mcp_configs["https://api.snyk.io/mcp-server/mcp"] + assert len(entries) == 1 + entry_name, config = entries[0] + assert entry_name == "snyk" + assert isinstance(config, RemoteServer) + assert config.url == "https://api.snyk.io/mcp-server/mcp" + assert config.type == "http" + + def test_skills_are_never_included(self): + client = single_remote_client_to_inspect("snyk", "https://example.test/mcp", None) + assert client.skills_dirs == {} + + def test_name_falls_back_to_hostname_then_url(self): + by_host = single_remote_client_to_inspect(None, "https://example.test/mcp", None) + assert by_host.mcp_configs["https://example.test/mcp"][0][0] == "example.test" + + # No hostname to extract -> the raw target is used as the label. + by_url = single_remote_client_to_inspect(None, "not-a-url", None) + assert by_url.mcp_configs["not-a-url"][0][0] == "not-a-url" + + def test_type_may_stay_unset(self): + client = single_remote_client_to_inspect("x", "https://example.test/mcp", None) + assert client.mcp_configs["https://example.test/mcp"][0][1].type is None + + +class TestFilterClientsToServer: + def test_keeps_only_the_named_entry(self): + clients = [ + _client( + "cursor", + { + "/cfg.json": [ + ("wanted", RemoteServer(url="https://a.test/mcp", type="http")), + ("other", RemoteServer(url="https://b.test/mcp", type="http")), + ] + }, + ) + ] + + filtered = filter_clients_to_server(clients, "wanted") + + assert len(filtered) == 1 + entries = filtered[0].mcp_configs["/cfg.json"] + assert [name for name, _ in entries] == ["wanted"] + + def test_drops_clients_with_no_match(self): + clients = [ + _client("cursor", {"/a.json": [("x", RemoteServer(url="https://a.test/mcp"))]}), + _client("vscode", {"/b.json": [("wanted", RemoteServer(url="https://b.test/mcp"))]}), + ] + + filtered = filter_clients_to_server(clients, "wanted") + + assert [c.name for c in filtered] == ["vscode"] + + def test_no_match_returns_empty(self): + clients = [_client("cursor", {"/a.json": [("x", RemoteServer(url="https://a.test/mcp"))]})] + assert filter_clients_to_server(clients, "absent") == [] + + def test_server_type_overrides_configured_transport(self): + clients = [_client("cursor", {"/a.json": [("wanted", RemoteServer(url="https://a.test/mcp", type="http"))]})] + + filtered = filter_clients_to_server(clients, "wanted", "sse") + + assert filtered[0].mcp_configs["/a.json"][0][1].type == "sse" + + def test_skills_are_dropped_and_username_preserved(self): + clients = [ + _client( + "cursor", + {"/a.json": [("wanted", RemoteServer(url="https://a.test/mcp"))]}, + username="az", + skills={"/skills": []}, + ) + ] + + filtered = filter_clients_to_server(clients, "wanted") + + assert filtered[0].skills_dirs == {} + assert filtered[0].username == "az" + + def test_error_sentinel_config_values_are_skipped(self): + # mcp_configs values are not always lists; unparseable files use sentinels. + clients = [ + _client( + "cursor", + { + "/broken.json": FileNotFoundConfig(message="File or folder not found"), + "/ok.json": [("wanted", RemoteServer(url="https://a.test/mcp"))], + }, + ) + ] + + filtered = filter_clients_to_server(clients, "wanted") + + assert list(filtered[0].mcp_configs) == ["/ok.json"] + + def test_matches_stdio_servers_too(self): + clients = [_client("cursor", {"/a.json": [("local", StdioServer(command="npx", args=["-y", "pkg"]))]})] + + filtered = filter_clients_to_server(clients, "local") + + assert isinstance(filtered[0].mcp_configs["/a.json"][0][1], StdioServer) + + +class TestDiscoverServersByName: + @pytest.fixture + def clients(self): + return [ + _client( + "cursor", + { + "/a.json": [ + ("remote", RemoteServer(url="https://a.test/mcp", type="http")), + ("local", StdioServer(command="npx", args=[])), + ] + }, + ), + _client("vscode", {"/b.json": [("remote", RemoteServer(url="https://DIFFERENT.test/mcp"))]}), + ] + + @pytest.mark.asyncio + async def test_remote_only_excludes_stdio(self, clients): + with patch("agent_scan.pipelines.discover_clients_to_inspect", new=AsyncMock(return_value=(clients, [], []))): + found = await discover_servers_by_name(_args(), remote_only=True) + + assert set(found) == {"remote"} + + @pytest.mark.asyncio + async def test_includes_stdio_by_default(self, clients): + with patch("agent_scan.pipelines.discover_clients_to_inspect", new=AsyncMock(return_value=(clients, [], []))): + found = await discover_servers_by_name(_args()) + + assert set(found) == {"remote", "local"} + + @pytest.mark.asyncio + async def test_first_occurrence_wins_on_duplicate_names(self, clients): + # Both clients define "remote"; mcp-auth's historical behavior keeps the first. + with patch("agent_scan.pipelines.discover_clients_to_inspect", new=AsyncMock(return_value=(clients, [], []))): + found = await discover_servers_by_name(_args(), remote_only=True) + + assert found["remote"].url == "https://a.test/mcp" + + +def _args(): + return InspectArgs(timeout=10, tokens=[], paths=[], all_users=False, scan_skills=False) From b3cb7ce757f41f77cc4c2a6dffe17f7cd45da9ee Mon Sep 17 00:00:00 2001 From: Aleksey Zhadeev Date: Mon, 10 Aug 2026 19:41:26 -0400 Subject: [PATCH 03/13] fix(oauth): harden credential handling in the token store - create the token file 0600 before writing, not after (was 0644 during write) - stop the debug helper printing access/refresh tokens and client secrets - do not follow redirects on the token exchange (httpx re-sends the body on 307/308) - require HTTPS or loopback for the token endpoint before sending a refresh token --- .../2026-08-10-oauth-credential-hardening.md | 833 ++++++++++++++++++ src/agent_scan/debug_mcp_auth.py | 7 +- src/agent_scan/oauth_flow.py | 7 +- src/agent_scan/oauth_store.py | 148 +++- tests/unit/test_debug_mcp_auth.py | 64 ++ tests/unit/test_oauth_store.py | 209 ++++- 6 files changed, 1251 insertions(+), 17 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-10-oauth-credential-hardening.md diff --git a/docs/superpowers/plans/2026-08-10-oauth-credential-hardening.md b/docs/superpowers/plans/2026-08-10-oauth-credential-hardening.md new file mode 100644 index 00000000..60bce4a7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-oauth-credential-hardening.md @@ -0,0 +1,833 @@ +# OAuth Credential Handling Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close four concrete defects in the OAuth credential handling added by `feat/oauth-resolution`, without changing the storage architecture. + +**Architecture:** All four fixes are local to two modules — `src/agent_scan/oauth_store.py` (the file-backed token store and the proactive refresh) and `src/agent_scan/debug_mcp_auth.py` (a diagnostics helper). No new dependencies, no change to the on-disk format, no change to any CLI surface. Each fix is independently testable and independently revertable, and each gets its own commit. + +**Tech Stack:** Python 3.10+, `pydantic` v2, `httpx`, `mcp==1.27.0`, `pytest` + `pytest-asyncio`, `ruff` for lint/format. + +## Global Constraints + +- Branch: `feat/oauth-resolution`. Working tree was clean at plan time; do not rebase or merge `main` as part of this work. +- Python floor is **3.10** (`requires-python = ">=3.10"`). No `match` statements, no 3.11-only stdlib. `ipaddress` and `os.fchmod` are both 3.10-safe. +- **No new dependencies.** `pyproject.toml` pins deliberately and carries CVE overrides; adding a package is out of scope for this plan. +- Run tests via the Makefile: `make test `. Bare `pytest` fails — the suite defines a required `--runner` option, which the Makefile supplies (`--runner=uv`). +- Line length is 120 (`[tool.ruff] line-length = 120`). Double quotes. `ruff` lint selects `E,F,I,B,C4,UP,SIM,TCH,W,RUF`. +- POSIX-mode assertions must be skipped on Windows, matching the existing convention in `tests/unit/test_guard.py`: `@pytest.mark.skipif(sys.platform == "win32", reason=...)`. +- Do **not** add a `CHANGELOG.md` entry. That file is version-keyed and written by the release commit (see `0.5.16` at the tail); adding an unreleased line here would conflict with that flow. +- Every new test must be observed **failing for the intended reason** before its fix is written, except where a step explicitly labels a test as a characterization test. + +--- + +## File Structure + +| File | Responsibility | Change | +|---|---|---| +| `src/agent_scan/oauth_store.py` | Token persistence, permissions, proactive refresh, token-endpoint validation | Modify — Tasks 1, 2, 3, 4 | +| `src/agent_scan/debug_mcp_auth.py` | Diagnostics helper for the interactive auth flow | Modify — Task 2 | +| `tests/unit/test_oauth_store.py` | Unit tests for the store and refresh | Modify — Tasks 1, 2, 3, 4 | +| `tests/unit/test_debug_mcp_auth.py` | Unit tests for the diagnostics helper | Modify — Task 2 | + +`oauth_store.py` is ~380 lines and already cohesive (persistence + refresh for one concern). It is not unwieldy and this plan does **not** split it. + +Two shared test helpers in `tests/unit/test_oauth_store.py` are extended rather than duplicated: + +- `_FakeResponse` gains a `headers` argument (Task 3). +- `_FakeAsyncClient` gains `last_init_kwargs` recording (Task 3). + +Task 3 is therefore ordered before Task 4, because Task 4's tests reuse the extended `_FakeResponse`. + +--- + +## Task 1: Create the token file owner-only from the first byte + +**Files:** +- Modify: `src/agent_scan/oauth_store.py:160-168` (`OAuthTokenStore._write_raw`) +- Test: `tests/unit/test_oauth_store.py` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: no new public names. `_write_raw(self, data: dict[str, dict]) -> None` keeps its exact signature; callers `put`, `update_token`, `set_token_url` are unchanged. + +**Why this is a defect.** The current code writes the token document with builtin `open(tmp, "w")`, which creates the file with `0o666 & ~umask` — `0o644` under the common `umask 022`. The access token, refresh token, and client secret are written into that world-readable file, and only *afterwards* is `os.chmod(tmp, 0o600)` applied. Between the write and the chmod, any local user can read every stored credential. The existing test at `tests/unit/test_oauth_store.py:94` asserts the *final* mode and so passes despite the window. + +- [ ] **Step 1: Add the umask-pinning fixture and the two imports it needs** + +At the top of `tests/unit/test_oauth_store.py`, the import block is currently: + +```python +import json +import stat +import time +``` + +Replace it with: + +```python +import json +import os +import stat +import sys +import time +``` + +Then add this fixture immediately after the `_entry` helper (after line 36, before the `test_normalize_server_url` parametrize block): + +```python +@pytest.fixture +def permissive_umask(): + """Pin a permissive umask for the duration of a test. + + Without this, a developer running with ``umask 077`` would see the + permission tests pass even against the unfixed code, because the ambient + umask — not the code — would be what tightened the file. + """ + previous = os.umask(0o022) + try: + yield + finally: + os.umask(previous) +``` + +- [ ] **Step 2: Write the failing test** + +Append to `tests/unit/test_oauth_store.py`: + +```python +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes are not meaningful on Windows") +def test_temp_file_is_owner_only_while_being_written(tmp_path, monkeypatch, permissive_umask): + """The temp file must be 0600 before any token bytes reach it. + + Regression test: creating it with builtin ``open()`` yields ``0o666 & ~umask`` + (0o644 here) and only tightens it after the write, so the fully-written + credential file is world-readable for the length of the write. + """ + path = tmp_path / "store.json" + tmp_file = tmp_path / "store.json.tmp" + observed: dict[str, int] = {} + + real_dump = oauth_store.json.dump + + def spy_dump(obj, fp, **kwargs): + # Sampled at the moment the credentials are being serialized — the exact + # window the unfixed code leaves open. + observed["mode"] = stat.S_IMODE(tmp_file.stat().st_mode) + return real_dump(obj, fp, **kwargs) + + monkeypatch.setattr(oauth_store.json, "dump", spy_dump) + OAuthTokenStore(path=path).put("https://mcp.linear.app/mcp", _entry()) + + assert observed["mode"] == 0o600 +``` + +- [ ] **Step 3: Run the test and confirm it fails for the right reason** + +Run: `make test tests/unit/test_oauth_store.py::test_temp_file_is_owner_only_while_being_written ARGS="-v"` + +Expected: **FAIL** with `assert 420 == 384`. `420` is `0o644` in decimal and `384` is `0o600`. If you instead see it pass, the fixture is not applied — check that `permissive_umask` is in the test signature. + +- [ ] **Step 4: Apply the fix** + +In `src/agent_scan/oauth_store.py`, replace `_write_raw` in full: + +```python + def _write_raw(self, data: dict[str, dict]) -> None: + # Create the directory owner-only from the start; the chmod covers the + # case where it already existed with looser permissions. + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + with contextlib.suppress(OSError): + os.chmod(self.path.parent, 0o700) + tmp = self.path.with_suffix(self.path.suffix + ".tmp") + # Open at 0o600 *before* any token bytes are written. Builtin open() + # would create the file at 0o666 & ~umask (0o644 under the usual + # umask 022) and only tighten it afterwards, leaving a fully-written + # credential file readable by every local user for the length of the + # write. + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + # os.open's mode argument is masked by umask; fchmod is not, so this + # pins 0o600 regardless of the umask the caller runs with. + os.fchmod(f.fileno(), 0o600) + json.dump(data, f, indent=2, default=str) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, self.path) +``` + +Notes for the implementer: + +- `os.fdopen` takes ownership of `fd`, so the `with` block closes it on any exception. Do not add a bare `os.close(fd)` — that would double-close. +- The `f.flush()` / `os.fsync(...)` pair is **durability, not security**: it makes the "atomic write" actually survive a crash rather than leaving a zero-length store. If you want the minimal security-only diff, drop those two lines; nothing else depends on them. +- `mode=0o700` on `mkdir` applies only to the final path component and only when the directory is actually created, which is why the explicit `chmod` stays. + +- [ ] **Step 5: Run the test and confirm it passes** + +Run: `make test tests/unit/test_oauth_store.py::test_temp_file_is_owner_only_while_being_written ARGS="-v"` + +Expected: **PASS** + +- [ ] **Step 6: Add the directory characterization test** + +This one **passes before and after** the fix — `_write_raw` already chmods the parent to `0o700`. It is worth adding anyway: nothing currently pins that behavior, so a future refactor could drop it silently. Label it as such so no one mistakes it for a regression test. + +Append to `tests/unit/test_oauth_store.py`: + +```python +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes are not meaningful on Windows") +def test_store_directory_is_owner_only(tmp_path, permissive_umask): + """Characterization test: the store directory is 0700, including when created. + + Passes before and after the temp-file fix. It exists so that the directory + tightening cannot be removed without a test failing. + """ + store_dir = tmp_path / "nested" / ".mcp-scan" + OAuthTokenStore(path=store_dir / "store.json").put("https://mcp.linear.app/mcp", _entry()) + + assert stat.S_IMODE(store_dir.stat().st_mode) == 0o700 +``` + +- [ ] **Step 7: Run the whole store suite to check nothing regressed** + +Run: `make test tests/unit/test_oauth_store.py ARGS="-v"` + +Expected: **PASS**, including the pre-existing `test_store_roundtrip_and_permissions`, which asserts the final mode is `0o600`. + +- [ ] **Step 8: Commit** + +```bash +git add src/agent_scan/oauth_store.py tests/unit/test_oauth_store.py +git commit -m "fix(oauth): create the token store file 0600 before writing credentials + +Builtin open() created the temp file at 0o666 & ~umask and only chmod'd it +to 0600 after the tokens were written, leaving a world-readable credential +file for the duration of the write. Open with os.open(..., 0o600) and pin +with fchmod instead." +``` + +--- + +## Task 2: Stop the diagnostics helper printing live credentials + +**Files:** +- Modify: `src/agent_scan/oauth_store.py` — add `StoredServerAuth.safe_summary` +- Modify: `src/agent_scan/debug_mcp_auth.py:39-40` +- Test: `tests/unit/test_oauth_store.py`, `tests/unit/test_debug_mcp_auth.py` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces: `StoredServerAuth.safe_summary(self) -> dict[str, object]` — a non-secret dict describing one store entry. Used by `debug_mcp_auth.run_debug_auth`, and available to any future `--list` / logging code. + +**Why this is a defect.** `debug_mcp_auth.py:39-40` prints `json.dumps(entry.model_dump(mode="json"), indent=2)`, which is the *complete* credential set — `access_token`, `refresh_token`, and `client_secret` — to stdout. `main()` at `debug_mcp_auth.py:70` hardcodes `print_details=True`, so running the module dumps every secret for that server to the terminal, where it lands in scrollback, `script`/`tee` logs, and CI output. The module ships inside the wheel (`packages = ["src/agent_scan"]`). + +An alternative is deleting the module outright. This plan keeps it and makes it safe, because it has a test and a plausible support use; if you would rather delete it, delete `src/agent_scan/debug_mcp_auth.py` and `tests/unit/test_debug_mcp_auth.py` and skip to Task 3 — but still add `safe_summary`, since Task 2's Step 2 test and any future diagnostics depend on it. + +- [ ] **Step 1: Write the failing test for `safe_summary`** + +Append to `tests/unit/test_oauth_store.py`: + +```python +def test_safe_summary_omits_secrets(): + entry = _entry(token=_token(access="SECRETACCESS", refresh="SECRETREFRESH")) + entry.client_secret = "SECRETCLIENT" + + summary = entry.safe_summary() + rendered = json.dumps(summary) + + assert "SECRETACCESS" not in rendered + assert "SECRETREFRESH" not in rendered + assert "SECRETCLIENT" not in rendered + # Presence is still reportable without disclosing the values. + assert summary["has_refresh_token"] is True + assert summary["has_client_secret"] is True + # Non-secret identifiers stay useful for diagnostics. + assert summary["client_id"] == "client-123" + assert summary["token_url"] == "https://mcp.linear.app/token" +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `make test tests/unit/test_oauth_store.py::test_safe_summary_omits_secrets ARGS="-v"` + +Expected: **FAIL** with `AttributeError: 'StoredServerAuth' object has no attribute 'safe_summary'` + +- [ ] **Step 3: Implement `safe_summary`** + +In `src/agent_scan/oauth_store.py`, add this method to `StoredServerAuth`, immediately after `is_access_token_expired` (which ends at line 132): + +```python + def safe_summary(self) -> dict[str, object]: + """Non-secret description of this entry, for diagnostics and logs. + + Deliberately omits ``access_token``, ``refresh_token`` and + ``client_secret``. Anything that prints or logs an entry must go through + here — ``model_dump()`` returns the live credentials verbatim. + """ + return { + "server_name": self.server_name, + "mcp_server_url": self.mcp_server_url, + "client_id": self.client_id, + "token_url": self.token_url, + "redirect_uris": self.redirect_uris, + "updated_at": self.updated_at, + "expires_at": self.expires_at, + "has_client_secret": self.client_secret is not None, + "has_refresh_token": self.token.refresh_token is not None, + "access_token_expired": self.is_access_token_expired(), + } +``` + +`client_id` is intentionally included: in OAuth it is a public identifier, not a secret, and it is the field you actually need when debugging a DCR problem. + +- [ ] **Step 4: Run it and confirm it passes** + +Run: `make test tests/unit/test_oauth_store.py::test_safe_summary_omits_secrets ARGS="-v"` + +Expected: **PASS** + +- [ ] **Step 5: Write the failing test for the helper's output** + +Append to `tests/unit/test_debug_mcp_auth.py`: + +```python +@pytest.mark.asyncio +async def test_run_debug_auth_does_not_print_secrets(tmp_path, monkeypatch, capsys): + """print_details must never put live credentials on stdout.""" + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put( + "https://example.com/mcp", + StoredServerAuth( + server_name="example", + client_id="client-1", + client_secret="SECRETCLIENT", + token_url="https://example.com/token", + mcp_server_url="https://example.com/mcp", + redirect_uris=["http://127.0.0.1:1234/callback"], + updated_at=1.0, + expires_at=2.0, + token=OAuthToken( + access_token="SECRETACCESS", + token_type="Bearer", + expires_in=3600, + refresh_token="SECRETREFRESH", + ), + ), + ) + + async def fake_authenticate_server(url, server_name, store, **kwargs): + return AuthResult(ok=True, server_url=url, message="ok") + + monkeypatch.setattr("agent_scan.debug_mcp_auth.authenticate_server", fake_authenticate_server) + + await run_debug_auth( + url="https://example.com/mcp", + server_name="example", + store=store, + timeout=1.0, + verbose=True, + print_details=True, + ) + + out = capsys.readouterr().out + assert "SECRETACCESS" not in out + assert "SECRETREFRESH" not in out + assert "SECRETCLIENT" not in out + # The non-secret summary is still printed, so the helper remains useful. + assert "client-1" in out +``` + +The secret values are deliberately single unbroken words. `rich` soft-wraps at the console width, and a hyphenated or spaced value could be split across lines and defeat a plain substring assertion. + +- [ ] **Step 6: Run it and confirm it fails** + +Run: `make test tests/unit/test_debug_mcp_auth.py::test_run_debug_auth_does_not_print_secrets ARGS="-v"` + +Expected: **FAIL** on `assert "SECRETACCESS" not in out` — the current code dumps the full model. + +- [ ] **Step 7: Apply the fix** + +In `src/agent_scan/debug_mcp_auth.py`, the current block is: + +```python + if entry is not None: + if print_details: + rich.print(json.dumps(entry.model_dump(mode="json"), indent=2)) +``` + +Replace it with: + +```python + if entry is not None: + if print_details: + # safe_summary(), never model_dump(): the latter includes the access + # token, refresh token and client secret, which must not reach stdout. + rich.print(json.dumps(entry.safe_summary(), indent=2)) +``` + +- [ ] **Step 8: Run both test files and confirm they pass** + +Run: `make test tests/unit/test_debug_mcp_auth.py tests/unit/test_oauth_store.py ARGS="-v"` + +Expected: **PASS**, including the pre-existing `test_run_debug_auth_reports_existing_entry`. + +- [ ] **Step 9: Commit** + +```bash +git add src/agent_scan/oauth_store.py src/agent_scan/debug_mcp_auth.py \ + tests/unit/test_oauth_store.py tests/unit/test_debug_mcp_auth.py +git commit -m "fix(oauth): stop the debug helper printing access and refresh tokens + +run_debug_auth printed entry.model_dump(), i.e. the access token, refresh +token and client secret, to stdout — and main() hardcodes print_details=True. +Add StoredServerAuth.safe_summary() and print that instead." +``` + +--- + +## Task 3: Do not follow redirects on the token endpoint + +**Files:** +- Modify: `src/agent_scan/oauth_store.py:365-370` (inside `ensure_fresh_token`) +- Test: `tests/unit/test_oauth_store.py` + +**Interfaces:** +- Consumes: nothing from Tasks 1–2. +- Produces: no new public names. `ensure_fresh_token(store, server_url, *, timeout=30.0) -> None` keeps its signature and its never-raises contract. +- Extends two test helpers that Task 4 reuses: `_FakeResponse.__init__(self, status_code, content, headers=None)` and `_FakeAsyncClient.last_init_kwargs`. + +**Why this is a defect.** The refresh POST at `oauth_store.py:366` uses `httpx.AsyncClient(timeout=timeout, follow_redirects=True)` and sends `refresh_token` plus `client_secret` in the body. `httpx` preserves the method and re-sends the body on `307` and `308`. The target, `entry.token_url`, comes from OAuth discovery metadata captured in `oauth_flow.authenticate_server` — i.e. it is chosen by the remote server. A server that returns `307 Location: https://attacker.example/` therefore receives the long-lived refresh token and the client secret. This is the one item of the four I would call a genuine vulnerability rather than hardening. + +Turning redirects off makes the existing `if resp.status_code != 200` branch handle it, so the fix needs no restructuring — only an explicit branch so the log says something useful. + +- [ ] **Step 1: Extend the two shared test helpers** + +In `tests/unit/test_oauth_store.py`, replace the `_FakeResponse` class: + +```python +class _FakeResponse: + def __init__(self, status_code, content): + self.status_code = status_code + self.content = content +``` + +with: + +```python +class _FakeResponse: + def __init__(self, status_code, content, headers=None): + self.status_code = status_code + self.content = content + self.headers = headers or {} +``` + +Then replace `_FakeAsyncClient.__init__`: + +```python + def __init__(self, *args, **kwargs): + pass +``` + +with: + +```python + def __init__(self, *args, **kwargs): + # Recorded on the base class so subclass instances report here too. + _FakeAsyncClient.last_init_kwargs = kwargs +``` + +and add the class attribute alongside the existing `last_post = None`: + +```python + last_post = None + last_init_kwargs: dict | None = None +``` + +- [ ] **Step 2: Write the two failing tests** + +Append to `tests/unit/test_oauth_store.py`: + +```python +@pytest.mark.asyncio +async def test_refresh_disables_redirect_following(tmp_path, monkeypatch): + """The token exchange must not follow redirects. + + token_url comes from server-controlled discovery metadata, and httpx + re-sends the body on 307/308 — so following a redirect would hand the + refresh token and client secret to a host the server chose. + """ + monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _FakeAsyncClient) + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry(expires_at=time.time() - 10)) + + await ensure_fresh_token(store, "https://mcp.linear.app/mcp") + + assert _FakeAsyncClient.last_init_kwargs["follow_redirects"] is False + + +@pytest.mark.asyncio +async def test_refresh_ignores_a_redirect_response(tmp_path, monkeypatch): + class _Redirecting(_FakeAsyncClient): + async def post(self, url, data=None, headers=None): + _FakeAsyncClient.last_post = {"url": url, "data": data} + return _FakeResponse(307, b"", {"location": "https://attacker.example/token"}) + + monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _Redirecting) + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry(token=_token(access="stale"), expires_at=time.time() - 10)) + + await ensure_fresh_token(store, "https://mcp.linear.app/mcp") + + # The stale token is left for the connection to try, and the only request + # made went to the configured endpoint. + assert store.get("https://mcp.linear.app/mcp").token.access_token == "stale" + assert _FakeAsyncClient.last_post["url"] == "https://mcp.linear.app/token" +``` + +- [ ] **Step 3: Run them and confirm the first fails** + +Run: `make test tests/unit/test_oauth_store.py ARGS="-v -k redirect"` + +Expected: `test_refresh_disables_redirect_following` **FAILS** with `assert True is False`. `test_refresh_ignores_a_redirect_response` already passes — the fake client does not itself follow redirects, so it only pins the fail-closed handling of a `3xx` body. Both are worth keeping. + +- [ ] **Step 4: Apply the fix** + +In `src/agent_scan/oauth_store.py`, inside `ensure_fresh_token`, the current block is: + +```python + try: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + resp = await client.post(entry.token_url, data=data, headers=headers) + if resp.status_code != 200: + logger.info("Refresh for %s failed with status %s; leaving stored token", server_url, resp.status_code) + return + new_token = OAuthToken.model_validate_json(resp.content) +``` + +Replace it with: + +```python + try: + # follow_redirects=False is deliberate and security-relevant: httpx + # preserves the method and re-sends the body on 307/308, and + # entry.token_url comes from server-controlled discovery metadata. + # Following a redirect would deliver the refresh token and client + # secret to a host the remote server picked. + async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: + resp = await client.post(entry.token_url, data=data, headers=headers) + if resp.status_code in (301, 302, 303, 307, 308): + logger.warning( + "Token endpoint for %s returned a %s redirect to %s; not resending the refresh token", + server_url, + resp.status_code, + resp.headers.get("location", ""), + ) + return + if resp.status_code != 200: + logger.info("Refresh for %s failed with status %s; leaving stored token", server_url, resp.status_code) + return + new_token = OAuthToken.model_validate_json(resp.content) +``` + +- [ ] **Step 5: Run the full store suite** + +Run: `make test tests/unit/test_oauth_store.py ARGS="-v"` + +Expected: **PASS**. In particular `test_ensure_fresh_token_refreshes_expired` must still pass — the helper change to `_FakeAsyncClient.__init__` must not have broken it. + +- [ ] **Step 6: Commit** + +```bash +git add src/agent_scan/oauth_store.py tests/unit/test_oauth_store.py +git commit -m "fix(oauth): do not follow redirects when exchanging a refresh token + +The refresh POST ran with follow_redirects=True against a token_url taken +from server-controlled discovery metadata. httpx re-sends the body on +307/308, so a redirect would have delivered the refresh token and client +secret to a host chosen by the remote server. Fail closed on any 3xx." +``` + +--- + +## Task 4: Require HTTPS (or loopback) for the token endpoint + +**Files:** +- Modify: `src/agent_scan/oauth_store.py` — add `ipaddress` import, `_is_loopback_host`, `is_secure_token_url`; guard `set_token_url` and `ensure_fresh_token` +- Test: `tests/unit/test_oauth_store.py` + +**Interfaces:** +- Consumes: `_FakeResponse(status_code, content, headers=None)` and `_FakeAsyncClient` from Task 3. +- Produces: + - `is_secure_token_url(url: str) -> bool` — module-level, public. `True` for `https`, or for `http` on a loopback host. + - `_is_loopback_host(host: str) -> bool` — module-level, private. + - `OAuthTokenStore.set_token_url` keeps its `(self, server_url: str, token_url: str) -> None` signature and stays non-raising; it now silently declines to persist an insecure endpoint, with a warning log. + +**Why this is a defect.** `entry.token_url` is written from discovery metadata (`oauth_flow.py:282-285`) with no scheme check, and `ensure_fresh_token` POSTs the refresh token and client secret to it. A discovery document advertising `http://…` gets the credential sent in cleartext. RFC 6749 §3.2 requires TLS on the token endpoint. + +Enforce at both chokepoints rather than with a pydantic field validator. A strict validator on `StoredServerAuth.token_url` would break `oauth_flow._AuthFlowTokenStorage.set_tokens`, which deliberately constructs the entry with `token_url=""` and lets `authenticate_server` fill it in afterwards (`oauth_flow.py:179`). + +Plain `http` on loopback is allowed: local MCP servers legitimately use it, and the credential never touches a network. `urlparse("")` yields an empty scheme, so `token_url=""` is rejected by the same check — which is the correct fail-closed result. + +- [ ] **Step 1: Write the failing tests** + +First, add `is_secure_token_url` to the existing import block in `tests/unit/test_oauth_store.py`: + +```python +from agent_scan.oauth_store import ( + OAuthTokenStore, + PersistentTokenStorage, + StoredServerAuth, + ensure_fresh_token, + is_secure_token_url, + normalize_server_url, +) +``` + +Then append: + +```python +@pytest.mark.parametrize( + "url,expected", + [ + ("https://mcp.linear.app/token", True), + ("https://auth.atlassian.com/oauth/token", True), + # Loopback http never leaves the host, and local servers use it. + ("http://127.0.0.1:8080/token", True), + ("http://localhost:8080/token", True), + ("http://[::1]:8080/token", True), + # Anything else must be TLS (RFC 6749 s3.2). + ("http://mcp.linear.app/token", False), + ("http://attacker.example/token", False), + # Empty (an entry whose endpoint was never finalized) and malformed. + ("", False), + ("not a url", False), + ], +) +def test_is_secure_token_url(url, expected): + assert is_secure_token_url(url) is expected + + +@pytest.mark.asyncio +async def test_refresh_refuses_a_plaintext_token_endpoint(tmp_path, monkeypatch): + called = {"post": False} + + class _NoPost(_FakeAsyncClient): + async def post(self, *a, **k): + called["post"] = True + return _FakeResponse(200, b"{}") + + monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _NoPost) + store = OAuthTokenStore(path=tmp_path / "store.json") + entry = _entry(expires_at=time.time() - 10) + entry.token_url = "http://mcp.linear.app/token" + store.put("https://mcp.linear.app/mcp", entry) + + await ensure_fresh_token(store, "https://mcp.linear.app/mcp") + + assert called["post"] is False # the refresh token was never sent in cleartext + + +def test_set_token_url_rejects_plaintext(tmp_path): + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry()) + + store.set_token_url("https://mcp.linear.app/mcp", "http://attacker.example/token") + + # The original endpoint is retained; the insecure one is never persisted. + assert store.get("https://mcp.linear.app/mcp").token_url == "https://mcp.linear.app/token" + + +def test_set_token_url_accepts_https(tmp_path): + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry()) + + store.set_token_url("https://mcp.linear.app/mcp", "https://auth.atlassian.com/oauth/token") + + assert store.get("https://mcp.linear.app/mcp").token_url == "https://auth.atlassian.com/oauth/token" +``` + +- [ ] **Step 2: Run them and confirm they fail** + +Run: `make test tests/unit/test_oauth_store.py ARGS="-v -k 'secure_token_url or plaintext or set_token_url'"` + +Expected: collection fails first with `ImportError: cannot import name 'is_secure_token_url'`. That counts as the failing state — it proves the tests are wired to the not-yet-written function. After Step 3 the remaining genuine failure to watch for is `test_refresh_refuses_a_plaintext_token_endpoint`. + +- [ ] **Step 3: Add the validation helpers** + +In `src/agent_scan/oauth_store.py`, add `ipaddress` to the stdlib import block (it sorts between `contextlib` and `json`, per `ruff`'s isort rules): + +```python +import asyncio +import contextlib +import ipaddress +import json +import logging +import os +import time +``` + +Then add both helpers immediately after the `_PLACEHOLDER_REDIRECT_URI` constant (line 55) and before `normalize_server_url`: + +```python +def _is_loopback_host(host: str) -> bool: + """True for ``localhost`` and any address in 127.0.0.0/8 or ::1.""" + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def is_secure_token_url(url: str) -> bool: + """True if a refresh token and client secret may be sent to ``url``. + + RFC 6749 s3.2 requires TLS on the token endpoint. The endpoint we persist is + taken from server-controlled OAuth discovery metadata, so it is validated + before use rather than trusted. Plain ``http`` is accepted only for loopback + hosts: local MCP servers legitimately use it, and the credential never + reaches a network. An empty or unparseable URL is rejected, which is also + what makes an entry whose endpoint was never finalized fail closed. + """ + try: + parsed = urlparse(url) + except ValueError: + return False + if parsed.scheme == "https": + return True + return parsed.scheme == "http" and _is_loopback_host((parsed.hostname or "").lower()) +``` + +- [ ] **Step 4: Guard `set_token_url`** + +In `OAuthTokenStore.set_token_url`, insert the check as the first statement in the body, before `key = normalize_server_url(server_url)`: + +```python + if not is_secure_token_url(token_url): + logger.warning( + "Refusing to store a non-HTTPS token endpoint for %s: %r", server_url, token_url + ) + return +``` + +Also extend that method's docstring with a final paragraph: + +``` + Declines to store an endpoint that is neither HTTPS nor loopback, so a + discovery document cannot arrange for the refresh token to be sent in + cleartext later. The entry keeps whatever endpoint it already had. +``` + +- [ ] **Step 5: Guard `ensure_fresh_token`** + +In `ensure_fresh_token`, the current block is: + +```python + if entry.token.refresh_token is None: + # Nothing to refresh with; let the connection fail to auth_failed. + logger.debug("Stored token for %s expired and has no refresh token", server_url) + return +``` + +Add immediately after it, before `data = {...}`: + +```python + if not is_secure_token_url(entry.token_url): + # Fail closed rather than send the credential in cleartext. The scan + # then tries the stale token and falls to auth_failed, prompting the + # user to re-run mcp-auth. + logger.warning( + "Refusing to refresh %s: stored token endpoint %r is neither HTTPS nor loopback", + server_url, + entry.token_url, + ) + return +``` + +- [ ] **Step 6: Run the store suite and confirm everything passes** + +Run: `make test tests/unit/test_oauth_store.py ARGS="-v"` + +Expected: **PASS**. All the pre-existing tests use `https://mcp.linear.app/token`, so none of them trip the new guard. + +- [ ] **Step 7: Commit** + +```bash +git add src/agent_scan/oauth_store.py tests/unit/test_oauth_store.py +git commit -m "fix(oauth): require HTTPS or loopback for the token endpoint + +token_url is taken from server-controlled discovery metadata and was used +without a scheme check, so a discovery document advertising http:// would +get the refresh token and client secret sent in cleartext. Validate at both +chokepoints: refuse to persist an insecure endpoint, and refuse to refresh +against one." +``` + +--- + +## Task 5: Verification sweep + +**Files:** none modified — this task only runs checks. + +**Interfaces:** +- Consumes: all four fixes from Tasks 1–4. +- Produces: nothing. + +- [ ] **Step 1: Run the full unit suite** + +Run: `make test tests/unit ARGS="-q"` + +Expected: **PASS**, no new failures relative to the branch's pre-change state. If anything unrelated was already failing on `feat/oauth-resolution`, confirm that by stashing and re-running rather than assuming this plan caused it. + +- [ ] **Step 2: Lint and format** + +```bash +uv run ruff check src/agent_scan/oauth_store.py src/agent_scan/debug_mcp_auth.py \ + tests/unit/test_oauth_store.py tests/unit/test_debug_mcp_auth.py +uv run ruff format --check src/agent_scan/oauth_store.py src/agent_scan/debug_mcp_auth.py \ + tests/unit/test_oauth_store.py tests/unit/test_debug_mcp_auth.py +``` + +Expected: both clean. If `ruff format --check` reports a diff, run without `--check` and fold the result into the relevant commit with `git commit --amend`. + +- [ ] **Step 3: Confirm no secret-printing paths remain** + +```bash +grep -rn "model_dump" src/agent_scan/oauth_store.py src/agent_scan/debug_mcp_auth.py +``` + +Expected: only the `model_dump_json()` calls inside `OAuthTokenStore.put`, `update_token`, and `set_token_url` — those write to the `0600` store file, which is correct. No `model_dump` should feed `rich.print`, `print`, or a `logger` call. + +- [ ] **Step 4: Manually confirm the permission fix against a real store** + +```bash +uv run -m src.agent_scan.run mcp-auth --help +ls -la ~/.mcp-scan/ +``` + +Expected: `~/.mcp-scan` is `drwx------`, and `oauth-tokens.json` (if present from earlier use) is `-rw-------`. This is a sanity check on the real path, not a substitute for Step 1. + +- [ ] **Step 5: Review the four commits as a set** + +```bash +git log --oneline origin/main..HEAD | head -10 +git diff origin/main...HEAD -- src/agent_scan/oauth_store.py src/agent_scan/debug_mcp_auth.py +``` + +Confirm each commit is independently revertable and that no unrelated change slipped in. + +--- + +## Deferred, deliberately not in this plan + +- **Purge / TTL / `mcp-auth --forget`** — agreed as the likely next step. Needs its own plan: it adds CLI surface, and a TTL changes `StoredServerAuth` semantics. +- **OS keystore backend** (macOS Keychain, Windows DPAPI, Secret Service) — roadmap item, not remediation. +- **Correcting the MDM premise in the docstrings** at `oauth_store.py:3-5`, `oauth_store.py:52-54`, and `oauth_store.py:78-80`. The deployment model is a security admin's own machine, not unattended MDM, and the comments state otherwise — including a claim that `~/.mcp-scan` is "the same working directory the MDM deployment already runs the scan from", which conflates the home directory with the cwd. Worth a small separate commit so it is not buried in a security fix. +- **`--mcp-oauth-tokens-path` now persists to `~/.mcp-scan/oauth-tokens.json`** (`mcp_client.py:73-75`), which it did not on `main`. Not a defect, but undocumented at `docs/cli-reference.md:117`. +- **`CHANGELOG.md`** — one line describing these fixes belongs in the release commit that bumps the version, not here. + +## Self-review notes + +- Spec coverage: bug 1 → Task 1; bug 2 → Task 2; bug 3 → Task 3; bug 4 → Task 4. All four covered, each with a test that fails first. +- Task order matters in exactly one place: Task 3 extends `_FakeResponse` with `headers`, which Task 4's tests do not use — but Task 4 does reuse `_FakeAsyncClient`, so keep 3 before 4. +- Two tests are labelled as characterization tests (`test_store_directory_is_owner_only`, and `test_refresh_ignores_a_redirect_response`, which passes pre-fix) rather than presented as regression tests. That is intentional and called out at each step. diff --git a/src/agent_scan/debug_mcp_auth.py b/src/agent_scan/debug_mcp_auth.py index 3cb12e42..bb18815e 100644 --- a/src/agent_scan/debug_mcp_auth.py +++ b/src/agent_scan/debug_mcp_auth.py @@ -3,12 +3,11 @@ import json import logging import sys -from typing import Any import rich from agent_scan.oauth_flow import AuthResult, authenticate_server -from agent_scan.oauth_store import OAuthTokenStore, StoredServerAuth, normalize_server_url +from agent_scan.oauth_store import OAuthTokenStore, normalize_server_url logger = logging.getLogger(__name__) @@ -37,7 +36,9 @@ async def run_debug_auth( if entry is not None: if print_details: - rich.print(json.dumps(entry.model_dump(mode="json"), indent=2)) + # safe_summary(), never model_dump(): the latter includes the access + # token, refresh token and client secret, which must not reach stdout. + rich.print(json.dumps(entry.safe_summary(), indent=2)) if verbose: rich.print("[green]Found existing stored auth entry[/green]") else: diff --git a/src/agent_scan/oauth_flow.py b/src/agent_scan/oauth_flow.py index 30f956aa..311db81f 100644 --- a/src/agent_scan/oauth_flow.py +++ b/src/agent_scan/oauth_flow.py @@ -282,7 +282,12 @@ async def authenticate_server( metadata = getattr(provider.context, "oauth_metadata", None) token_endpoint = getattr(metadata, "token_endpoint", None) if metadata else None if token_endpoint: - store.set_token_url(url, str(token_endpoint)) + if not store.set_token_url(url, str(token_endpoint)): + rich.print( + "[yellow]Warning:[/yellow] the server advertised a non-HTTPS token endpoint " + f"({token_endpoint!r}); automatic refresh is disabled for this server and you " + "will need to run mcp-auth again once the token expires." + ) else: logger.warning("Authenticated %s but no token endpoint was discovered", url) return AuthResult(ok=True, server_url=normalize_server_url(url)) diff --git a/src/agent_scan/oauth_store.py b/src/agent_scan/oauth_store.py index 4ca4e0ba..aeed6269 100644 --- a/src/agent_scan/oauth_store.py +++ b/src/agent_scan/oauth_store.py @@ -27,6 +27,7 @@ import asyncio import contextlib +import ipaddress import json import logging import os @@ -55,6 +56,35 @@ _PLACEHOLDER_REDIRECT_URI = "http://127.0.0.1:33418/callback" +def _is_loopback_host(host: str) -> bool: + """True for ``localhost`` and any address in 127.0.0.0/8 or ::1.""" + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def is_secure_token_url(url: str) -> bool: + """True if a refresh token and client secret may be sent to ``url``. + + RFC 6749 s3.2 requires TLS on the token endpoint. The endpoint we persist is + taken from server-controlled OAuth discovery metadata, so it is validated + before use rather than trusted. Plain ``http`` is accepted only for loopback + hosts: local MCP servers legitimately use it, and the credential never + reaches a network. An empty or unparseable URL is rejected, which is also + what makes an entry whose endpoint was never finalized fail closed. + """ + try: + parsed = urlparse(url) + except ValueError: + return False + if parsed.scheme == "https": + return True + return parsed.scheme == "http" and _is_loopback_host((parsed.hostname or "").lower()) + + def normalize_server_url(url: str) -> str: """Reduce a remote MCP server URL to a stable identity key. @@ -123,14 +153,35 @@ def is_access_token_expired(self, *, now: float | None = None) -> bool: """True if the access token is at/near expiry (with a safety skew). Unknown expiry (``expires_at is None``) is treated as *not* expired: we - cannot prove it is stale, so we let the connection try it and fall back - to the ``auth_failed`` path if the server rejects it. + cannot prove it is stale, so we let the connection try it; if the server + rejects it, the connection attempt fails and the scan reports a + connection error for that server. """ if self.expires_at is None: return False current = time.time() if now is None else now return current >= (self.expires_at - _EXPIRY_SKEW_SECONDS) + def safe_summary(self) -> dict[str, object]: + """Non-secret description of this entry, for diagnostics and logs. + + Deliberately omits ``access_token``, ``refresh_token`` and + ``client_secret``. Anything that prints or logs an entry must go through + here — ``model_dump()`` returns the live credentials verbatim. + """ + return { + "server_name": self.server_name, + "mcp_server_url": self.mcp_server_url, + "client_id": self.client_id, + "token_url": self.token_url, + "redirect_uris": self.redirect_uris, + "updated_at": self.updated_at, + "expires_at": self.expires_at, + "has_client_secret": self.client_secret is not None, + "has_refresh_token": self.token.refresh_token is not None, + "access_token_expired": self.is_access_token_expired(), + } + class OAuthTokenStore: """File-backed map of ``normalized server URL -> StoredServerAuth``. @@ -158,13 +209,33 @@ def _read_raw(self) -> dict[str, dict]: return data if isinstance(data, dict) else {} def _write_raw(self, data: dict[str, dict]) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) + # Create the directory owner-only from the start; the chmod covers the + # case where it already existed with looser permissions. + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) with contextlib.suppress(OSError): os.chmod(self.path.parent, 0o700) tmp = self.path.with_suffix(self.path.suffix + ".tmp") - with open(tmp, "w", encoding="utf-8") as f: + # Open at 0o600 *before* any token bytes are written. Builtin open() + # would create the file at 0o666 & ~umask (0o644 under the usual + # umask 022) and only tighten it afterwards, leaving a fully-written + # credential file readable by every local user for the length of the + # write. + # O_NOFOLLOW (POSIX-only, absent on Windows) refuses to open the path if + # it is a symlink, so a symlink planted at the ``.tmp`` path beforehand + # cannot redirect the write to an arbitrary target. getattr(...) makes + # this a no-op flag bit on platforms without it. + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0), 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + # os.open's mode argument is masked by umask; fchmod is not, so this + # pins 0o600 regardless of the umask the caller runs with. Guarded + # because os.fchmod is POSIX-only and absent on Windows before + # Python 3.13 (this repo's floor is 3.10 and CI includes + # windows-latest); POSIX file modes are meaningless there anyway. + if hasattr(os, "fchmod"): + os.fchmod(f.fileno(), 0o600) json.dump(data, f, indent=2, default=str) - os.chmod(tmp, 0o600) + f.flush() + os.fsync(f.fileno()) os.replace(tmp, self.path) def _locked(self): @@ -215,25 +286,38 @@ def update_token(self, server_url: str, token: OAuthToken, *, expires_at: float data[key] = json.loads(entry.model_dump_json()) self._write_raw(data) - def set_token_url(self, server_url: str, token_url: str) -> None: + def set_token_url(self, server_url: str, token_url: str) -> bool: """Record the discovered token endpoint for an entry. The interactive auth command captures the token endpoint from OAuth discovery and stores it here so ``ensure_fresh_token`` refreshes against the correct URL — important for servers (e.g. Atlassian) whose token endpoint is on a different host than ``/token``. + + Declines to store an endpoint that is neither HTTPS nor loopback, so a + discovery document cannot arrange for the refresh token to be sent in + cleartext later. The entry keeps whatever endpoint it already had. + + Returns ``True`` if the endpoint was stored, ``False`` if it was refused + (non-HTTPS/non-loopback) or there was no existing entry to update. Never + raises; callers that need the user to know about a refusal must check + the return value themselves. """ + if not is_secure_token_url(token_url): + logger.warning("Refusing to store a non-HTTPS token endpoint for %s: %r", server_url, token_url) + return False key = normalize_server_url(server_url) with self._locked(): data = self._read_raw() raw = data.get(key) if raw is None: - return + return False entry = StoredServerAuth.model_validate(raw) entry.token_url = token_url entry.updated_at = time.time() data[key] = json.loads(entry.model_dump_json()) self._write_raw(data) + return True class _FileLock: @@ -335,8 +419,19 @@ async def ensure_fresh_token(store: OAuthTokenStore, server_url: str, *, timeout Best-effort and non-fatal: any failure (network, dead refresh token, non-rotating server) is logged and swallowed. The scan then attempts the - stale token; if the server rejects it, the existing ``auth_failed`` path - records that, and the user re-authenticates. Never raises. + stale token; if the server rejects it, the connection attempt fails and the + scan reports a connection error for that server, and the user + re-authenticates. Never raises. + + This function guards only its own proactive refresh: ``follow_redirects`` + and ``is_secure_token_url`` below cover this module's HTTP POST to the + stored token endpoint, not the MCP SDK's own refresh. When that guard + declines, the stale token is instead handed to the SDK's + ``OAuthClientProvider``, which performs its own refresh + (``mcp/client/auth/oauth2.py``) against a ``token_endpoint`` taken from + discovery with no scheme check, through an ``httpx`` client built with + ``follow_redirects=True`` (see ``mcp_client.py`` and ``oauth_flow.py``). + That path is not covered here and is tracked as a follow-up. """ entry = store.get(server_url) if entry is None or not entry.is_access_token_expired(): @@ -350,10 +445,25 @@ async def ensure_fresh_token(store: OAuthTokenStore, server_url: str, *, timeout if entry is None or not entry.is_access_token_expired(): return if entry.token.refresh_token is None: - # Nothing to refresh with; let the connection fail to auth_failed. + # Nothing to refresh with; let the connection attempt fail on its own, + # which the scan reports as a connection error for this server. logger.debug("Stored token for %s expired and has no refresh token", server_url) return + if not is_secure_token_url(entry.token_url): + # Fail closed rather than send the credential in cleartext through + # this module's own refresh POST below. This does not stop the MCP + # SDK's own refresh (see the docstring above) — only this proactive + # path. The scan then tries the stale token; if the server rejects + # it, the connection attempt fails and the scan reports a connection + # error, prompting the user to re-run mcp-auth. + logger.warning( + "Refusing to refresh %s: stored token endpoint %r is neither HTTPS nor loopback", + server_url, + entry.token_url, + ) + return + data = { "grant_type": "refresh_token", "refresh_token": entry.token.refresh_token, @@ -363,8 +473,24 @@ async def ensure_fresh_token(store: OAuthTokenStore, server_url: str, *, timeout data["client_secret"] = entry.client_secret headers = {"Content-Type": "application/x-www-form-urlencoded"} try: - async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + # follow_redirects=False is deliberate and security-relevant for this + # module's own refresh POST: httpx preserves the method and re-sends + # the body on 307/308, and entry.token_url comes from + # server-controlled discovery metadata. Following a redirect would + # deliver the refresh token and client secret to a host the remote + # server picked. This protects only this proactive-refresh path, not + # the MCP SDK's own refresh (see the docstring above); that path is + # not covered here and is tracked as a follow-up. + async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: resp = await client.post(entry.token_url, data=data, headers=headers) + if resp.status_code in (301, 302, 303, 307, 308): + logger.warning( + "Token endpoint for %s returned a %s redirect to %s; not resending the refresh token", + server_url, + resp.status_code, + resp.headers.get("location", ""), + ) + return if resp.status_code != 200: logger.info("Refresh for %s failed with status %s; leaving stored token", server_url, resp.status_code) return diff --git a/tests/unit/test_debug_mcp_auth.py b/tests/unit/test_debug_mcp_auth.py index 01f8aca3..28e334ee 100644 --- a/tests/unit/test_debug_mcp_auth.py +++ b/tests/unit/test_debug_mcp_auth.py @@ -40,3 +40,67 @@ async def fake_authenticate_server(url, server_name, store, **kwargs): assert result.ok is True assert result.server_url == "https://example.com/mcp" + + +@pytest.mark.asyncio +async def test_run_debug_auth_does_not_print_secrets(tmp_path, monkeypatch, capsys): + """print_details must never put live credentials on stdout. + + Two things make this test genuinely able to catch a regression rather than + pass by accident: + + * ``COLUMNS`` is pinned wide before any ``rich.print`` call. ``rich`` + soft-wraps at the console width (80 columns by default when not + attached to a real terminal), which would fold a long secret across + multiple lines and let a plain substring check pass even against the + old leaking ``model_dump()`` code. + * The secrets are realistic-length OAuth tokens (200+ chars), not short + 12-character fixture strings — a short secret is exactly what would fit + on one wrapped line and mask the bug the wide-console fix addresses. + """ + monkeypatch.setenv("COLUMNS", "1000") + secret_access_token = "SECRETACCESS-" + "a1b2c3d4e5f6g7h8i9j0" * 10 # 213 chars + secret_refresh_token = "SECRETREFRESH-" + "k1l2m3n4o5p6q7r8s9t0" * 10 # 214 chars + secret_client_secret = "SECRETCLIENT-" + "u1v2w3x4y5z6a7b8c9d0" * 10 # 213 chars + + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put( + "https://example.com/mcp", + StoredServerAuth( + server_name="example", + client_id="client-1", + client_secret=secret_client_secret, + token_url="https://example.com/token", + mcp_server_url="https://example.com/mcp", + redirect_uris=["http://127.0.0.1:1234/callback"], + updated_at=1.0, + expires_at=2.0, + token=OAuthToken( + access_token=secret_access_token, + token_type="Bearer", + expires_in=3600, + refresh_token=secret_refresh_token, + ), + ), + ) + + async def fake_authenticate_server(url, server_name, store, **kwargs): + return AuthResult(ok=True, server_url=url, message="ok") + + monkeypatch.setattr("agent_scan.debug_mcp_auth.authenticate_server", fake_authenticate_server) + + await run_debug_auth( + url="https://example.com/mcp", + server_name="example", + store=store, + timeout=1.0, + verbose=True, + print_details=True, + ) + + out = capsys.readouterr().out + assert secret_access_token not in out + assert secret_refresh_token not in out + assert secret_client_secret not in out + # The non-secret summary is still printed, so the helper remains useful. + assert "client-1" in out diff --git a/tests/unit/test_oauth_store.py b/tests/unit/test_oauth_store.py index 2a2911f0..b5848e6b 100644 --- a/tests/unit/test_oauth_store.py +++ b/tests/unit/test_oauth_store.py @@ -1,7 +1,9 @@ """Unit tests for the persistent OAuth token store (M1).""" import json +import os import stat +import sys import time import pytest @@ -14,6 +16,7 @@ PersistentTokenStorage, StoredServerAuth, ensure_fresh_token, + is_secure_token_url, normalize_server_url, ) @@ -36,6 +39,21 @@ def _entry(url="https://mcp.linear.app", token=None, expires_at=None): ) +@pytest.fixture +def permissive_umask(): + """Pin a permissive umask for the duration of a test. + + Without this, a developer running with ``umask 077`` would see the + permission tests pass even against the unfixed code, because the ambient + umask — not the code — would be what tightened the file. + """ + previous = os.umask(0o022) + try: + yield + finally: + os.umask(previous) + + @pytest.mark.parametrize( "url,expected", [ @@ -123,18 +141,21 @@ async def test_persistent_storage_roundtrip(tmp_path): class _FakeResponse: - def __init__(self, status_code, content): + def __init__(self, status_code, content, headers=None): self.status_code = status_code self.content = content + self.headers = headers or {} class _FakeAsyncClient: """Minimal stand-in for httpx.AsyncClient capturing the refresh POST.""" last_post = None + last_init_kwargs: dict | None = None def __init__(self, *args, **kwargs): - pass + # Recorded on the base class so subclass instances report here too. + _FakeAsyncClient.last_init_kwargs = kwargs async def __aenter__(self): return self @@ -218,3 +239,187 @@ async def post(self, *a, **k): # Must not raise; the stale token is left in place for the connection to try. await ensure_fresh_token(store, "https://mcp.linear.app/mcp") assert store.get("https://mcp.linear.app/mcp").token.access_token == "stale" + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes are not meaningful on Windows") +def test_temp_file_is_owner_only_while_being_written(tmp_path, monkeypatch, permissive_umask): + """The temp file must be 0600 before any token bytes reach it. + + Regression test: creating it with builtin ``open()`` yields ``0o666 & ~umask`` + (0o644 here) and only tightens it after the write, so the fully-written + credential file is world-readable for the length of the write. + """ + path = tmp_path / "store.json" + tmp_file = tmp_path / "store.json.tmp" + observed: dict[str, int] = {} + + real_dump = oauth_store.json.dump + + def spy_dump(obj, fp, **kwargs): + # Sampled at the moment the credentials are being serialized — the exact + # window the unfixed code leaves open. + observed["mode"] = stat.S_IMODE(tmp_file.stat().st_mode) + return real_dump(obj, fp, **kwargs) + + monkeypatch.setattr(oauth_store.json, "dump", spy_dump) + OAuthTokenStore(path=path).put("https://mcp.linear.app/mcp", _entry()) + + assert observed["mode"] == 0o600 + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes are not meaningful on Windows") +def test_store_directory_is_owner_only(tmp_path, permissive_umask): + """Characterization test: the store directory is 0700, including when created. + + Passes before and after the temp-file fix. It exists so that the directory + tightening cannot be removed without a test failing. + """ + store_dir = tmp_path / "nested" / ".mcp-scan" + OAuthTokenStore(path=store_dir / "store.json").put("https://mcp.linear.app/mcp", _entry()) + + assert stat.S_IMODE(store_dir.stat().st_mode) == 0o700 + + +def test_safe_summary_omits_secrets(): + entry = _entry(token=_token(access="SECRETACCESS", refresh="SECRETREFRESH")) + entry.client_secret = "SECRETCLIENT" + + summary = entry.safe_summary() + rendered = json.dumps(summary) + + assert "SECRETACCESS" not in rendered + assert "SECRETREFRESH" not in rendered + assert "SECRETCLIENT" not in rendered + # Presence is still reportable without disclosing the values. + assert summary["has_refresh_token"] is True + assert summary["has_client_secret"] is True + # Non-secret identifiers stay useful for diagnostics. + assert summary["client_id"] == "client-123" + assert summary["token_url"] == "https://mcp.linear.app/token" + + +@pytest.mark.asyncio +async def test_refresh_disables_redirect_following(tmp_path, monkeypatch): + """The token exchange must not follow redirects. + + token_url comes from server-controlled discovery metadata, and httpx + re-sends the body on 307/308 — so following a redirect would hand the + refresh token and client secret to a host the server chose. + """ + monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _FakeAsyncClient) + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry(expires_at=time.time() - 10)) + + await ensure_fresh_token(store, "https://mcp.linear.app/mcp") + + assert _FakeAsyncClient.last_init_kwargs["follow_redirects"] is False + + +@pytest.mark.asyncio +async def test_refresh_ignores_a_redirect_response(tmp_path, monkeypatch, caplog): + class _Redirecting(_FakeAsyncClient): + async def post(self, url, data=None, headers=None): + _FakeAsyncClient.last_post = {"url": url, "data": data} + return _FakeResponse(307, b"", {"location": "https://attacker.example/token"}) + + monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _Redirecting) + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry(token=_token(access="stale"), expires_at=time.time() - 10)) + + with caplog.at_level("WARNING", logger="agent_scan.oauth_store"): + await ensure_fresh_token(store, "https://mcp.linear.app/mcp") + + # The stale token is left for the connection to try, and the only request + # made went to the configured endpoint. + assert store.get("https://mcp.linear.app/mcp").token.access_token == "stale" + assert _FakeAsyncClient.last_post["url"] == "https://mcp.linear.app/token" + # This branch's only observable behaviour distinct from a plain non-200 is + # its warning log — assert it fires and names the redirect target, so + # deleting the 3xx branch (which would fall through to the generic + # status_code != 200 return, producing identical stored-token state) fails + # this test. + redirect_warnings = [r for r in caplog.records if "redirect" in r.getMessage()] + assert len(redirect_warnings) == 1 + assert "307" in redirect_warnings[0].getMessage() + assert "https://attacker.example/token" in redirect_warnings[0].getMessage() + + +@pytest.mark.parametrize( + "url,expected", + [ + ("https://mcp.linear.app/token", True), + ("https://auth.atlassian.com/oauth/token", True), + # Loopback http never leaves the host, and local servers use it. + ("http://127.0.0.1:8080/token", True), + ("http://localhost:8080/token", True), + ("http://[::1]:8080/token", True), + # Anything else must be TLS (RFC 6749 s3.2). + ("http://mcp.linear.app/token", False), + ("http://attacker.example/token", False), + # Empty (an entry whose endpoint was never finalized) and malformed. + ("", False), + ("not a url", False), + ], +) +def test_is_secure_token_url(url, expected): + assert is_secure_token_url(url) is expected + + +@pytest.mark.asyncio +async def test_refresh_refuses_a_plaintext_token_endpoint(tmp_path, monkeypatch): + called = {"post": False} + + class _NoPost(_FakeAsyncClient): + async def post(self, *a, **k): + called["post"] = True + return _FakeResponse(200, b"{}") + + monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _NoPost) + store = OAuthTokenStore(path=tmp_path / "store.json") + entry = _entry(expires_at=time.time() - 10) + entry.token_url = "http://mcp.linear.app/token" + store.put("https://mcp.linear.app/mcp", entry) + + await ensure_fresh_token(store, "https://mcp.linear.app/mcp") + + assert called["post"] is False # the refresh token was never sent in cleartext + + +def test_set_token_url_rejects_plaintext(tmp_path): + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry()) + + result = store.set_token_url("https://mcp.linear.app/mcp", "http://attacker.example/token") + + # The original endpoint is retained; the insecure one is never persisted. + assert store.get("https://mcp.linear.app/mcp").token_url == "https://mcp.linear.app/token" + # Callers (e.g. authenticate_server) need to know the endpoint was refused + # so they can warn the user that automatic refresh is disabled. + assert result is False + + +def test_set_token_url_accepts_https(tmp_path): + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry()) + + result = store.set_token_url("https://mcp.linear.app/mcp", "https://auth.atlassian.com/oauth/token") + + assert store.get("https://mcp.linear.app/mcp").token_url == "https://auth.atlassian.com/oauth/token" + assert result is True + + +def test_set_token_url_return_value_distinguishes_accepted_from_refused(tmp_path): + """set_token_url's bool return is what lets a caller warn the user. + + Without it, authenticate_server cannot tell a stored endpoint from a + silently-refused one, so mcp-auth would print "authenticated" even though + the entry's token_url stayed unset and can never be refreshed. + """ + store = OAuthTokenStore(path=tmp_path / "store.json") + store.put("https://mcp.linear.app/mcp", _entry()) + + accepted = store.set_token_url("https://mcp.linear.app/mcp", "https://mcp.linear.app/oauth/token") + refused = store.set_token_url("https://mcp.linear.app/mcp", "http://attacker.example/token") + + assert accepted is True + assert refused is False From b4e4d3fbfbaf4803ce3061db8959f8638a92ee1d Mon Sep 17 00:00:00 2001 From: Aleksey Zhadeev Date: Tue, 25 Aug 2026 18:50:47 -0400 Subject: [PATCH 04/13] fix: resolve PR #452 CI failures (mypy, GitGuardian, windows tests) - oauth_flow.py: add missing type: ignore on the monkey-patched HTTPServer.callback_received access - cli.py: cast --server-type to its Literal type and annotate unresolved_paths so mypy resolves the single-server-scan branch - mcp_client.py: ruff-format line reflow - test_redact.py: replace a JWT-shaped fake bearer token fixture (GitGuardian flagged it as a real secret) with a non-JWT-shaped one - test_oauth_store.py: guard the 0600 permission assertion with the same win32 check its sibling tests already use (NTFS has no POSIX mode bits) Co-Authored-By: Claude Sonnet 5 --- src/agent_scan/cli.py | 6 ++++-- src/agent_scan/mcp_client.py | 4 +--- src/agent_scan/oauth_flow.py | 6 +++++- tests/unit/test_oauth_store.py | 5 +++-- tests/unit/test_redact.py | 6 ++++-- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index c0e853fd..aca58b4c 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -1283,7 +1283,9 @@ async def run_scan(args, mode: Literal["scan", "inspect"] = "scan") -> ScanRespo # irrelevant, and --server-type pins the transport so nothing is probed. target_name: str | None = getattr(args, "server", None) target_url: str | None = getattr(args, "url", None) - target_type: str | None = getattr(args, "server_type", None) + # argparse restricts --server-type to choices=["http", "sse"] (see + # add_target_arguments), so the runtime value always matches the literal. + target_type = cast('Literal["sse", "http"] | None', getattr(args, "server_type", None)) if target_type and not (target_name or target_url): rich.print("[bold red]--server-type requires --server or --url .[/bold red]") sys.exit(2) @@ -1312,7 +1314,7 @@ async def run_scan(args, mode: Literal["scan", "inspect"] = "scan") -> ScanRespo if target_url: # Addressed directly: skip discovery entirely. clients_to_inspect = [single_remote_client_to_inspect(target_name, target_url, target_type)] - unresolved_paths = [] + unresolved_paths: list[InspectedPath] = [] scanned_usernames = [get_username()] else: clients_to_inspect, unresolved_paths, scanned_usernames = await discover_clients_to_inspect(inspect_args) diff --git a/src/agent_scan/mcp_client.py b/src/agent_scan/mcp_client.py index 98d67b21..83666221 100644 --- a/src/agent_scan/mcp_client.py +++ b/src/agent_scan/mcp_client.py @@ -52,9 +52,7 @@ async def _handle_callback_unsupported(auth_code: str, state: str | None) -> tup raise NotImplementedError("Interactive OAuth callback is not supported on the scan path") -async def _resolve_scan_oauth_provider( - url: str, token: TokenAndClientInfo | None -) -> OAuthClientProvider | None: +async def _resolve_scan_oauth_provider(url: str, token: TokenAndClientInfo | None) -> OAuthClientProvider | None: """Build a store-backed, non-interactive OAuth provider for the scan path. Looks up (or seeds) the persistent store by normalized URL, proactively diff --git a/src/agent_scan/oauth_flow.py b/src/agent_scan/oauth_flow.py index 311db81f..efda59cb 100644 --- a/src/agent_scan/oauth_flow.py +++ b/src/agent_scan/oauth_flow.py @@ -116,7 +116,11 @@ async def redirect_handler(self, authorization_url: str) -> None: async def callback_handler(self) -> tuple[str, str | None]: loop = asyncio.get_event_loop() - received = await loop.run_in_executor(None, self._server.callback_received.wait, _CALLBACK_TIMEOUT_SECONDS) + received = await loop.run_in_executor( + None, + self._server.callback_received.wait, # type: ignore[attr-defined] + _CALLBACK_TIMEOUT_SECONDS, + ) if not received: raise TimeoutError("Timed out waiting for the OAuth callback") result = self._server.oauth_result or {} # type: ignore[attr-defined] diff --git a/tests/unit/test_oauth_store.py b/tests/unit/test_oauth_store.py index b5848e6b..957d069a 100644 --- a/tests/unit/test_oauth_store.py +++ b/tests/unit/test_oauth_store.py @@ -108,8 +108,9 @@ def test_store_roundtrip_and_permissions(tmp_path): store.put("https://mcp.linear.app/mcp", _entry()) got = store.get("https://mcp.linear.app/mcp") assert got is not None and got.client_id == "client-123" - # File is written 0600. - assert stat.S_IMODE(path.stat().st_mode) == 0o600 + if sys.platform != "win32": + # File is written 0600. POSIX file modes are not meaningful on Windows. + assert stat.S_IMODE(path.stat().st_mode) == 0o600 # It is valid JSON keyed by the normalized URL. data = json.loads(path.read_text()) assert list(data.keys()) == ["https://mcp.linear.app"] diff --git a/tests/unit/test_redact.py b/tests/unit/test_redact.py index 9c163feb..d1ddd1ea 100644 --- a/tests/unit/test_redact.py +++ b/tests/unit/test_redact.py @@ -1270,9 +1270,11 @@ def test_realistic_hooks_diff_with_malformed_uuid(self): class TestRedactBearerTokens: def test_redacts_authorization_bearer(self): - text = "SENT: GET /mcp\nAuthorization: Bearer eyJhbGc.aBc-1_2+3/==" + # Deliberately not JWT-shaped (no "eyJ..." base64 header lookalike) so this + # fixture doesn't itself get flagged as a real bearer token by secret scanners. + text = "SENT: GET /mcp\nAuthorization: Bearer NOT-A-REAL.tok-en_val+ue/==" out = redact_bearer_tokens(text) - assert "eyJhbGc.aBc-1_2+3/==" not in out + assert "NOT-A-REAL.tok-en_val+ue/==" not in out assert "Bearer **REDACTED**" in out def test_redacts_lowercase_and_leaves_rest(self): From ed772147cbec900bacf686563f84b16c7149e249 Mon Sep 17 00:00:00 2001 From: Aleksey Zhadeev Date: Tue, 25 Aug 2026 19:43:34 -0400 Subject: [PATCH 05/13] fix: address code-review findings on PR #452 - oauth_store.py, oauth_flow.py: normalize_server_url and _transport_strategy sliced a transport suffix off the raw URL string, mangling any query string (e.g. ?tenant=acme). _transport_strategy now delegates to normalize_server_url instead of duplicating the logic. - redact.py: bearer-token redaction only matched "Bearer"/"bearer"; auth schemes are case-insensitive per RFC 7235 s2.1, so "BEARER" or "BeArEr" tokens could leak past the redaction boundary. - pipelines.py: filter_clients_to_server kept every entry matching --server NAME across all clients/config files instead of the first occurrence, so a duplicated name could scan multiple distinct servers. Now matches discover_servers_by_name's first-occurrence-wins policy. - cli.py: mcp-auth always exited 0, even on auth failure or an unknown server name, so scripts couldn't detect failure. It now returns/ propagates a real exit status. - oauth_store.py: PersistentTokenStorage handed the SDK's OAuthClientProvider a live refresh token and client secret even when ensure_fresh_token had refused to refresh against an insecure token endpoint, letting the SDK's own unguarded refresh bypass that protection. It now withholds both when the stored endpoint isn't HTTPS/loopback. - oauth_flow.py: added success/failure-path tests for authenticate_server, previously untested. Co-Authored-By: Claude Sonnet 5 --- src/agent_scan/cli.py | 18 ++++--- src/agent_scan/oauth_flow.py | 17 +++--- src/agent_scan/oauth_store.py | 45 +++++++++++----- src/agent_scan/pipelines.py | 54 +++++++++---------- src/agent_scan/redact.py | 2 +- tests/unit/test_mcp_auth_cli.py | 78 +++++++++++++++++++++++++++ tests/unit/test_oauth_flow.py | 78 +++++++++++++++++++++++++++ tests/unit/test_oauth_store.py | 26 +++++++++ tests/unit/test_redact.py | 5 ++ tests/unit/test_single_server_scan.py | 23 ++++++++ 10 files changed, 291 insertions(+), 55 deletions(-) create mode 100644 tests/unit/test_mcp_auth_cli.py diff --git a/src/agent_scan/cli.py b/src/agent_scan/cli.py index aca58b4c..df2300e7 100644 --- a/src/agent_scan/cli.py +++ b/src/agent_scan/cli.py @@ -1115,8 +1115,7 @@ def main(): asyncio.run(evo(args)) sys.exit(0) elif args.command == "mcp-auth": - asyncio.run(mcp_auth(args)) - sys.exit(0) + sys.exit(asyncio.run(mcp_auth(args))) elif args.command == "guard": from agent_scan.guard import run_guard @@ -1193,12 +1192,16 @@ def _should_show_analysis_results(args) -> bool: ) -async def mcp_auth(args): +async def mcp_auth(args) -> int: """Interactively authenticate an OAuth-protected remote MCP server. Runs the browser OAuth flow and persists the token to the local store, so subsequent (unattended) scans use and refresh it. This is the only command that performs an interactive authorization; the scan path never does. + + Returns an exit status: 0 if every requested target authenticated + successfully, 1 otherwise (including invalid/missing target selection), so + scripts can detect failure instead of always seeing a zero exit. """ from urllib.parse import urlparse @@ -1230,16 +1233,17 @@ async def mcp_auth(args): elif server_arg: if server_arg not in remote: print_server_not_found(server_arg, remote, remote_only=True) - return + return 1 targets = [(server_arg, remote[server_arg])] else: rich.print("[bold red]Specify a server name, --url , or --all-unauthenticated.[/bold red]") - return + return 1 if not targets: rich.print("No remote MCP servers to authenticate.") - return + return 1 + all_ok = True for name, url in targets: rich.print(f"\n[bold]Authenticating '{name}'[/bold] ({url}) ...") result = await authenticate_server(url, name, store) @@ -1247,6 +1251,8 @@ async def mcp_auth(args): rich.print(f"[bold green]{name}: authenticated[/bold green]") else: rich.print(f"[bold red]{name}: authentication failed[/bold red] — {result.message}") + all_ok = False + return 0 if all_ok else 1 async def run_scan(args, mode: Literal["scan", "inspect"] = "scan") -> ScanResponse | list[InspectedPath]: diff --git a/src/agent_scan/oauth_flow.py b/src/agent_scan/oauth_flow.py index efda59cb..8566d67d 100644 --- a/src/agent_scan/oauth_flow.py +++ b/src/agent_scan/oauth_flow.py @@ -202,14 +202,15 @@ def _transport_strategy(url: str) -> list[tuple[str, str]]: The OAuth flow triggers on the 401 from whichever transport/URL the server actually answers on, so we try the common shapes until one connects. """ - base = url.rstrip("/") - path = urlparse(base).path - if path.endswith("/sse"): - base = base[: -len("/sse")] - elif path.endswith("/mcp"): - base = base[: -len("/mcp")] - base = base.rstrip("/") - with_mcp, with_sse = base + "/mcp", base + "/sse" + # Reuse the store's suffix-stripping so a query string or fragment (e.g. + # ``?tenant=acme``) is preserved rather than sliced off with the suffix. + base = normalize_server_url(url) + split = urlparse(base) + + def _with_path_suffix(suffix: str) -> str: + return split._replace(path=split.path + suffix).geturl() + + with_mcp, with_sse = _with_path_suffix("/mcp"), _with_path_suffix("/sse") ordered = [ ("http", with_mcp), ("http", base), diff --git a/src/agent_scan/oauth_store.py b/src/agent_scan/oauth_store.py index aeed6269..6df81eff 100644 --- a/src/agent_scan/oauth_store.py +++ b/src/agent_scan/oauth_store.py @@ -34,7 +34,7 @@ import time from pathlib import Path from typing import TYPE_CHECKING -from urllib.parse import urlparse +from urllib.parse import urlparse, urlsplit, urlunsplit import httpx from mcp.client.auth import TokenStorage @@ -88,18 +88,21 @@ def is_secure_token_url(url: str) -> bool: def normalize_server_url(url: str) -> str: """Reduce a remote MCP server URL to a stable identity key. - Mirrors the reduction ``check_server`` applies while probing transports - (``mcp_client.py``): strip a trailing slash, then a trailing ``/mcp`` or - ``/sse`` path segment. This ensures the same server keys to one store entry - whether it is reached via ``.../mcp``, ``.../sse``, or the bare base URL. + Similar in spirit to the reduction ``check_server`` applies while probing + transports (``mcp_client.py``): strip a trailing slash, then a trailing + ``/mcp`` or ``/sse`` path segment. This ensures the same server keys to one + store entry whether it is reached via ``.../mcp``, ``.../sse``, or the bare + base URL. Unlike that helper, the suffix is stripped from the parsed + *path* only, so a query string or fragment (e.g. ``?tenant=acme``) is + preserved rather than sliced off along with the suffix. """ - base = url.rstrip("/") - path = urlparse(base).path + split = urlsplit(url) + path = split.path.rstrip("/") if path.endswith("/sse"): - base = base[: -len("/sse")] + path = path[: -len("/sse")] elif path.endswith("/mcp"): - base = base[: -len("/mcp")] - return base.rstrip("/") + path = path[: -len("/mcp")] + return urlunsplit((split.scheme, split.netloc, path.rstrip("/"), split.query, split.fragment)) def _store_path() -> Path: @@ -360,6 +363,15 @@ class PersistentTokenStorage(TokenStorage): Unlike the read-only ``FileTokenStorage`` it replaces on the scan path, this persists refreshed tokens (``set_tokens``) and registered client info (``set_client_info``) back to disk, so the next process run reuses them. + + When the entry's token endpoint fails ``is_secure_token_url`` -- the same + check ``ensure_fresh_token`` uses before its own guarded refresh -- the + refresh token and client secret are withheld from what is handed to the + SDK's ``OAuthClientProvider``. Without that, the provider still holds a + live refresh token and would perform its *own* unguarded refresh against + that endpoint on a 401, bypassing the HTTPS/loopback and no-redirect + protections entirely. The (possibly stale) access token is still returned, + so the connection attempt itself is unaffected. """ def __init__(self, store: OAuthTokenStore, server_url: str): @@ -368,7 +380,11 @@ def __init__(self, store: OAuthTokenStore, server_url: str): async def get_tokens(self) -> OAuthToken | None: entry = self._store.get(self._server_url) - return entry.token if entry else None + if entry is None: + return None + if entry.token.refresh_token is not None and not is_secure_token_url(entry.token_url): + return entry.token.model_copy(update={"refresh_token": None}) + return entry.token async def set_tokens(self, tokens: OAuthToken) -> None: expires_at: float | None = None @@ -380,11 +396,14 @@ async def get_client_info(self) -> OAuthClientInformationFull | None: entry = self._store.get(self._server_url) if entry is None: return None + client_secret = entry.client_secret + if client_secret is not None and not is_secure_token_url(entry.token_url): + client_secret = None return OAuthClientInformationFull( client_id=entry.client_id, - client_secret=entry.client_secret, + client_secret=client_secret, redirect_uris=entry.redirect_uris or [_PLACEHOLDER_REDIRECT_URI], - token_endpoint_auth_method="client_secret_post" if entry.client_secret else "none", + token_endpoint_auth_method="client_secret_post" if client_secret else "none", ) async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: diff --git a/src/agent_scan/pipelines.py b/src/agent_scan/pipelines.py index d9386271..6875ccdc 100644 --- a/src/agent_scan/pipelines.py +++ b/src/agent_scan/pipelines.py @@ -256,39 +256,39 @@ def filter_clients_to_server( server_name: str, server_type: Literal["sse", "http"] | None = None, ) -> list[ClientToInspect]: - """Narrow a discovered plan down to entries named exactly ``server_name``. - - Clients left holding nothing are dropped. ``skills_dirs`` is emptied - because a single-server scan never wants skills. When ``server_type`` is - given it overrides the configured transport on matched remote servers, - which is what lets ``--server-type`` correct a wrong type in a config. + """Narrow a discovered plan down to the single, first-occurrence entry named ``server_name``. + + First-occurrence-wins, matching ``discover_servers_by_name``'s definition of + "the server named X": the traversal order (clients, then each client's + config paths, then each config's entries) is identical, so both agree on + which entry that name resolves to. This also guarantees exactly one server + is ever targeted -- if the same name is configured in more than one place, + only the first is used, rather than every matching entry across every + client and config file. ``skills_dirs`` is emptied because a single-server + scan never wants skills. When ``server_type`` is given it overrides the + configured transport on the matched remote server, which is what lets + ``--server-type`` correct a wrong type in a config. """ - filtered: list[ClientToInspect] = [] for client in clients: - kept: dict[str, list[tuple[str, StdioServer | RemoteServer]]] = {} for config_path, entries in client.mcp_configs.items(): # Values may be error sentinels rather than lists; skip those. if not isinstance(entries, list): continue - matches = [(entry_name, cfg) for entry_name, cfg in entries if entry_name == server_name] - if not matches: - continue - if server_type is not None: - for _entry_name, cfg in matches: - if isinstance(cfg, RemoteServer): - cfg.type = server_type - kept[config_path] = matches - if kept: - filtered.append( - ClientToInspect( - name=client.name, - client_path=client.client_path, - username=client.username, - mcp_configs=kept, - skills_dirs={}, - ) - ) - return filtered + for entry_name, cfg in entries: + if entry_name != server_name: + continue + if server_type is not None and isinstance(cfg, RemoteServer): + cfg.type = server_type + return [ + ClientToInspect( + name=client.name, + client_path=client.client_path, + username=client.username, + mcp_configs={config_path: [(entry_name, cfg)]}, + skills_dirs={}, + ) + ] + return [] async def discover_servers_by_name( diff --git a/src/agent_scan/redact.py b/src/agent_scan/redact.py index 0fc1c584..457922ab 100644 --- a/src/agent_scan/redact.py +++ b/src/agent_scan/redact.py @@ -171,7 +171,7 @@ def _redaction_marker(plugin_name: str) -> str: return f"**REDACTED_SECRET_{plugin_name.upper()}**" -_BEARER_TOKEN_RE = re.compile(r"[Bb]earer\s+[\w.\-~+/]+=*") +_BEARER_TOKEN_RE = re.compile(r"bearer\s+[\w.\-~+/]+=*", re.IGNORECASE) def redact_bearer_tokens(text: str | None) -> str | None: diff --git a/tests/unit/test_mcp_auth_cli.py b/tests/unit/test_mcp_auth_cli.py new file mode 100644 index 00000000..c64a7a5e --- /dev/null +++ b/tests/unit/test_mcp_auth_cli.py @@ -0,0 +1,78 @@ +"""Tests for the ``mcp-auth`` command's exit status. + +``mcp_auth`` must return a status that reflects whether every requested +target actually authenticated, since ``cli.py`` propagates it via +``sys.exit`` and scripts rely on the exit code to detect failure. +""" + +from argparse import Namespace +from unittest.mock import AsyncMock, patch + +import pytest + +from agent_scan.cli import mcp_auth +from agent_scan.models import RemoteServer +from agent_scan.oauth_flow import AuthResult + + +def _args(**kwargs): + defaults = { + "url": None, + "server": None, + "all_unauthenticated": False, + "server_timeout": 10, + "scan_all_users": False, + } + defaults.update(kwargs) + return Namespace(**defaults) + + +@pytest.mark.asyncio +async def test_returns_zero_when_url_target_succeeds(): + with patch( + "agent_scan.oauth_flow.authenticate_server", + AsyncMock(return_value=AuthResult(ok=True, server_url="https://example.test")), + ): + assert await mcp_auth(_args(url="https://example.test/mcp")) == 0 + + +@pytest.mark.asyncio +async def test_returns_one_when_url_target_fails(): + with patch( + "agent_scan.oauth_flow.authenticate_server", + AsyncMock(return_value=AuthResult(ok=False, server_url="https://example.test", message="boom")), + ): + assert await mcp_auth(_args(url="https://example.test/mcp")) == 1 + + +@pytest.mark.asyncio +async def test_returns_one_for_unknown_server_name(): + with patch("agent_scan.cli.discover_servers_by_name", AsyncMock(return_value={})): + assert await mcp_auth(_args(server="does-not-exist")) == 1 + + +@pytest.mark.asyncio +async def test_returns_one_when_no_target_specified(): + with patch("agent_scan.cli.discover_servers_by_name", AsyncMock(return_value={})): + assert await mcp_auth(_args()) == 1 + + +@pytest.mark.asyncio +async def test_returns_one_when_any_of_several_targets_fails(): + discovered = { + "good": RemoteServer(url="https://good.test/mcp"), + "bad": RemoteServer(url="https://bad.test/mcp"), + } + results = { + "https://good.test/mcp": AuthResult(ok=True, server_url="https://good.test"), + "https://bad.test/mcp": AuthResult(ok=False, server_url="https://bad.test", message="boom"), + } + + async def fake_authenticate_server(url, name, store, **kwargs): + return results[url] + + with ( + patch("agent_scan.cli.discover_servers_by_name", AsyncMock(return_value=discovered)), + patch("agent_scan.oauth_flow.authenticate_server", fake_authenticate_server), + ): + assert await mcp_auth(_args(all_unauthenticated=True)) == 1 diff --git a/tests/unit/test_oauth_flow.py b/tests/unit/test_oauth_flow.py index 52c92cb3..5084241e 100644 --- a/tests/unit/test_oauth_flow.py +++ b/tests/unit/test_oauth_flow.py @@ -1,14 +1,17 @@ """Unit tests for the interactive OAuth flow (M2).""" import urllib.request +from types import SimpleNamespace import pytest from mcp.shared.auth import OAuthClientInformationFull, OAuthToken +from agent_scan import oauth_flow as oauth_flow_module from agent_scan.oauth_flow import ( _AuthFlowTokenStorage, _LoopbackCallbackServer, _transport_strategy, + authenticate_server, ) from agent_scan.oauth_store import OAuthTokenStore @@ -34,6 +37,15 @@ def test_transport_strategy_mcp_url_prefers_http_first(): assert ("sse", "https://mcp.linear.app/sse") in attempts +def test_transport_strategy_preserves_query_string(): + # A query string must survive suffix stripping/rewriting, not be sliced off + # or land after the wrong path segment. + attempts = _transport_strategy("https://host/mcp?tenant=acme") + assert ("http", "https://host/mcp?tenant=acme") in attempts + assert ("http", "https://host?tenant=acme") in attempts + assert ("sse", "https://host/sse?tenant=acme") in attempts + + @pytest.mark.asyncio async def test_loopback_callback_success(): server = _LoopbackCallbackServer(port=0) @@ -88,3 +100,69 @@ async def test_auth_flow_storage_creates_entry(tmp_path): assert entry.token.refresh_token == "refresh-value" assert entry.expires_at is not None assert entry.redirect_uris == ["http://127.0.0.1:5000/callback"] + + +@pytest.mark.asyncio +async def test_authenticate_server_success_persists_token_endpoint(tmp_path, monkeypatch): + store = OAuthTokenStore(path=tmp_path / "store.json") + + async def fake_connect_once(kind, attempt_url, provider, timeout): + # Simulate the SDK completing the auth-code exchange during the real + # connect: it registers the client, stores the token, and discovers + # the token endpoint that authenticate_server later finalizes. + await provider.context.storage.set_client_info( + OAuthClientInformationFull(client_id="cid-1", redirect_uris=["http://127.0.0.1:0/callback"]) + ) + await provider.context.storage.set_tokens(_tok(access="access-value", refresh="refresh-value")) + provider.context.oauth_metadata = SimpleNamespace(token_endpoint="https://example.test/token") + + monkeypatch.setattr(oauth_flow_module, "_connect_once", fake_connect_once) + + result = await authenticate_server("https://example.test/mcp", "example", store, timeout=1.0) + + assert result.ok is True + assert result.server_url == "https://example.test" + entry = store.get("https://example.test/mcp") + assert entry is not None + assert entry.token.access_token == "access-value" + assert entry.token_url == "https://example.test/token" + + +@pytest.mark.asyncio +async def test_authenticate_server_reports_generic_failure_after_exhausting_transports(tmp_path, monkeypatch): + store = OAuthTokenStore(path=tmp_path / "store.json") + + async def fake_connect_once(kind, attempt_url, provider, timeout): + raise ConnectionRefusedError("nobody home") + + monkeypatch.setattr(oauth_flow_module, "_connect_once", fake_connect_once) + + result = await authenticate_server("https://example.test/mcp", "example", store, timeout=1.0) + + assert result.ok is False + assert result.server_url == "https://example.test" + assert "ConnectionRefusedError" in result.message + # Nothing was persisted for a fully failed attempt. + assert store.get("https://example.test/mcp") is None + + +@pytest.mark.asyncio +async def test_authenticate_server_warns_on_insecure_token_endpoint(tmp_path, monkeypatch): + store = OAuthTokenStore(path=tmp_path / "store.json") + + async def fake_connect_once(kind, attempt_url, provider, timeout): + await provider.context.storage.set_client_info( + OAuthClientInformationFull(client_id="cid-1", redirect_uris=["http://127.0.0.1:0/callback"]) + ) + await provider.context.storage.set_tokens(_tok(access="access-value", refresh="refresh-value")) + provider.context.oauth_metadata = SimpleNamespace(token_endpoint="http://not-secure.example/token") + + monkeypatch.setattr(oauth_flow_module, "_connect_once", fake_connect_once) + + result = await authenticate_server("https://example.test/mcp", "example", store, timeout=1.0) + + # The connection itself still succeeded -- only automatic refresh is disabled. + assert result.ok is True + entry = store.get("https://example.test/mcp") + assert entry is not None + assert entry.token_url == "" diff --git a/tests/unit/test_oauth_store.py b/tests/unit/test_oauth_store.py index 957d069a..9e6e65e3 100644 --- a/tests/unit/test_oauth_store.py +++ b/tests/unit/test_oauth_store.py @@ -63,6 +63,8 @@ def permissive_umask(): ("https://mcp.linear.app/", "https://mcp.linear.app"), ("https://mcp.linear.app", "https://mcp.linear.app"), ("https://cf.mcp.atlassian.com/v1/mcp", "https://cf.mcp.atlassian.com/v1"), + # A query string is preserved, not sliced off along with the /mcp suffix. + ("https://host/mcp?tenant=acme", "https://host?tenant=acme"), ], ) def test_normalize_server_url(url, expected): @@ -141,6 +143,30 @@ async def test_persistent_storage_roundtrip(tmp_path): assert store.get("https://mcp.linear.app/mcp").token.access_token == "rotated" +@pytest.mark.asyncio +async def test_persistent_storage_withholds_refresh_credentials_for_insecure_endpoint(tmp_path): + """The SDK provider must not receive a usable refresh token/secret when the + stored token endpoint fails ``is_secure_token_url`` -- otherwise it would + perform its own unguarded refresh against that endpoint on a 401, bypassing + ``ensure_fresh_token``'s HTTPS/loopback and no-redirect protections. + """ + store = OAuthTokenStore(path=tmp_path / "store.json") + entry = _entry(token=_token(access="live", refresh="refresh-token")) + entry.token_url = "http://attacker.example/token" # not HTTPS, not loopback + entry.client_secret = "shh" + store.put("https://mcp.linear.app/mcp", entry) + + storage = PersistentTokenStorage(store, "https://mcp.linear.app/mcp") + tokens = await storage.get_tokens() + client_info = await storage.get_client_info() + + assert tokens is not None + assert tokens.access_token == "live" # the (possibly stale) access token still flows through + assert tokens.refresh_token is None + assert client_info.client_secret is None + assert client_info.token_endpoint_auth_method == "none" + + class _FakeResponse: def __init__(self, status_code, content, headers=None): self.status_code = status_code diff --git a/tests/unit/test_redact.py b/tests/unit/test_redact.py index d1ddd1ea..a610bd8e 100644 --- a/tests/unit/test_redact.py +++ b/tests/unit/test_redact.py @@ -1281,6 +1281,11 @@ def test_redacts_lowercase_and_leaves_rest(self): out = redact_bearer_tokens("prefix bearer TOKEN123 suffix") assert out == "prefix Bearer **REDACTED** suffix" + def test_redacts_other_case_variants(self): + # RFC 7235 s2.1: the auth-scheme token is case-insensitive. + assert redact_bearer_tokens("Authorization: BEARER TOKEN123") == "Authorization: Bearer **REDACTED**" + assert redact_bearer_tokens("Authorization: BeArEr TOKEN123") == "Authorization: Bearer **REDACTED**" + def test_passthrough_when_no_token(self): assert redact_bearer_tokens("no credentials here") == "no credentials here" assert redact_bearer_tokens(None) is None diff --git a/tests/unit/test_single_server_scan.py b/tests/unit/test_single_server_scan.py index b78cbc23..531e1eb0 100644 --- a/tests/unit/test_single_server_scan.py +++ b/tests/unit/test_single_server_scan.py @@ -131,6 +131,29 @@ def test_matches_stdio_servers_too(self): assert isinstance(filtered[0].mcp_configs["/a.json"][0][1], StdioServer) + def test_duplicate_name_across_configs_keeps_only_first_occurrence(self): + # Same name configured in two different places (e.g. a global config + # and a per-project one) must resolve to exactly one server, matching + # discover_servers_by_name's first-occurrence-wins definition of "the + # server named X" -- not every matching entry across every client. + clients = [ + _client( + "cursor", + {"/global.json": [("wanted", RemoteServer(url="https://a.test/mcp"))]}, + ), + _client( + "vscode", + {"/project.json": [("wanted", RemoteServer(url="https://b.test/mcp"))]}, + ), + ] + + filtered = filter_clients_to_server(clients, "wanted") + + assert len(filtered) == 1 + assert filtered[0].name == "cursor" + assert list(filtered[0].mcp_configs) == ["/global.json"] + assert filtered[0].mcp_configs["/global.json"][0][1].url == "https://a.test/mcp" + class TestDiscoverServersByName: @pytest.fixture From 2801ab82611b2ecd90474553385d54cf36a7e7f7 Mon Sep 17 00:00:00 2001 From: Aleksey Zhadeev Date: Wed, 26 Aug 2026 18:16:42 -0400 Subject: [PATCH 06/13] feat: expand ${VAR} placeholders in StdioServer.env from the scanning process's environment --- src/agent_scan/utils.py | 37 +++++++++++++++++++++++++++++ tests/unit/test_utils.py | 50 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/agent_scan/utils.py b/src/agent_scan/utils.py index 30323cc9..8cd0e317 100644 --- a/src/agent_scan/utils.py +++ b/src/agent_scan/utils.py @@ -4,6 +4,7 @@ import logging import os import platform +import re import shutil import subprocess import sys @@ -164,6 +165,42 @@ def resolve_command_and_args(server_config: StdioServer) -> tuple[str, list[str] raise ValueError(f"Command {command} not found") +_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + + +def expand_env_vars(env: dict[str, str] | None) -> dict[str, str] | None: + """Substitute ``${NAME}`` references in stdio-server env values with the + *scanning process's own* environment variables. + + Lets a config commit a placeholder like ``{"AUTH_HEADER": "${AUTH_HEADER}"}`` + instead of a real secret: the real value is resolved only here, at spawn + time, from whichever shell is running the scan, and the result is never + written back onto the parsed config -- callers must treat the return + value as a throwaway dict for immediate use, not something to persist. + + A ``${NAME}`` reference to a variable that isn't set in the scanning + process's environment is left unexpanded (and logged as a warning) + rather than silently substituted with an empty string, so a missing + credential fails loudly -- and visibly, in ``--verbose`` output -- rather + than connecting with a blank header. + """ + if env is None: + return None + + def _substitute(match: re.Match[str]) -> str: + name = match.group(1) + value = os.environ.get(name) + if value is None: + logger.warning( + f"Env var '{name}' referenced in a server config's env block but not set in " + f"the scanning environment; leaving '${{{name}}}' unexpanded" + ) + return match.group(0) + return value + + return {key: _ENV_VAR_PATTERN.sub(_substitute, value) for key, value in env.items()} + + @contextlib.contextmanager def suppress_stdout(): with open(os.devnull, "w") as devnull: diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index d9f29527..f9ed9537 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,4 +1,5 @@ import io +import logging import os import subprocess import sys @@ -10,6 +11,7 @@ from agent_scan.models import CommandParsingError, rebalance_command_args from agent_scan.utils import ( calculate_distance, + expand_env_vars, get_readable_home_directories, get_relative_path, suppress_stdout, @@ -560,3 +562,51 @@ def fake_run(cmd, **_kwargs): usernames = {u for _p, u in result} assert usernames == {"wsl_alice"}, f"WSL homes must still surface when CIM query fails; got {usernames}" + + +class TestExpandEnvVars: + def test_none_input_returns_none(self): + assert expand_env_vars(None) is None + + def test_empty_dict_returns_empty_dict(self): + assert expand_env_vars({}) == {} + + def test_value_without_placeholder_is_unchanged(self): + result = expand_env_vars({"FOO": "plain-value"}) + assert result == {"FOO": "plain-value"} + + def test_substitutes_var_from_environment(self, monkeypatch): + monkeypatch.setenv("AUTH_HEADER", "Bearer secret-token") + result = expand_env_vars({"AUTH_HEADER": "${AUTH_HEADER}"}) + assert result == {"AUTH_HEADER": "Bearer secret-token"} + + def test_substitutes_var_with_different_name_than_key(self, monkeypatch): + """The child env-var key need not match the placeholder name.""" + monkeypatch.setenv("MY_SECRET", "the-real-value") + result = expand_env_vars({"FOO_TOKEN": "${MY_SECRET}"}) + assert result == {"FOO_TOKEN": "the-real-value"} + + def test_substitutes_multiple_placeholders_in_one_value(self, monkeypatch): + monkeypatch.setenv("PREFIX", "Bearer ") + monkeypatch.setenv("SUFFIX", "-token") + result = expand_env_vars({"AUTH": "${PREFIX}abc${SUFFIX}"}) + assert result == {"AUTH": "Bearer abc-token"} + + def test_missing_var_is_left_unexpanded_and_warns(self, monkeypatch, caplog): + monkeypatch.delenv("DOES_NOT_EXIST", raising=False) + with caplog.at_level(logging.WARNING): + result = expand_env_vars({"AUTH_HEADER": "${DOES_NOT_EXIST}"}) + assert result == {"AUTH_HEADER": "${DOES_NOT_EXIST}"} + assert "DOES_NOT_EXIST" in caplog.text + + def test_does_not_mutate_input_dict(self, monkeypatch): + monkeypatch.setenv("AUTH_HEADER", "resolved") + original = {"AUTH_HEADER": "${AUTH_HEADER}"} + result = expand_env_vars(original) + assert original == {"AUTH_HEADER": "${AUTH_HEADER}"} + assert result == {"AUTH_HEADER": "resolved"} + + def test_non_variable_dollar_sign_is_left_alone(self): + """A bare '$' or a name that doesn't look like ${IDENTIFIER} is not touched.""" + result = expand_env_vars({"PRICE": "$5.00 (${) not a var"}) + assert result == {"PRICE": "$5.00 (${) not a var"} From 0f6b12ecd06392fcbcaf150327279a05f6f535d6 Mon Sep 17 00:00:00 2001 From: Aleksey Zhadeev Date: Wed, 26 Aug 2026 18:27:23 -0400 Subject: [PATCH 07/13] fix: update expand_env_vars docstring to plain prose and simplify log message --- src/agent_scan/utils.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/agent_scan/utils.py b/src/agent_scan/utils.py index 8cd0e317..09c3ee97 100644 --- a/src/agent_scan/utils.py +++ b/src/agent_scan/utils.py @@ -169,20 +169,19 @@ def resolve_command_and_args(server_config: StdioServer) -> tuple[str, list[str] def expand_env_vars(env: dict[str, str] | None) -> dict[str, str] | None: - """Substitute ``${NAME}`` references in stdio-server env values with the - *scanning process's own* environment variables. + """Substitute ${NAME} references in stdio-server env values with the + scanning process's own environment variables. - Lets a config commit a placeholder like ``{"AUTH_HEADER": "${AUTH_HEADER}"}`` + Lets a config commit a placeholder like {"AUTH_HEADER": "${AUTH_HEADER}"} instead of a real secret: the real value is resolved only here, at spawn time, from whichever shell is running the scan, and the result is never written back onto the parsed config -- callers must treat the return value as a throwaway dict for immediate use, not something to persist. - A ``${NAME}`` reference to a variable that isn't set in the scanning + A ${NAME} reference to a variable that isn't set in the scanning process's environment is left unexpanded (and logged as a warning) rather than silently substituted with an empty string, so a missing - credential fails loudly -- and visibly, in ``--verbose`` output -- rather - than connecting with a blank header. + credential fails loudly rather than connecting with a blank header. """ if env is None: return None @@ -193,7 +192,7 @@ def _substitute(match: re.Match[str]) -> str: if value is None: logger.warning( f"Env var '{name}' referenced in a server config's env block but not set in " - f"the scanning environment; leaving '${{{name}}}' unexpanded" + f"the scanning environment; leaving '{match.group(0)}' unexpanded" ) return match.group(0) return value From 6299a04379e3a9a8ec48d196c7760e5df446005e Mon Sep 17 00:00:00 2001 From: Aleksey Zhadeev Date: Wed, 26 Aug 2026 18:39:57 -0400 Subject: [PATCH 08/13] feat: expand env var placeholders when spawning stdio MCP servers --- src/agent_scan/mcp_client.py | 4 ++-- tests/unit/test_mcp_client.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/agent_scan/mcp_client.py b/src/agent_scan/mcp_client.py index 83666221..45ee1a0c 100644 --- a/src/agent_scan/mcp_client.py +++ b/src/agent_scan/mcp_client.py @@ -38,7 +38,7 @@ ensure_fresh_token, ) from agent_scan.traffic_capture import PipeStderrCapture, TrafficCapture, capturing_client -from agent_scan.utils import resolve_command_and_args +from agent_scan.utils import expand_env_vars, resolve_command_and_args # Set up logger for this module logger = logging.getLogger(__name__) @@ -148,7 +148,7 @@ async def get_client( server_params = StdioServerParameters( command=command, args=args, - env=server_config.env, + env=expand_env_vars(server_config.env), ) # Create stderr capture with real pipe if traffic capture is enabled. # When streaming is requested, the capture also forwards each line to diff --git a/tests/unit/test_mcp_client.py b/tests/unit/test_mcp_client.py index 53c36485..56f08d8c 100644 --- a/tests/unit/test_mcp_client.py +++ b/tests/unit/test_mcp_client.py @@ -17,7 +17,7 @@ ) from pytest_lazy_fixtures import lf -from agent_scan.mcp_client import _check_server_pass, check_server, scan_mcp_config_file +from agent_scan.mcp_client import _check_server_pass, check_server, get_client, scan_mcp_config_file from agent_scan.models import RemoteServer, StdioServer from agent_scan.utils import resolve_command_and_args @@ -101,6 +101,35 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): assert len(signature.tools) == 3 +@pytest.mark.asyncio +@patch("agent_scan.mcp_client.stdio_client") +async def test_get_client_expands_env_placeholders_for_stdio_server(mock_stdio_client, monkeypatch): + """get_client() must pass the EXPANDED env to StdioServerParameters, not + the literal ${VAR} placeholder from the parsed config.""" + monkeypatch.setenv("AUTH_HEADER", "Bearer real-secret-value") + + mock_read = AsyncMock() + mock_write = AsyncMock() + mock_client = AsyncMock() + mock_client.__aenter__.return_value = (mock_read, mock_write) + mock_stdio_client.return_value = mock_client + + server = StdioServer( + command="npx", + args=["-y", "mcp-remote@0.3.0", "https://example.com/mcp", "--header", "Authorization:${AUTH_HEADER}"], + env={"AUTH_HEADER": "${AUTH_HEADER}"}, + ) + + async with get_client(server, timeout=5): + pass + + assert mock_stdio_client.call_count == 1 + called_params = mock_stdio_client.call_args.args[0] + assert called_params.env == {"AUTH_HEADER": "Bearer real-secret-value"} + # The parsed model itself must be untouched -- still the literal placeholder. + assert server.env == {"AUTH_HEADER": "${AUTH_HEADER}"} + + @pytest.mark.parametrize( "input_url", [ From 03661db0b704c39c84c33ad80b00967acec20c00 Mon Sep 17 00:00:00 2001 From: Aleksey Zhadeev Date: Wed, 26 Aug 2026 18:49:28 -0400 Subject: [PATCH 09/13] docs: document ${VAR} expansion for stdio server env blocks --- docs/scanning.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/scanning.md b/docs/scanning.md index 135582ee..666e15f5 100644 --- a/docs/scanning.md +++ b/docs/scanning.md @@ -65,6 +65,40 @@ Agent applications, skills, tool names, and descriptions are shared with Snyk. R Discovered client information, MCP server configurations and signatures, and skill files are shared with Snyk. Secrets in configuration values and text are redacted before transmission. Results use scored risk indicators; see the [risk reference](risks.md). Operational errors remain separate in the [failure code reference](failure-codes.md). +### Env var placeholders in stdio server configs + +A stdio MCP server's `env` block in a client config (`.claude.json`, `.mcp.json`, +etc.) can reference `${NAME}` to pull a value from whichever environment is +running the scan, instead of hardcoding a secret in the config file: + +```json +{ + "mcpServers": { + "my-server": { + "command": "npx", + "args": ["-y", "mcp-remote@0.3.0", "https://example.com/mcp", "--header", "Authorization:${AUTH_HEADER}"], + "env": { "AUTH_HEADER": "${AUTH_HEADER}" } + } + } +} +``` + +`${AUTH_HEADER}` in the `env` value is substituted from the scanning +process's own environment variable of the same name at the moment the +server is spawned. Note the child process's env-var *key* (the left side, +`"AUTH_HEADER"` here) doesn't have to match the placeholder name on the +right — `"env": {"FOO_TOKEN": "${MY_SECRET}"}` is equally valid. If the +referenced variable isn't set when the scan runs, the placeholder is left +unexpanded and a warning is logged (visible with `--verbose`) rather than +silently connecting with a blank credential. + +This only applies to `env` values. A value inside `args` (as in the +`mcp-remote` example above) is *not* expanded by Agent Scan itself -- +`mcp-remote` and similar wrapper commands already resolve `${VAR}` +references in their own arguments from their own process environment, so +once the variable is present in the spawned process's environment (via the +`env` block), that resolution happens downstream, in the wrapper. + ## CLI Usage The command structure and most options are shared by both versions: From 6058c4d4b79fd2b4b8df9caa4094b2659c7f14ee Mon Sep 17 00:00:00 2001 From: Aleksey Zhadeev Date: Wed, 26 Aug 2026 19:31:08 -0400 Subject: [PATCH 10/13] fix: address final-review findings on the env-var expansion feature - utils.py: a set-but-empty env var (e.g. AUTH_HEADER=) now counts as unset too, so it warns and leaves the placeholder unexpanded instead of silently substituting a blank credential. - utils.py: restore the docstring's --verbose qualifier -- default logging suppresses the warning, so "fails loudly" overstated it. - consent.py: the interactive consent prompt now shows a whole-value ${NAME} placeholder literally instead of masking it as ***, since the placeholder itself isn't a secret -- it discloses which of the user's own env vars a server is about to read before they approve it. - docs/scanning.md: promote the new section out of the version-specific data-sharing list, document that a scanned config's env block can now pull arbitrary variables from the scanning user's shell (not just values written on disk), and note there's no escape syntax for a literal ${NAME}. - test_mcp_client.py: assert args stay unexpanded (only env is), the one previously-unpinned scope boundary from the plan. - test_consent.py: new, covers _render_env_redacted's masked vs. placeholder-literal branches (this module had no tests before). --- docs/scanning.md | 21 +++++++++++++++++++-- src/agent_scan/consent.py | 15 +++++++++++++-- src/agent_scan/utils.py | 12 ++++++------ tests/unit/test_consent.py | 32 ++++++++++++++++++++++++++++++++ tests/unit/test_mcp_client.py | 1 + tests/unit/test_utils.py | 7 +++++++ 6 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_consent.py diff --git a/docs/scanning.md b/docs/scanning.md index 666e15f5..4ec828a9 100644 --- a/docs/scanning.md +++ b/docs/scanning.md @@ -65,7 +65,7 @@ Agent applications, skills, tool names, and descriptions are shared with Snyk. R Discovered client information, MCP server configurations and signatures, and skill files are shared with Snyk. Secrets in configuration values and text are redacted before transmission. Results use scored risk indicators; see the [risk reference](risks.md). Operational errors remain separate in the [failure code reference](failure-codes.md). -### Env var placeholders in stdio server configs +## Env var placeholders in stdio server configs A stdio MCP server's `env` block in a client config (`.claude.json`, `.mcp.json`, etc.) can reference `${NAME}` to pull a value from whichever environment is @@ -90,7 +90,11 @@ server is spawned. Note the child process's env-var *key* (the left side, right — `"env": {"FOO_TOKEN": "${MY_SECRET}"}` is equally valid. If the referenced variable isn't set when the scan runs, the placeholder is left unexpanded and a warning is logged (visible with `--verbose`) rather than -silently connecting with a blank credential. +silently connecting with a blank credential. There is no escape syntax for +a literal `${NAME}` today: if an `env` value happens to legitimately +contain well-formed `${NAME}` text that isn't meant as a placeholder, it +will still be substituted whenever `NAME` happens to be set in the +scanning environment. This only applies to `env` values. A value inside `args` (as in the `mcp-remote` example above) is *not* expanded by Agent Scan itself -- @@ -99,6 +103,19 @@ references in their own arguments from their own process environment, so once the variable is present in the spawned process's environment (via the `env` block), that resolution happens downstream, in the wrapper. +Because a config's `env` block can reference `${NAME}` for any variable +name, a discovered (and possibly untrusted or tampered) config can use +this feature to pull *any* variable out of the scanning user's shell -- +for example `"env": {"X": "${SNYK_TOKEN}"}`, or a reference to +`AWS_SECRET_ACCESS_KEY`, `GITHUB_TOKEN`, or similar. This is a natural +consequence of the feature rather than itself a bug: starting a stdio MCP +server already requires explicit consent (or `--dangerously-run-mcp-servers`), +and spawning a server is arbitrary code execution regardless of whether its +`env` block references any placeholders. To make this visible, the +interactive consent prompt (shown unless `--dangerously-run-mcp-servers` is +passed) displays which environment variable names a server's `env` block +will pull from your shell, so you can review them before the server starts. + ## CLI Usage The command structure and most options are shared by both versions: diff --git a/src/agent_scan/consent.py b/src/agent_scan/consent.py index 672e1503..12be6f1b 100644 --- a/src/agent_scan/consent.py +++ b/src/agent_scan/consent.py @@ -18,6 +18,7 @@ StdioServer, UnknownConfigFormat, ) +from agent_scan.utils import _ENV_VAR_PATTERN # The consent UI is diagnostic chrome, not scan output, so it is rendered on stderr. _stderr_console = Console(stderr=True) @@ -29,10 +30,20 @@ def _render_command(server: StdioServer) -> str: def _render_env_redacted(server: StdioServer) -> str | None: - """Render env as ``KEY=***``. Values are never echoed back to the terminal.""" + """Render env as ``KEY=***``, except a value that is exactly one ``${NAME}`` + placeholder, which is rendered literally -- the placeholder itself is a + variable reference, not a secret, so showing it discloses which of the + user's own environment variables the server is about to read. + """ if not server.env: return None - return ", ".join(f"{k}=***" for k in sorted(server.env.keys())) + parts = [] + for k, v in sorted(server.env.items()): + if _ENV_VAR_PATTERN.fullmatch(v): + parts.append(f"{k}={v}") + else: + parts.append(f"{k}=***") + return ", ".join(parts) def _read_yes_no(prompt: str) -> bool: diff --git a/src/agent_scan/utils.py b/src/agent_scan/utils.py index 09c3ee97..f82f4a68 100644 --- a/src/agent_scan/utils.py +++ b/src/agent_scan/utils.py @@ -178,10 +178,10 @@ def expand_env_vars(env: dict[str, str] | None) -> dict[str, str] | None: written back onto the parsed config -- callers must treat the return value as a throwaway dict for immediate use, not something to persist. - A ${NAME} reference to a variable that isn't set in the scanning - process's environment is left unexpanded (and logged as a warning) - rather than silently substituted with an empty string, so a missing - credential fails loudly rather than connecting with a blank header. + A ${NAME} reference to a variable that isn't set (or is empty) in the + scanning process's environment is left unexpanded and logged as a + warning (visible with --verbose), rather than silently connecting with + a blank credential. """ if env is None: return None @@ -189,9 +189,9 @@ def expand_env_vars(env: dict[str, str] | None) -> dict[str, str] | None: def _substitute(match: re.Match[str]) -> str: name = match.group(1) value = os.environ.get(name) - if value is None: + if not value: logger.warning( - f"Env var '{name}' referenced in a server config's env block but not set in " + f"Env var '{name}' referenced in a server config's env block but not set (or empty) in " f"the scanning environment; leaving '{match.group(0)}' unexpanded" ) return match.group(0) diff --git a/tests/unit/test_consent.py b/tests/unit/test_consent.py new file mode 100644 index 00000000..e4ef0f93 --- /dev/null +++ b/tests/unit/test_consent.py @@ -0,0 +1,32 @@ +"""Unit tests for agent_scan.consent.""" + +from agent_scan.consent import _render_env_redacted +from agent_scan.models import StdioServer + + +class TestRenderEnvRedacted: + def test_none_env_returns_none(self): + server = StdioServer(command="npx", args=[]) + assert _render_env_redacted(server) is None + + def test_placeholder_value_is_shown_literally(self): + server = StdioServer(command="npx", args=[], env={"AUTH_HEADER": "${AUTH_HEADER}"}) + assert _render_env_redacted(server) == "AUTH_HEADER=${AUTH_HEADER}" + + def test_literal_value_is_masked(self): + server = StdioServer(command="npx", args=[], env={"API_KEY": "sk-real-secret-value"}) + assert _render_env_redacted(server) == "API_KEY=***" + + def test_mixed_placeholder_and_extra_text_is_masked(self): + """A value that isn't ENTIRELY one placeholder may still mix in a real + secret, so it stays masked -- only a whole-value placeholder is shown.""" + server = StdioServer(command="npx", args=[], env={"AUTH": "Bearer ${TOKEN}"}) + assert _render_env_redacted(server) == "AUTH=***" + + def test_multiple_keys_sorted_and_mixed(self): + server = StdioServer( + command="npx", + args=[], + env={"ZKEY": "${ZVAR}", "AKEY": "hardcoded-secret"}, + ) + assert _render_env_redacted(server) == "AKEY=***, ZKEY=${ZVAR}" diff --git a/tests/unit/test_mcp_client.py b/tests/unit/test_mcp_client.py index 56f08d8c..6f326ae4 100644 --- a/tests/unit/test_mcp_client.py +++ b/tests/unit/test_mcp_client.py @@ -126,6 +126,7 @@ async def test_get_client_expands_env_placeholders_for_stdio_server(mock_stdio_c assert mock_stdio_client.call_count == 1 called_params = mock_stdio_client.call_args.args[0] assert called_params.env == {"AUTH_HEADER": "Bearer real-secret-value"} + assert called_params.args == server.args # The parsed model itself must be untouched -- still the literal placeholder. assert server.env == {"AUTH_HEADER": "${AUTH_HEADER}"} diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index f9ed9537..5460eba3 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -599,6 +599,13 @@ def test_missing_var_is_left_unexpanded_and_warns(self, monkeypatch, caplog): assert result == {"AUTH_HEADER": "${DOES_NOT_EXIST}"} assert "DOES_NOT_EXIST" in caplog.text + def test_empty_env_var_is_treated_as_unset(self, monkeypatch, caplog): + monkeypatch.setenv("AUTH_HEADER", "") + with caplog.at_level(logging.WARNING): + result = expand_env_vars({"AUTH_HEADER": "${AUTH_HEADER}"}) + assert result == {"AUTH_HEADER": "${AUTH_HEADER}"} + assert "AUTH_HEADER" in caplog.text + def test_does_not_mutate_input_dict(self, monkeypatch): monkeypatch.setenv("AUTH_HEADER", "resolved") original = {"AUTH_HEADER": "${AUTH_HEADER}"} From 34f0e381bcb1c9bba5e4d44db8d9a167f24f4948 Mon Sep 17 00:00:00 2001 From: Aleksey Zhadeev Date: Fri, 28 Aug 2026 14:51:01 -0400 Subject: [PATCH 11/13] Auth header variable interpolation for http servers --- src/agent_scan/mcp_client.py | 4 +-- tests/unit/test_mcp_client.py | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/agent_scan/mcp_client.py b/src/agent_scan/mcp_client.py index 45ee1a0c..cc1b4555 100644 --- a/src/agent_scan/mcp_client.py +++ b/src/agent_scan/mcp_client.py @@ -126,7 +126,7 @@ async def get_client( sse_oauth_provider = await _resolve_scan_oauth_provider(server_config.url, token) client_cm = sse_client( url=server_config.url, - headers=server_config.headers, + headers=expand_env_vars(server_config.headers), # env=server_config.env, #Not supported by MCP yet, but present in vscode timeout=timeout, auth=sse_oauth_provider, @@ -137,7 +137,7 @@ async def get_client( ) client_cm = streamablehttp_client_without_session( url=server_config.url, - headers=server_config.headers, + headers=expand_env_vars(server_config.headers), timeout=timeout or 60, token=token, ) diff --git a/tests/unit/test_mcp_client.py b/tests/unit/test_mcp_client.py index 6f326ae4..b3ebb286 100644 --- a/tests/unit/test_mcp_client.py +++ b/tests/unit/test_mcp_client.py @@ -131,6 +131,64 @@ async def test_get_client_expands_env_placeholders_for_stdio_server(mock_stdio_c assert server.env == {"AUTH_HEADER": "${AUTH_HEADER}"} +@pytest.mark.asyncio +@patch("agent_scan.mcp_client.streamablehttp_client_without_session") +async def test_get_client_expands_env_placeholders_for_remote_http_server(mock_streamable_client, monkeypatch): + """get_client() must pass the EXPANDED headers to the HTTP transport, not + the literal ${VAR} placeholder from the parsed config.""" + monkeypatch.setenv("LINEAR_API_TOKEN", "real-secret-value") + + mock_read = AsyncMock() + mock_write = AsyncMock() + mock_client = AsyncMock() + mock_client.__aenter__.return_value = (mock_read, mock_write) + mock_streamable_client.return_value = mock_client + + server = RemoteServer( + url="https://example.com/mcp", + type="http", + headers={"Authorization": "Bearer ${LINEAR_API_TOKEN}"}, + ) + + async with get_client(server, timeout=5): + pass + + assert mock_streamable_client.call_count == 1 + called_kwargs = mock_streamable_client.call_args.kwargs + assert called_kwargs["headers"] == {"Authorization": "Bearer real-secret-value"} + # The parsed model itself must be untouched -- still the literal placeholder. + assert server.headers == {"Authorization": "Bearer ${LINEAR_API_TOKEN}"} + + +@pytest.mark.asyncio +@patch("agent_scan.mcp_client.sse_client") +async def test_get_client_expands_env_placeholders_for_remote_sse_server(mock_sse_client, monkeypatch): + """get_client() must pass the EXPANDED headers to the SSE transport, not + the literal ${VAR} placeholder from the parsed config.""" + monkeypatch.setenv("LINEAR_API_TOKEN", "real-secret-value") + + mock_read = AsyncMock() + mock_write = AsyncMock() + mock_client = AsyncMock() + mock_client.__aenter__.return_value = (mock_read, mock_write) + mock_sse_client.return_value = mock_client + + server = RemoteServer( + url="https://example.com/sse", + type="sse", + headers={"Authorization": "Bearer ${LINEAR_API_TOKEN}"}, + ) + + async with get_client(server, timeout=5): + pass + + assert mock_sse_client.call_count == 1 + called_kwargs = mock_sse_client.call_args.kwargs + assert called_kwargs["headers"] == {"Authorization": "Bearer real-secret-value"} + # The parsed model itself must be untouched -- still the literal placeholder. + assert server.headers == {"Authorization": "Bearer ${LINEAR_API_TOKEN}"} + + @pytest.mark.parametrize( "input_url", [ From 6a66281ac2f124edb5cd6b8885231ae15281005f Mon Sep 17 00:00:00 2001 From: Aleksey Zhadeev Date: Fri, 28 Aug 2026 15:02:30 -0400 Subject: [PATCH 12/13] removed planning file from change per CR --- .../2026-08-10-oauth-credential-hardening.md | 833 ------------------ 1 file changed, 833 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-10-oauth-credential-hardening.md diff --git a/docs/superpowers/plans/2026-08-10-oauth-credential-hardening.md b/docs/superpowers/plans/2026-08-10-oauth-credential-hardening.md deleted file mode 100644 index 60bce4a7..00000000 --- a/docs/superpowers/plans/2026-08-10-oauth-credential-hardening.md +++ /dev/null @@ -1,833 +0,0 @@ -# OAuth Credential Handling Hardening Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Close four concrete defects in the OAuth credential handling added by `feat/oauth-resolution`, without changing the storage architecture. - -**Architecture:** All four fixes are local to two modules — `src/agent_scan/oauth_store.py` (the file-backed token store and the proactive refresh) and `src/agent_scan/debug_mcp_auth.py` (a diagnostics helper). No new dependencies, no change to the on-disk format, no change to any CLI surface. Each fix is independently testable and independently revertable, and each gets its own commit. - -**Tech Stack:** Python 3.10+, `pydantic` v2, `httpx`, `mcp==1.27.0`, `pytest` + `pytest-asyncio`, `ruff` for lint/format. - -## Global Constraints - -- Branch: `feat/oauth-resolution`. Working tree was clean at plan time; do not rebase or merge `main` as part of this work. -- Python floor is **3.10** (`requires-python = ">=3.10"`). No `match` statements, no 3.11-only stdlib. `ipaddress` and `os.fchmod` are both 3.10-safe. -- **No new dependencies.** `pyproject.toml` pins deliberately and carries CVE overrides; adding a package is out of scope for this plan. -- Run tests via the Makefile: `make test `. Bare `pytest` fails — the suite defines a required `--runner` option, which the Makefile supplies (`--runner=uv`). -- Line length is 120 (`[tool.ruff] line-length = 120`). Double quotes. `ruff` lint selects `E,F,I,B,C4,UP,SIM,TCH,W,RUF`. -- POSIX-mode assertions must be skipped on Windows, matching the existing convention in `tests/unit/test_guard.py`: `@pytest.mark.skipif(sys.platform == "win32", reason=...)`. -- Do **not** add a `CHANGELOG.md` entry. That file is version-keyed and written by the release commit (see `0.5.16` at the tail); adding an unreleased line here would conflict with that flow. -- Every new test must be observed **failing for the intended reason** before its fix is written, except where a step explicitly labels a test as a characterization test. - ---- - -## File Structure - -| File | Responsibility | Change | -|---|---|---| -| `src/agent_scan/oauth_store.py` | Token persistence, permissions, proactive refresh, token-endpoint validation | Modify — Tasks 1, 2, 3, 4 | -| `src/agent_scan/debug_mcp_auth.py` | Diagnostics helper for the interactive auth flow | Modify — Task 2 | -| `tests/unit/test_oauth_store.py` | Unit tests for the store and refresh | Modify — Tasks 1, 2, 3, 4 | -| `tests/unit/test_debug_mcp_auth.py` | Unit tests for the diagnostics helper | Modify — Task 2 | - -`oauth_store.py` is ~380 lines and already cohesive (persistence + refresh for one concern). It is not unwieldy and this plan does **not** split it. - -Two shared test helpers in `tests/unit/test_oauth_store.py` are extended rather than duplicated: - -- `_FakeResponse` gains a `headers` argument (Task 3). -- `_FakeAsyncClient` gains `last_init_kwargs` recording (Task 3). - -Task 3 is therefore ordered before Task 4, because Task 4's tests reuse the extended `_FakeResponse`. - ---- - -## Task 1: Create the token file owner-only from the first byte - -**Files:** -- Modify: `src/agent_scan/oauth_store.py:160-168` (`OAuthTokenStore._write_raw`) -- Test: `tests/unit/test_oauth_store.py` - -**Interfaces:** -- Consumes: nothing from earlier tasks. -- Produces: no new public names. `_write_raw(self, data: dict[str, dict]) -> None` keeps its exact signature; callers `put`, `update_token`, `set_token_url` are unchanged. - -**Why this is a defect.** The current code writes the token document with builtin `open(tmp, "w")`, which creates the file with `0o666 & ~umask` — `0o644` under the common `umask 022`. The access token, refresh token, and client secret are written into that world-readable file, and only *afterwards* is `os.chmod(tmp, 0o600)` applied. Between the write and the chmod, any local user can read every stored credential. The existing test at `tests/unit/test_oauth_store.py:94` asserts the *final* mode and so passes despite the window. - -- [ ] **Step 1: Add the umask-pinning fixture and the two imports it needs** - -At the top of `tests/unit/test_oauth_store.py`, the import block is currently: - -```python -import json -import stat -import time -``` - -Replace it with: - -```python -import json -import os -import stat -import sys -import time -``` - -Then add this fixture immediately after the `_entry` helper (after line 36, before the `test_normalize_server_url` parametrize block): - -```python -@pytest.fixture -def permissive_umask(): - """Pin a permissive umask for the duration of a test. - - Without this, a developer running with ``umask 077`` would see the - permission tests pass even against the unfixed code, because the ambient - umask — not the code — would be what tightened the file. - """ - previous = os.umask(0o022) - try: - yield - finally: - os.umask(previous) -``` - -- [ ] **Step 2: Write the failing test** - -Append to `tests/unit/test_oauth_store.py`: - -```python -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes are not meaningful on Windows") -def test_temp_file_is_owner_only_while_being_written(tmp_path, monkeypatch, permissive_umask): - """The temp file must be 0600 before any token bytes reach it. - - Regression test: creating it with builtin ``open()`` yields ``0o666 & ~umask`` - (0o644 here) and only tightens it after the write, so the fully-written - credential file is world-readable for the length of the write. - """ - path = tmp_path / "store.json" - tmp_file = tmp_path / "store.json.tmp" - observed: dict[str, int] = {} - - real_dump = oauth_store.json.dump - - def spy_dump(obj, fp, **kwargs): - # Sampled at the moment the credentials are being serialized — the exact - # window the unfixed code leaves open. - observed["mode"] = stat.S_IMODE(tmp_file.stat().st_mode) - return real_dump(obj, fp, **kwargs) - - monkeypatch.setattr(oauth_store.json, "dump", spy_dump) - OAuthTokenStore(path=path).put("https://mcp.linear.app/mcp", _entry()) - - assert observed["mode"] == 0o600 -``` - -- [ ] **Step 3: Run the test and confirm it fails for the right reason** - -Run: `make test tests/unit/test_oauth_store.py::test_temp_file_is_owner_only_while_being_written ARGS="-v"` - -Expected: **FAIL** with `assert 420 == 384`. `420` is `0o644` in decimal and `384` is `0o600`. If you instead see it pass, the fixture is not applied — check that `permissive_umask` is in the test signature. - -- [ ] **Step 4: Apply the fix** - -In `src/agent_scan/oauth_store.py`, replace `_write_raw` in full: - -```python - def _write_raw(self, data: dict[str, dict]) -> None: - # Create the directory owner-only from the start; the chmod covers the - # case where it already existed with looser permissions. - self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - with contextlib.suppress(OSError): - os.chmod(self.path.parent, 0o700) - tmp = self.path.with_suffix(self.path.suffix + ".tmp") - # Open at 0o600 *before* any token bytes are written. Builtin open() - # would create the file at 0o666 & ~umask (0o644 under the usual - # umask 022) and only tighten it afterwards, leaving a fully-written - # credential file readable by every local user for the length of the - # write. - fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as f: - # os.open's mode argument is masked by umask; fchmod is not, so this - # pins 0o600 regardless of the umask the caller runs with. - os.fchmod(f.fileno(), 0o600) - json.dump(data, f, indent=2, default=str) - f.flush() - os.fsync(f.fileno()) - os.replace(tmp, self.path) -``` - -Notes for the implementer: - -- `os.fdopen` takes ownership of `fd`, so the `with` block closes it on any exception. Do not add a bare `os.close(fd)` — that would double-close. -- The `f.flush()` / `os.fsync(...)` pair is **durability, not security**: it makes the "atomic write" actually survive a crash rather than leaving a zero-length store. If you want the minimal security-only diff, drop those two lines; nothing else depends on them. -- `mode=0o700` on `mkdir` applies only to the final path component and only when the directory is actually created, which is why the explicit `chmod` stays. - -- [ ] **Step 5: Run the test and confirm it passes** - -Run: `make test tests/unit/test_oauth_store.py::test_temp_file_is_owner_only_while_being_written ARGS="-v"` - -Expected: **PASS** - -- [ ] **Step 6: Add the directory characterization test** - -This one **passes before and after** the fix — `_write_raw` already chmods the parent to `0o700`. It is worth adding anyway: nothing currently pins that behavior, so a future refactor could drop it silently. Label it as such so no one mistakes it for a regression test. - -Append to `tests/unit/test_oauth_store.py`: - -```python -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes are not meaningful on Windows") -def test_store_directory_is_owner_only(tmp_path, permissive_umask): - """Characterization test: the store directory is 0700, including when created. - - Passes before and after the temp-file fix. It exists so that the directory - tightening cannot be removed without a test failing. - """ - store_dir = tmp_path / "nested" / ".mcp-scan" - OAuthTokenStore(path=store_dir / "store.json").put("https://mcp.linear.app/mcp", _entry()) - - assert stat.S_IMODE(store_dir.stat().st_mode) == 0o700 -``` - -- [ ] **Step 7: Run the whole store suite to check nothing regressed** - -Run: `make test tests/unit/test_oauth_store.py ARGS="-v"` - -Expected: **PASS**, including the pre-existing `test_store_roundtrip_and_permissions`, which asserts the final mode is `0o600`. - -- [ ] **Step 8: Commit** - -```bash -git add src/agent_scan/oauth_store.py tests/unit/test_oauth_store.py -git commit -m "fix(oauth): create the token store file 0600 before writing credentials - -Builtin open() created the temp file at 0o666 & ~umask and only chmod'd it -to 0600 after the tokens were written, leaving a world-readable credential -file for the duration of the write. Open with os.open(..., 0o600) and pin -with fchmod instead." -``` - ---- - -## Task 2: Stop the diagnostics helper printing live credentials - -**Files:** -- Modify: `src/agent_scan/oauth_store.py` — add `StoredServerAuth.safe_summary` -- Modify: `src/agent_scan/debug_mcp_auth.py:39-40` -- Test: `tests/unit/test_oauth_store.py`, `tests/unit/test_debug_mcp_auth.py` - -**Interfaces:** -- Consumes: nothing from Task 1. -- Produces: `StoredServerAuth.safe_summary(self) -> dict[str, object]` — a non-secret dict describing one store entry. Used by `debug_mcp_auth.run_debug_auth`, and available to any future `--list` / logging code. - -**Why this is a defect.** `debug_mcp_auth.py:39-40` prints `json.dumps(entry.model_dump(mode="json"), indent=2)`, which is the *complete* credential set — `access_token`, `refresh_token`, and `client_secret` — to stdout. `main()` at `debug_mcp_auth.py:70` hardcodes `print_details=True`, so running the module dumps every secret for that server to the terminal, where it lands in scrollback, `script`/`tee` logs, and CI output. The module ships inside the wheel (`packages = ["src/agent_scan"]`). - -An alternative is deleting the module outright. This plan keeps it and makes it safe, because it has a test and a plausible support use; if you would rather delete it, delete `src/agent_scan/debug_mcp_auth.py` and `tests/unit/test_debug_mcp_auth.py` and skip to Task 3 — but still add `safe_summary`, since Task 2's Step 2 test and any future diagnostics depend on it. - -- [ ] **Step 1: Write the failing test for `safe_summary`** - -Append to `tests/unit/test_oauth_store.py`: - -```python -def test_safe_summary_omits_secrets(): - entry = _entry(token=_token(access="SECRETACCESS", refresh="SECRETREFRESH")) - entry.client_secret = "SECRETCLIENT" - - summary = entry.safe_summary() - rendered = json.dumps(summary) - - assert "SECRETACCESS" not in rendered - assert "SECRETREFRESH" not in rendered - assert "SECRETCLIENT" not in rendered - # Presence is still reportable without disclosing the values. - assert summary["has_refresh_token"] is True - assert summary["has_client_secret"] is True - # Non-secret identifiers stay useful for diagnostics. - assert summary["client_id"] == "client-123" - assert summary["token_url"] == "https://mcp.linear.app/token" -``` - -- [ ] **Step 2: Run it and confirm it fails** - -Run: `make test tests/unit/test_oauth_store.py::test_safe_summary_omits_secrets ARGS="-v"` - -Expected: **FAIL** with `AttributeError: 'StoredServerAuth' object has no attribute 'safe_summary'` - -- [ ] **Step 3: Implement `safe_summary`** - -In `src/agent_scan/oauth_store.py`, add this method to `StoredServerAuth`, immediately after `is_access_token_expired` (which ends at line 132): - -```python - def safe_summary(self) -> dict[str, object]: - """Non-secret description of this entry, for diagnostics and logs. - - Deliberately omits ``access_token``, ``refresh_token`` and - ``client_secret``. Anything that prints or logs an entry must go through - here — ``model_dump()`` returns the live credentials verbatim. - """ - return { - "server_name": self.server_name, - "mcp_server_url": self.mcp_server_url, - "client_id": self.client_id, - "token_url": self.token_url, - "redirect_uris": self.redirect_uris, - "updated_at": self.updated_at, - "expires_at": self.expires_at, - "has_client_secret": self.client_secret is not None, - "has_refresh_token": self.token.refresh_token is not None, - "access_token_expired": self.is_access_token_expired(), - } -``` - -`client_id` is intentionally included: in OAuth it is a public identifier, not a secret, and it is the field you actually need when debugging a DCR problem. - -- [ ] **Step 4: Run it and confirm it passes** - -Run: `make test tests/unit/test_oauth_store.py::test_safe_summary_omits_secrets ARGS="-v"` - -Expected: **PASS** - -- [ ] **Step 5: Write the failing test for the helper's output** - -Append to `tests/unit/test_debug_mcp_auth.py`: - -```python -@pytest.mark.asyncio -async def test_run_debug_auth_does_not_print_secrets(tmp_path, monkeypatch, capsys): - """print_details must never put live credentials on stdout.""" - store = OAuthTokenStore(path=tmp_path / "store.json") - store.put( - "https://example.com/mcp", - StoredServerAuth( - server_name="example", - client_id="client-1", - client_secret="SECRETCLIENT", - token_url="https://example.com/token", - mcp_server_url="https://example.com/mcp", - redirect_uris=["http://127.0.0.1:1234/callback"], - updated_at=1.0, - expires_at=2.0, - token=OAuthToken( - access_token="SECRETACCESS", - token_type="Bearer", - expires_in=3600, - refresh_token="SECRETREFRESH", - ), - ), - ) - - async def fake_authenticate_server(url, server_name, store, **kwargs): - return AuthResult(ok=True, server_url=url, message="ok") - - monkeypatch.setattr("agent_scan.debug_mcp_auth.authenticate_server", fake_authenticate_server) - - await run_debug_auth( - url="https://example.com/mcp", - server_name="example", - store=store, - timeout=1.0, - verbose=True, - print_details=True, - ) - - out = capsys.readouterr().out - assert "SECRETACCESS" not in out - assert "SECRETREFRESH" not in out - assert "SECRETCLIENT" not in out - # The non-secret summary is still printed, so the helper remains useful. - assert "client-1" in out -``` - -The secret values are deliberately single unbroken words. `rich` soft-wraps at the console width, and a hyphenated or spaced value could be split across lines and defeat a plain substring assertion. - -- [ ] **Step 6: Run it and confirm it fails** - -Run: `make test tests/unit/test_debug_mcp_auth.py::test_run_debug_auth_does_not_print_secrets ARGS="-v"` - -Expected: **FAIL** on `assert "SECRETACCESS" not in out` — the current code dumps the full model. - -- [ ] **Step 7: Apply the fix** - -In `src/agent_scan/debug_mcp_auth.py`, the current block is: - -```python - if entry is not None: - if print_details: - rich.print(json.dumps(entry.model_dump(mode="json"), indent=2)) -``` - -Replace it with: - -```python - if entry is not None: - if print_details: - # safe_summary(), never model_dump(): the latter includes the access - # token, refresh token and client secret, which must not reach stdout. - rich.print(json.dumps(entry.safe_summary(), indent=2)) -``` - -- [ ] **Step 8: Run both test files and confirm they pass** - -Run: `make test tests/unit/test_debug_mcp_auth.py tests/unit/test_oauth_store.py ARGS="-v"` - -Expected: **PASS**, including the pre-existing `test_run_debug_auth_reports_existing_entry`. - -- [ ] **Step 9: Commit** - -```bash -git add src/agent_scan/oauth_store.py src/agent_scan/debug_mcp_auth.py \ - tests/unit/test_oauth_store.py tests/unit/test_debug_mcp_auth.py -git commit -m "fix(oauth): stop the debug helper printing access and refresh tokens - -run_debug_auth printed entry.model_dump(), i.e. the access token, refresh -token and client secret, to stdout — and main() hardcodes print_details=True. -Add StoredServerAuth.safe_summary() and print that instead." -``` - ---- - -## Task 3: Do not follow redirects on the token endpoint - -**Files:** -- Modify: `src/agent_scan/oauth_store.py:365-370` (inside `ensure_fresh_token`) -- Test: `tests/unit/test_oauth_store.py` - -**Interfaces:** -- Consumes: nothing from Tasks 1–2. -- Produces: no new public names. `ensure_fresh_token(store, server_url, *, timeout=30.0) -> None` keeps its signature and its never-raises contract. -- Extends two test helpers that Task 4 reuses: `_FakeResponse.__init__(self, status_code, content, headers=None)` and `_FakeAsyncClient.last_init_kwargs`. - -**Why this is a defect.** The refresh POST at `oauth_store.py:366` uses `httpx.AsyncClient(timeout=timeout, follow_redirects=True)` and sends `refresh_token` plus `client_secret` in the body. `httpx` preserves the method and re-sends the body on `307` and `308`. The target, `entry.token_url`, comes from OAuth discovery metadata captured in `oauth_flow.authenticate_server` — i.e. it is chosen by the remote server. A server that returns `307 Location: https://attacker.example/` therefore receives the long-lived refresh token and the client secret. This is the one item of the four I would call a genuine vulnerability rather than hardening. - -Turning redirects off makes the existing `if resp.status_code != 200` branch handle it, so the fix needs no restructuring — only an explicit branch so the log says something useful. - -- [ ] **Step 1: Extend the two shared test helpers** - -In `tests/unit/test_oauth_store.py`, replace the `_FakeResponse` class: - -```python -class _FakeResponse: - def __init__(self, status_code, content): - self.status_code = status_code - self.content = content -``` - -with: - -```python -class _FakeResponse: - def __init__(self, status_code, content, headers=None): - self.status_code = status_code - self.content = content - self.headers = headers or {} -``` - -Then replace `_FakeAsyncClient.__init__`: - -```python - def __init__(self, *args, **kwargs): - pass -``` - -with: - -```python - def __init__(self, *args, **kwargs): - # Recorded on the base class so subclass instances report here too. - _FakeAsyncClient.last_init_kwargs = kwargs -``` - -and add the class attribute alongside the existing `last_post = None`: - -```python - last_post = None - last_init_kwargs: dict | None = None -``` - -- [ ] **Step 2: Write the two failing tests** - -Append to `tests/unit/test_oauth_store.py`: - -```python -@pytest.mark.asyncio -async def test_refresh_disables_redirect_following(tmp_path, monkeypatch): - """The token exchange must not follow redirects. - - token_url comes from server-controlled discovery metadata, and httpx - re-sends the body on 307/308 — so following a redirect would hand the - refresh token and client secret to a host the server chose. - """ - monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _FakeAsyncClient) - store = OAuthTokenStore(path=tmp_path / "store.json") - store.put("https://mcp.linear.app/mcp", _entry(expires_at=time.time() - 10)) - - await ensure_fresh_token(store, "https://mcp.linear.app/mcp") - - assert _FakeAsyncClient.last_init_kwargs["follow_redirects"] is False - - -@pytest.mark.asyncio -async def test_refresh_ignores_a_redirect_response(tmp_path, monkeypatch): - class _Redirecting(_FakeAsyncClient): - async def post(self, url, data=None, headers=None): - _FakeAsyncClient.last_post = {"url": url, "data": data} - return _FakeResponse(307, b"", {"location": "https://attacker.example/token"}) - - monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _Redirecting) - store = OAuthTokenStore(path=tmp_path / "store.json") - store.put("https://mcp.linear.app/mcp", _entry(token=_token(access="stale"), expires_at=time.time() - 10)) - - await ensure_fresh_token(store, "https://mcp.linear.app/mcp") - - # The stale token is left for the connection to try, and the only request - # made went to the configured endpoint. - assert store.get("https://mcp.linear.app/mcp").token.access_token == "stale" - assert _FakeAsyncClient.last_post["url"] == "https://mcp.linear.app/token" -``` - -- [ ] **Step 3: Run them and confirm the first fails** - -Run: `make test tests/unit/test_oauth_store.py ARGS="-v -k redirect"` - -Expected: `test_refresh_disables_redirect_following` **FAILS** with `assert True is False`. `test_refresh_ignores_a_redirect_response` already passes — the fake client does not itself follow redirects, so it only pins the fail-closed handling of a `3xx` body. Both are worth keeping. - -- [ ] **Step 4: Apply the fix** - -In `src/agent_scan/oauth_store.py`, inside `ensure_fresh_token`, the current block is: - -```python - try: - async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: - resp = await client.post(entry.token_url, data=data, headers=headers) - if resp.status_code != 200: - logger.info("Refresh for %s failed with status %s; leaving stored token", server_url, resp.status_code) - return - new_token = OAuthToken.model_validate_json(resp.content) -``` - -Replace it with: - -```python - try: - # follow_redirects=False is deliberate and security-relevant: httpx - # preserves the method and re-sends the body on 307/308, and - # entry.token_url comes from server-controlled discovery metadata. - # Following a redirect would deliver the refresh token and client - # secret to a host the remote server picked. - async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client: - resp = await client.post(entry.token_url, data=data, headers=headers) - if resp.status_code in (301, 302, 303, 307, 308): - logger.warning( - "Token endpoint for %s returned a %s redirect to %s; not resending the refresh token", - server_url, - resp.status_code, - resp.headers.get("location", ""), - ) - return - if resp.status_code != 200: - logger.info("Refresh for %s failed with status %s; leaving stored token", server_url, resp.status_code) - return - new_token = OAuthToken.model_validate_json(resp.content) -``` - -- [ ] **Step 5: Run the full store suite** - -Run: `make test tests/unit/test_oauth_store.py ARGS="-v"` - -Expected: **PASS**. In particular `test_ensure_fresh_token_refreshes_expired` must still pass — the helper change to `_FakeAsyncClient.__init__` must not have broken it. - -- [ ] **Step 6: Commit** - -```bash -git add src/agent_scan/oauth_store.py tests/unit/test_oauth_store.py -git commit -m "fix(oauth): do not follow redirects when exchanging a refresh token - -The refresh POST ran with follow_redirects=True against a token_url taken -from server-controlled discovery metadata. httpx re-sends the body on -307/308, so a redirect would have delivered the refresh token and client -secret to a host chosen by the remote server. Fail closed on any 3xx." -``` - ---- - -## Task 4: Require HTTPS (or loopback) for the token endpoint - -**Files:** -- Modify: `src/agent_scan/oauth_store.py` — add `ipaddress` import, `_is_loopback_host`, `is_secure_token_url`; guard `set_token_url` and `ensure_fresh_token` -- Test: `tests/unit/test_oauth_store.py` - -**Interfaces:** -- Consumes: `_FakeResponse(status_code, content, headers=None)` and `_FakeAsyncClient` from Task 3. -- Produces: - - `is_secure_token_url(url: str) -> bool` — module-level, public. `True` for `https`, or for `http` on a loopback host. - - `_is_loopback_host(host: str) -> bool` — module-level, private. - - `OAuthTokenStore.set_token_url` keeps its `(self, server_url: str, token_url: str) -> None` signature and stays non-raising; it now silently declines to persist an insecure endpoint, with a warning log. - -**Why this is a defect.** `entry.token_url` is written from discovery metadata (`oauth_flow.py:282-285`) with no scheme check, and `ensure_fresh_token` POSTs the refresh token and client secret to it. A discovery document advertising `http://…` gets the credential sent in cleartext. RFC 6749 §3.2 requires TLS on the token endpoint. - -Enforce at both chokepoints rather than with a pydantic field validator. A strict validator on `StoredServerAuth.token_url` would break `oauth_flow._AuthFlowTokenStorage.set_tokens`, which deliberately constructs the entry with `token_url=""` and lets `authenticate_server` fill it in afterwards (`oauth_flow.py:179`). - -Plain `http` on loopback is allowed: local MCP servers legitimately use it, and the credential never touches a network. `urlparse("")` yields an empty scheme, so `token_url=""` is rejected by the same check — which is the correct fail-closed result. - -- [ ] **Step 1: Write the failing tests** - -First, add `is_secure_token_url` to the existing import block in `tests/unit/test_oauth_store.py`: - -```python -from agent_scan.oauth_store import ( - OAuthTokenStore, - PersistentTokenStorage, - StoredServerAuth, - ensure_fresh_token, - is_secure_token_url, - normalize_server_url, -) -``` - -Then append: - -```python -@pytest.mark.parametrize( - "url,expected", - [ - ("https://mcp.linear.app/token", True), - ("https://auth.atlassian.com/oauth/token", True), - # Loopback http never leaves the host, and local servers use it. - ("http://127.0.0.1:8080/token", True), - ("http://localhost:8080/token", True), - ("http://[::1]:8080/token", True), - # Anything else must be TLS (RFC 6749 s3.2). - ("http://mcp.linear.app/token", False), - ("http://attacker.example/token", False), - # Empty (an entry whose endpoint was never finalized) and malformed. - ("", False), - ("not a url", False), - ], -) -def test_is_secure_token_url(url, expected): - assert is_secure_token_url(url) is expected - - -@pytest.mark.asyncio -async def test_refresh_refuses_a_plaintext_token_endpoint(tmp_path, monkeypatch): - called = {"post": False} - - class _NoPost(_FakeAsyncClient): - async def post(self, *a, **k): - called["post"] = True - return _FakeResponse(200, b"{}") - - monkeypatch.setattr(oauth_store.httpx, "AsyncClient", _NoPost) - store = OAuthTokenStore(path=tmp_path / "store.json") - entry = _entry(expires_at=time.time() - 10) - entry.token_url = "http://mcp.linear.app/token" - store.put("https://mcp.linear.app/mcp", entry) - - await ensure_fresh_token(store, "https://mcp.linear.app/mcp") - - assert called["post"] is False # the refresh token was never sent in cleartext - - -def test_set_token_url_rejects_plaintext(tmp_path): - store = OAuthTokenStore(path=tmp_path / "store.json") - store.put("https://mcp.linear.app/mcp", _entry()) - - store.set_token_url("https://mcp.linear.app/mcp", "http://attacker.example/token") - - # The original endpoint is retained; the insecure one is never persisted. - assert store.get("https://mcp.linear.app/mcp").token_url == "https://mcp.linear.app/token" - - -def test_set_token_url_accepts_https(tmp_path): - store = OAuthTokenStore(path=tmp_path / "store.json") - store.put("https://mcp.linear.app/mcp", _entry()) - - store.set_token_url("https://mcp.linear.app/mcp", "https://auth.atlassian.com/oauth/token") - - assert store.get("https://mcp.linear.app/mcp").token_url == "https://auth.atlassian.com/oauth/token" -``` - -- [ ] **Step 2: Run them and confirm they fail** - -Run: `make test tests/unit/test_oauth_store.py ARGS="-v -k 'secure_token_url or plaintext or set_token_url'"` - -Expected: collection fails first with `ImportError: cannot import name 'is_secure_token_url'`. That counts as the failing state — it proves the tests are wired to the not-yet-written function. After Step 3 the remaining genuine failure to watch for is `test_refresh_refuses_a_plaintext_token_endpoint`. - -- [ ] **Step 3: Add the validation helpers** - -In `src/agent_scan/oauth_store.py`, add `ipaddress` to the stdlib import block (it sorts between `contextlib` and `json`, per `ruff`'s isort rules): - -```python -import asyncio -import contextlib -import ipaddress -import json -import logging -import os -import time -``` - -Then add both helpers immediately after the `_PLACEHOLDER_REDIRECT_URI` constant (line 55) and before `normalize_server_url`: - -```python -def _is_loopback_host(host: str) -> bool: - """True for ``localhost`` and any address in 127.0.0.0/8 or ::1.""" - if host == "localhost": - return True - try: - return ipaddress.ip_address(host).is_loopback - except ValueError: - return False - - -def is_secure_token_url(url: str) -> bool: - """True if a refresh token and client secret may be sent to ``url``. - - RFC 6749 s3.2 requires TLS on the token endpoint. The endpoint we persist is - taken from server-controlled OAuth discovery metadata, so it is validated - before use rather than trusted. Plain ``http`` is accepted only for loopback - hosts: local MCP servers legitimately use it, and the credential never - reaches a network. An empty or unparseable URL is rejected, which is also - what makes an entry whose endpoint was never finalized fail closed. - """ - try: - parsed = urlparse(url) - except ValueError: - return False - if parsed.scheme == "https": - return True - return parsed.scheme == "http" and _is_loopback_host((parsed.hostname or "").lower()) -``` - -- [ ] **Step 4: Guard `set_token_url`** - -In `OAuthTokenStore.set_token_url`, insert the check as the first statement in the body, before `key = normalize_server_url(server_url)`: - -```python - if not is_secure_token_url(token_url): - logger.warning( - "Refusing to store a non-HTTPS token endpoint for %s: %r", server_url, token_url - ) - return -``` - -Also extend that method's docstring with a final paragraph: - -``` - Declines to store an endpoint that is neither HTTPS nor loopback, so a - discovery document cannot arrange for the refresh token to be sent in - cleartext later. The entry keeps whatever endpoint it already had. -``` - -- [ ] **Step 5: Guard `ensure_fresh_token`** - -In `ensure_fresh_token`, the current block is: - -```python - if entry.token.refresh_token is None: - # Nothing to refresh with; let the connection fail to auth_failed. - logger.debug("Stored token for %s expired and has no refresh token", server_url) - return -``` - -Add immediately after it, before `data = {...}`: - -```python - if not is_secure_token_url(entry.token_url): - # Fail closed rather than send the credential in cleartext. The scan - # then tries the stale token and falls to auth_failed, prompting the - # user to re-run mcp-auth. - logger.warning( - "Refusing to refresh %s: stored token endpoint %r is neither HTTPS nor loopback", - server_url, - entry.token_url, - ) - return -``` - -- [ ] **Step 6: Run the store suite and confirm everything passes** - -Run: `make test tests/unit/test_oauth_store.py ARGS="-v"` - -Expected: **PASS**. All the pre-existing tests use `https://mcp.linear.app/token`, so none of them trip the new guard. - -- [ ] **Step 7: Commit** - -```bash -git add src/agent_scan/oauth_store.py tests/unit/test_oauth_store.py -git commit -m "fix(oauth): require HTTPS or loopback for the token endpoint - -token_url is taken from server-controlled discovery metadata and was used -without a scheme check, so a discovery document advertising http:// would -get the refresh token and client secret sent in cleartext. Validate at both -chokepoints: refuse to persist an insecure endpoint, and refuse to refresh -against one." -``` - ---- - -## Task 5: Verification sweep - -**Files:** none modified — this task only runs checks. - -**Interfaces:** -- Consumes: all four fixes from Tasks 1–4. -- Produces: nothing. - -- [ ] **Step 1: Run the full unit suite** - -Run: `make test tests/unit ARGS="-q"` - -Expected: **PASS**, no new failures relative to the branch's pre-change state. If anything unrelated was already failing on `feat/oauth-resolution`, confirm that by stashing and re-running rather than assuming this plan caused it. - -- [ ] **Step 2: Lint and format** - -```bash -uv run ruff check src/agent_scan/oauth_store.py src/agent_scan/debug_mcp_auth.py \ - tests/unit/test_oauth_store.py tests/unit/test_debug_mcp_auth.py -uv run ruff format --check src/agent_scan/oauth_store.py src/agent_scan/debug_mcp_auth.py \ - tests/unit/test_oauth_store.py tests/unit/test_debug_mcp_auth.py -``` - -Expected: both clean. If `ruff format --check` reports a diff, run without `--check` and fold the result into the relevant commit with `git commit --amend`. - -- [ ] **Step 3: Confirm no secret-printing paths remain** - -```bash -grep -rn "model_dump" src/agent_scan/oauth_store.py src/agent_scan/debug_mcp_auth.py -``` - -Expected: only the `model_dump_json()` calls inside `OAuthTokenStore.put`, `update_token`, and `set_token_url` — those write to the `0600` store file, which is correct. No `model_dump` should feed `rich.print`, `print`, or a `logger` call. - -- [ ] **Step 4: Manually confirm the permission fix against a real store** - -```bash -uv run -m src.agent_scan.run mcp-auth --help -ls -la ~/.mcp-scan/ -``` - -Expected: `~/.mcp-scan` is `drwx------`, and `oauth-tokens.json` (if present from earlier use) is `-rw-------`. This is a sanity check on the real path, not a substitute for Step 1. - -- [ ] **Step 5: Review the four commits as a set** - -```bash -git log --oneline origin/main..HEAD | head -10 -git diff origin/main...HEAD -- src/agent_scan/oauth_store.py src/agent_scan/debug_mcp_auth.py -``` - -Confirm each commit is independently revertable and that no unrelated change slipped in. - ---- - -## Deferred, deliberately not in this plan - -- **Purge / TTL / `mcp-auth --forget`** — agreed as the likely next step. Needs its own plan: it adds CLI surface, and a TTL changes `StoredServerAuth` semantics. -- **OS keystore backend** (macOS Keychain, Windows DPAPI, Secret Service) — roadmap item, not remediation. -- **Correcting the MDM premise in the docstrings** at `oauth_store.py:3-5`, `oauth_store.py:52-54`, and `oauth_store.py:78-80`. The deployment model is a security admin's own machine, not unattended MDM, and the comments state otherwise — including a claim that `~/.mcp-scan` is "the same working directory the MDM deployment already runs the scan from", which conflates the home directory with the cwd. Worth a small separate commit so it is not buried in a security fix. -- **`--mcp-oauth-tokens-path` now persists to `~/.mcp-scan/oauth-tokens.json`** (`mcp_client.py:73-75`), which it did not on `main`. Not a defect, but undocumented at `docs/cli-reference.md:117`. -- **`CHANGELOG.md`** — one line describing these fixes belongs in the release commit that bumps the version, not here. - -## Self-review notes - -- Spec coverage: bug 1 → Task 1; bug 2 → Task 2; bug 3 → Task 3; bug 4 → Task 4. All four covered, each with a test that fails first. -- Task order matters in exactly one place: Task 3 extends `_FakeResponse` with `headers`, which Task 4's tests do not use — but Task 4 does reuse `_FakeAsyncClient`, so keep 3 before 4. -- Two tests are labelled as characterization tests (`test_store_directory_is_owner_only`, and `test_refresh_ignores_a_redirect_response`, which passes pre-fix) rather than presented as regression tests. That is intentional and called out at each step. From e4728e9568b7c977368330b6c60b4c93ea4d49ec Mon Sep 17 00:00:00 2001 From: Aleksey Zhadeev Date: Fri, 28 Aug 2026 16:07:47 -0400 Subject: [PATCH 13/13] secret encrypted --- pyproject.toml | 1 + src/agent_scan/oauth_store.py | 107 +++++++++++++++++++- tests/unit/test_oauth_store.py | 62 ++++++++++++ uv.lock | 178 +++++++++++++++++++++++++++++++++ 4 files changed, 346 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2476cf8e..92a2a87c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "mcp[cli]==1.28.1", "regex>=2026.2.19", "detect-secrets>=1.5.0", + "cryptography>=42.0.0", # TOML decoder for the Codex discoverer on Python 3.10 (stdlib ``tomllib`` is # 3.11+). Declared directly so the ``tomli`` fallback in ``agents/codex.py`` is # guaranteed under ``requires-python>=3.10`` rather than relying on a transitive. diff --git a/src/agent_scan/oauth_store.py b/src/agent_scan/oauth_store.py index 6df81eff..242e6da9 100644 --- a/src/agent_scan/oauth_store.py +++ b/src/agent_scan/oauth_store.py @@ -37,6 +37,7 @@ from urllib.parse import urlparse, urlsplit, urlunsplit import httpx +from cryptography.fernet import Fernet, InvalidToken from mcp.client.auth import TokenStorage from mcp.shared.auth import OAuthClientInformationFull, OAuthToken from pydantic import BaseModel, ConfigDict @@ -105,6 +106,96 @@ def normalize_server_url(url: str) -> str: return urlunsplit((split.scheme, split.netloc, path.rstrip("/"), split.query, split.fragment)) +_ENCRYPTED_PREFIX = "enc:v1:" +_SECRET_TOKEN_FIELDS = ("access_token", "refresh_token") + + +def _get_or_create_key(directory: Path) -> bytes: + """Load, or create and persist, the symmetric key that encrypts secret + fields in the token store. + + The key lives in a plain file (``store.key``) next to the store it + protects, so this does not defend against a process already running as + this OS user -- it can read the key file too. It defends against the + token leaking on its own: pasted into a bug report, swept up by a naive + filesystem secret-scanner, or included in a partial backup that misses + the key file. + """ + key_path = directory / "store.key" + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + with contextlib.suppress(OSError): + os.chmod(directory, 0o700) + try: + with open(key_path, "rb") as f: + return f.read() + except FileNotFoundError: + pass + key = Fernet.generate_key() + try: + fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600) + except FileExistsError: + # Lost a race with a concurrent process generating the key first. + with open(key_path, "rb") as f: + return f.read() + with os.fdopen(fd, "wb") as f: + if hasattr(os, "fchmod"): + os.fchmod(f.fileno(), 0o600) + f.write(key) + f.flush() + os.fsync(f.fileno()) + return key + + +def _encrypt_value(fernet: Fernet, value: str) -> str: + return _ENCRYPTED_PREFIX + fernet.encrypt(value.encode()).decode() + + +def _decrypt_value(fernet: Fernet, value: str) -> str | None: + """Decrypt one stored value. + + A value with no encryption tag is legacy plaintext, written before this + was added; it is returned unchanged, and re-encrypted the next time its + entry is written. A tagged value that fails to decrypt (lost or rotated + key) returns ``None`` rather than the ciphertext, so a corrupt secret is + never mistaken for a real one. + """ + if not value.startswith(_ENCRYPTED_PREFIX): + return value + try: + return fernet.decrypt(value[len(_ENCRYPTED_PREFIX) :].encode()).decode() + except InvalidToken: + logger.warning("Could not decrypt a stored OAuth secret (lost or rotated key?); treating it as absent") + return None + + +def _encrypt_entry(fernet: Fernet, entry: dict) -> dict: + entry = dict(entry) + if entry.get("client_secret"): + entry["client_secret"] = _encrypt_value(fernet, entry["client_secret"]) + token = entry.get("token") + if isinstance(token, dict): + token = dict(token) + for field in _SECRET_TOKEN_FIELDS: + if token.get(field): + token[field] = _encrypt_value(fernet, token[field]) + entry["token"] = token + return entry + + +def _decrypt_entry(fernet: Fernet, entry: dict) -> dict: + entry = dict(entry) + if entry.get("client_secret"): + entry["client_secret"] = _decrypt_value(fernet, entry["client_secret"]) + token = entry.get("token") + if isinstance(token, dict): + token = dict(token) + for field in _SECRET_TOKEN_FIELDS: + if token.get(field): + token[field] = _decrypt_value(fernet, token[field]) + entry["token"] = token + return entry + + def _store_path() -> Path: """Location of the token store. @@ -200,6 +291,9 @@ def __init__(self, path: Path | None = None): # -- disk I/O ----------------------------------------------------------- + def _get_key(self) -> bytes: + return _get_or_create_key(self.path.parent) + def _read_raw(self) -> dict[str, dict]: try: with open(self.path, encoding="utf-8") as f: @@ -209,7 +303,12 @@ def _read_raw(self) -> dict[str, dict]: except (json.JSONDecodeError, OSError): logger.warning("OAuth token store at %s is unreadable; treating as empty", self.path) return {} - return data if isinstance(data, dict) else {} + if not isinstance(data, dict): + return {} + fernet = Fernet(self._get_key()) + return { + key: (_decrypt_entry(fernet, entry) if isinstance(entry, dict) else entry) for key, entry in data.items() + } def _write_raw(self, data: dict[str, dict]) -> None: # Create the directory owner-only from the start; the chmod covers the @@ -227,6 +326,10 @@ def _write_raw(self, data: dict[str, dict]) -> None: # it is a symlink, so a symlink planted at the ``.tmp`` path beforehand # cannot redirect the write to an arbitrary target. getattr(...) makes # this a no-op flag bit on platforms without it. + fernet = Fernet(self._get_key()) + encrypted = { + key: (_encrypt_entry(fernet, entry) if isinstance(entry, dict) else entry) for key, entry in data.items() + } fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0), 0o600) with os.fdopen(fd, "w", encoding="utf-8") as f: # os.open's mode argument is masked by umask; fchmod is not, so this @@ -236,7 +339,7 @@ def _write_raw(self, data: dict[str, dict]) -> None: # windows-latest); POSIX file modes are meaningless there anyway. if hasattr(os, "fchmod"): os.fchmod(f.fileno(), 0o600) - json.dump(data, f, indent=2, default=str) + json.dump(encrypted, f, indent=2, default=str) f.flush() os.fsync(f.fileno()) os.replace(tmp, self.path) diff --git a/tests/unit/test_oauth_store.py b/tests/unit/test_oauth_store.py index 9e6e65e3..30d8216f 100644 --- a/tests/unit/test_oauth_store.py +++ b/tests/unit/test_oauth_store.py @@ -118,6 +118,68 @@ def test_store_roundtrip_and_permissions(tmp_path): assert list(data.keys()) == ["https://mcp.linear.app"] +def test_store_encrypts_secrets_at_rest(tmp_path): + """The access/refresh tokens and client secret must not appear in + plaintext in the on-disk file, but a round trip through get() must still + return the original values.""" + path = tmp_path / "store.json" + store = OAuthTokenStore(path=path) + entry = _entry(token=_token(access="SECRETACCESS", refresh="SECRETREFRESH")) + entry.client_secret = "SECRETCLIENT" + store.put("https://mcp.linear.app/mcp", entry) + + raw_bytes = path.read_bytes() + assert b"SECRETACCESS" not in raw_bytes + assert b"SECRETREFRESH" not in raw_bytes + assert b"SECRETCLIENT" not in raw_bytes + + got = store.get("https://mcp.linear.app/mcp") + assert got.token.access_token == "SECRETACCESS" + assert got.token.refresh_token == "SECRETREFRESH" + assert got.client_secret == "SECRETCLIENT" + + +def test_store_reads_legacy_plaintext_entries(tmp_path): + """An entry written before encryption-at-rest was added (plain strings, + no encryption tag) must still be read correctly.""" + path = tmp_path / "store.json" + entry = _entry(token=_token(access="PLAINACCESS", refresh="PLAINREFRESH")) + path.write_text(json.dumps({"https://mcp.linear.app": json.loads(entry.model_dump_json())})) + + store = OAuthTokenStore(path=path) + got = store.get("https://mcp.linear.app/mcp") + assert got.token.access_token == "PLAINACCESS" + assert got.token.refresh_token == "PLAINREFRESH" + + +def test_store_migrates_legacy_entry_to_encrypted_on_next_write(tmp_path): + """A legacy plaintext entry gets encrypted the next time it is written.""" + path = tmp_path / "store.json" + entry = _entry(token=_token(access="PLAINACCESS", refresh="PLAINREFRESH")) + path.write_text(json.dumps({"https://mcp.linear.app": json.loads(entry.model_dump_json())})) + + store = OAuthTokenStore(path=path) + store.update_token( + "https://mcp.linear.app/mcp", _token(access="PLAINACCESS", refresh="PLAINREFRESH"), expires_at=None + ) + + assert b"PLAINACCESS" not in path.read_bytes() + assert store.get("https://mcp.linear.app/mcp").token.access_token == "PLAINACCESS" + + +def test_store_get_returns_none_when_encryption_key_is_lost(tmp_path): + """If the key file is lost/rotated, an undecryptable access token must not + be handed back as if it were a real credential.""" + path = tmp_path / "store.json" + store = OAuthTokenStore(path=path) + store.put("https://mcp.linear.app/mcp", _entry(token=_token(access="SECRETACCESS"))) + + key_path = path.parent / "store.key" + key_path.write_bytes(oauth_store.Fernet.generate_key()) # simulate a lost/rotated key + + assert store.get("https://mcp.linear.app/mcp") is None + + def test_update_token_preserves_refresh_when_omitted(tmp_path): store = OAuthTokenStore(path=tmp_path / "store.json") store.put("https://mcp.linear.app/mcp", _entry(token=_token(access="old", refresh="orig"))) diff --git a/uv.lock b/uv.lock index 7559ff46..fe5ee2cf 100644 --- a/uv.lock +++ b/uv.lock @@ -254,6 +254,116 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -498,6 +608,63 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + [[package]] name = "detect-secrets" version = "1.5.0" @@ -1124,6 +1291,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -2070,6 +2246,7 @@ version = "0.6.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, + { name = "cryptography" }, { name = "detect-secrets" }, { name = "lark" }, { name = "mcp", extra = ["cli"] }, @@ -2100,6 +2277,7 @@ test = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.14.3" }, + { name = "cryptography", specifier = ">=42.0.0" }, { name = "detect-secrets", specifier = ">=1.5.0" }, { name = "lark", specifier = ">=1.1.9" }, { name = "mcp", extras = ["cli"], specifier = "==1.28.1" },