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/docs/scanning.md b/docs/scanning.md index 135582ee..4ec828a9 100644 --- a/docs/scanning.md +++ b/docs/scanning.md @@ -65,6 +65,57 @@ 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. 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 -- +`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. + +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/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/cli.py b/src/agent_scan/cli.py index 1f7254c4..df2300e7 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 @@ -905,6 +977,16 @@ 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) + 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" + ) + # GUARD command guard_parser = subparsers.add_parser( "guard", @@ -1032,6 +1114,8 @@ def main(): elif args.command == "evo": asyncio.run(evo(args)) sys.exit(0) + elif args.command == "mcp-auth": + sys.exit(asyncio.run(mcp_auth(args))) elif args.command == "guard": from agent_scan.guard import run_guard @@ -1108,6 +1192,69 @@ def _should_show_analysis_results(args) -> bool: ) +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 + + 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, + ) + 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: + print_server_not_found(server_arg, remote, remote_only=True) + return 1 + targets = [(server_arg, remote[server_arg])] + else: + rich.print("[bold red]Specify a server name, --url , or --all-unauthenticated.[/bold red]") + return 1 + + if not targets: + rich.print("No remote MCP servers to authenticate.") + 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) + 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}") + all_ok = False + return 0 if all_ok else 1 + + 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. @@ -1131,17 +1278,33 @@ 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) + # 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) + 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. @@ -1154,7 +1317,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: list[InspectedPath] = [] + 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/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/debug_mcp_auth.py b/src/agent_scan/debug_mcp_auth.py new file mode 100644 index 00000000..bb18815e --- /dev/null +++ b/src/agent_scan/debug_mcp_auth.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import json +import logging +import sys + +import rich + +from agent_scan.oauth_flow import AuthResult, authenticate_server +from agent_scan.oauth_store import OAuthTokenStore, 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: + # 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: + 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 78db4be8..cc1b4555 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,13 +31,59 @@ 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 +from agent_scan.utils import expand_env_vars, resolve_command_and_args # Set up logger for this module 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 +91,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 +121,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, + 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, ) elif isinstance(server_config, RemoteServer) and server_config.type == "http": logger.debug( @@ -108,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, ) @@ -119,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 @@ -238,6 +267,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) @@ -271,20 +301,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: @@ -321,6 +359,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/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..8566d67d --- /dev/null +++ b/src/agent_scan/oauth_flow.py @@ -0,0 +1,302 @@ +"""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, # 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] + 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. + """ + # 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), + ("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: + 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)) + 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..242e6da9 --- /dev/null +++ b/src/agent_scan/oauth_store.py @@ -0,0 +1,628 @@ +"""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 ipaddress +import json +import logging +import os +import time +from pathlib import Path +from typing import TYPE_CHECKING +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 + +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 _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. + + 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. + """ + split = urlsplit(url) + path = split.path.rstrip("/") + if path.endswith("/sse"): + path = path[: -len("/sse")] + elif path.endswith("/mcp"): + path = path[: -len("/mcp")] + 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. + + ``~/.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; 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``. + + 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 _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: + 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 {} + 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 + # 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. + # 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. + 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 + # 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(encrypted, f, indent=2, default=str) + f.flush() + os.fsync(f.fileno()) + 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) -> 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 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: + """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. + + 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): + self._store = store + self._server_url = server_url + + async def get_tokens(self) -> OAuthToken | None: + entry = self._store.get(self._server_url) + 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 + 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 + 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=client_secret, + redirect_uris=entry.redirect_uris or [_PLACEHOLDER_REDIRECT_URI], + token_endpoint_auth_method="client_secret_post" if 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 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(): + 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 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, + "client_id": entry.client_id, + } + if entry.client_secret: + data["client_secret"] = entry.client_secret + headers = {"Content-Type": "application/x-www-form-urlencoded"} + try: + # 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 + 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/pipelines.py b/src/agent_scan/pipelines.py index 6a697be0..6875ccdc 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 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. + """ + for client in clients: + 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 + 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( + 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/src/agent_scan/redact.py b/src/agent_scan/redact.py index aaaf150c..457922ab 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"bearer\s+[\w.\-~+/]+=*", re.IGNORECASE) + + +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/src/agent_scan/utils.py b/src/agent_scan/utils.py index 30323cc9..f82f4a68 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,41 @@ 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 (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 + + def _substitute(match: re.Match[str]) -> str: + name = match.group(1) + value = os.environ.get(name) + if not value: + logger.warning( + 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) + 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_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_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_debug_mcp_auth.py b/tests/unit/test_debug_mcp_auth.py new file mode 100644 index 00000000..28e334ee --- /dev/null +++ b/tests/unit/test_debug_mcp_auth.py @@ -0,0 +1,106 @@ +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" + + +@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_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_mcp_client.py b/tests/unit/test_mcp_client.py index a90f3e48..b3ebb286 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,94 @@ 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"} + assert called_params.args == server.args + # The parsed model itself must be untouched -- still the literal placeholder. + 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", [ @@ -240,3 +328,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_oauth_flow.py b/tests/unit/test_oauth_flow.py new file mode 100644 index 00000000..5084241e --- /dev/null +++ b/tests/unit/test_oauth_flow.py @@ -0,0 +1,168 @@ +"""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 + + +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 + + +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) + 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"] + + +@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 new file mode 100644 index 00000000..30d8216f --- /dev/null +++ b/tests/unit/test_oauth_store.py @@ -0,0 +1,514 @@ +"""Unit tests for the persistent OAuth token store (M1).""" + +import json +import os +import stat +import sys +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, + is_secure_token_url, + 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.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", + [ + ("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"), + # 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): + 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" + 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"] + + +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"))) + # 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" + + +@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 + 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): + # Recorded on the base class so subclass instances report here too. + _FakeAsyncClient.last_init_kwargs = kwargs + + 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" + + +@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 diff --git a/tests/unit/test_redact.py b/tests/unit/test_redact.py index e7cc1206..a610bd8e 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,27 @@ 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): + # 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 "NOT-A-REAL.tok-en_val+ue/==" 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_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 + assert redact_bearer_tokens("") == "" diff --git a/tests/unit/test_single_server_scan.py b/tests/unit/test_single_server_scan.py new file mode 100644 index 00000000..531e1eb0 --- /dev/null +++ b/tests/unit/test_single_server_scan.py @@ -0,0 +1,198 @@ +"""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) + + 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 + 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) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index d9f29527..5460eba3 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,58 @@ 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_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}"} + 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"} 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" },