diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 00000000..3c1b5de6 --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,35 @@ +name: gitleaks + +# Block PRs that introduce hardcoded credentials. Runs the upstream gitleaks +# action against the diff (PR) or full history (push to main). False +# positives in the working tree are caught by the gitleaks-action's own +# allowlist mechanism — extend .gitleaks.toml at repo root rather than +# editing this workflow. + +on: + pull_request: + branches: ['**'] + push: + branches: [main, 'eric/**', 'haik/**', 'arnav/**'] + +permissions: + contents: read + pull-requests: read + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Full history needed so gitleaks can scan all new commits in a PR. + fetch-depth: 0 + + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Treat any high-confidence finding as a hard fail. + GITLEAKS_ENABLE_UPLOAD_ARTIFACT: 'true' + GITLEAKS_ENABLE_SUMMARY: 'true' diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index f1a759f8..c26130a1 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -19,6 +19,10 @@ name: Release (Windows) # AZURE_SIGNING_CERT_PROFILE Mist-Windows-Signing # GOOGLE_OAUTH_CLIENT_ID shipped in production .env (Google OAuth) # GOOGLE_OAUTH_CLIENT_SECRET shipped in production .env (Google OAuth) +# v1.0.29 cloud-proxied the OAuth flow itself, +# but the bundled google_workspace_mcp still +# requires CLIENT_SECRET at startup. v1.0.30 +# plans to remove this dependency. on: push: diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..5437f1d5 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,34 @@ +# gitleaks config — see .github/workflows/gitleaks.yml +# Extend the default rule set rather than overriding it; allowlists below +# cover known-safe placeholders and historical paths still in git history +# that are slated for redaction in a separate filter-repo pass. + +[extend] +useDefault = true + +[allowlist] +description = "Project allowlist for gitleaks" + +# Frontend Settings.tsx documents the Anthropic API key prefix in onboarding +# copy ("It will start with sk-ant-api03-…") — that's instructional text, not +# a real key. Path-scoped so an actual leak elsewhere in the same file would +# still trip. +paths = [ + '''frontend/src/app/pages/Settings/Settings\.tsx''', + # Vendored Python venv — never our code. + '''backend/\.venv/.*''', + # Vendored MCP server bundles (esbuild output of upstream npm packages). + '''backend/mcp-bundles/.*''', + # Bundled npm 9router (fetched from npm at build time; if a leak shows up + # here, the fix is to bump ROUTER_VERSION in scripts/fetch-router.sh, not + # to allowlist it — but until then, don't block our own PRs on it). + '''electron/build-staging/router/.*''', + # Local-only operator scratch dir, never tracked but defensive. + '''\.account-factory/.*''', +] + +regexes = [ + # Documentation/onboarding copy that talks about secret formats. + '''sk-ant-api03-…''', + '''sk-ant-\.\.\.''', +] diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index ba0cfd52..8b9386fb 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -4,7 +4,6 @@ import json import os import re import logging -import secrets import shutil import sys import time @@ -42,28 +41,10 @@ async def tools_lib_lifespan(): tools_lib = SubApp("tools", tools_lib_lifespan) -# Most providers go through a small HTTP claim handoff. Google uses a direct -# local callback. Both flows return an auto-closing HTML page when done. - -# Google OAuth (local callback flow). -GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" -GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +# All providers go through the Fly cloud-proxy claim handoff. The +# v1.0.28 local Google callback was retired in v1.0.29 once the prod +# Google OAuth client added the cloud's redirect URI. GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" -GOOGLE_SCOPES = [ - "openid", - "email", - "profile", - "https://www.googleapis.com/auth/gmail.modify", - "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/drive", - "https://www.googleapis.com/auth/documents", - "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/presentations", -] - -# Per-flow state for the local Google OAuth callback. Keyed by the OAuth -# `state` param; value is {tool_id} (PKCE verifier not used for Google). -_pending_oauth: dict[str, dict] = {} def _load_all() -> list[ToolDefinition]: @@ -146,77 +127,6 @@ async def list_tools(): return {"tools": [t.model_dump() for t in _load_all()]} -@tools_lib.router.get("/oauth/callback") -async def oauth_callback(code: str = Query(...), state: str = Query("")): - """Local Google OAuth callback (v1.0.25 flow, Google-only in v1.0.26). - - Notion / Airtable / HubSpot / Discord all flow through the cloud and - land at /oauth/cloud-claim instead. This endpoint stays Google-only - because the production Google OAuth client has localhost registered - and the user doesn't control the console to add a cloud URL. - """ - pending = _pending_oauth.pop(state, None) - if not pending: - return HTMLResponse( - "

Invalid OAuth state

", - status_code=400, - ) - - tool_id = pending["tool_id"] if isinstance(pending, dict) else pending - tool = _load(tool_id) - - client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "") - client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "") - if not client_id or not client_secret: - return HTMLResponse( - "

Google OAuth not configured

" - "GOOGLE_OAUTH_CLIENT_ID/SECRET missing from .env

", - status_code=500, - ) - _port = os.environ.get("OPENSWARM_PORT", "8324") - redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback" - - async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.post(GOOGLE_TOKEN_URL, data={ - "code": code, - "client_id": client_id, - "client_secret": client_secret, - "redirect_uri": redirect_uri, - "grant_type": "authorization_code", - }) - if resp.status_code != 200: - logger.warning("Google OAuth token exchange failed: %s", resp.text[:240]) - return HTMLResponse( - f"

Token exchange failed

{resp.text}
", - status_code=400, - ) - - tokens = resp.json() - access_token = tokens.get("access_token", "") - tool.oauth_tokens = { - "access_token": access_token, - "refresh_token": tokens.get("refresh_token", ""), - "token_expiry": time.time() + tokens.get("expires_in", 3600), - } - tool.auth_type = "oauth2" - tool.auth_status = "connected" - - if access_token: - try: - async with httpx.AsyncClient(timeout=10.0) as info_client: - info_resp = await info_client.get( - GOOGLE_USERINFO_URL, - headers={"Authorization": f"Bearer {access_token}"}, - ) - if info_resp.status_code == 200: - tool.connected_account_email = info_resp.json().get("email") - except Exception as e: - logger.warning("Failed to fetch Google userinfo: %s", e) - - _save(tool) - return _connected_html() - - def _connected_html() -> HTMLResponse: """v1.0.25-style auto-close page. Same markup so the UX is unchanged.""" return HTMLResponse(""" @@ -381,6 +291,15 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]: env["PRIVATE_APP_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"] if tool.oauth_tokens.get("refresh_token"): env["GOOGLE_WORKSPACE_REFRESH_TOKEN"] = tool.oauth_tokens["refresh_token"] + # google_workspace_mcp's auth/gauth.py requires all three of + # CLIENT_ID / CLIENT_SECRET / REFRESH_TOKEN at startup and does + # its own token refresh per API call; it ignores any + # pre-refreshed access_token. v1.0.29's cloud-proxy migration + # closes the OAuth-flow secret exposure, but the MCP still + # needs the local secret here. v1.0.30 should either fork the + # MCP to point token_uri at our cloud refresh proxy, or replace + # it with a thin in-house Gmail/Drive/Calendar wrapper that + # consumes a pre-refreshed access_token. client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "") client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "") if client_id: @@ -1099,14 +1018,19 @@ async def oauth_disconnect(tool_id: str): return {"ok": True, "tool": tool.model_dump()} -# Tool name → provider key for the OAuth helper service. Google is not in -# this map (it uses the direct local callback); anything else falls back to -# the local Google flow. +# Tool name → provider key for the OAuth helper service. All providers go +# through the Fly cloud-proxy so client_secret values never ship inside the +# desktop binary. v1.0.28 was the last release that used a local Google +# callback with the client_secret in backend/.env. _TOOL_NAME_TO_PROVIDER = { "airtable": "airtable", "hubspot": "hubspot", "discord": "discord", "notion": "notion", + # Built-in Google tool's name is "Google Workspace"; accept the bare + # "google" alias too for forward compatibility. + "google workspace": "google", + "google": "google", } @@ -1116,45 +1040,27 @@ def _proxied_provider_for(tool: ToolDefinition) -> Optional[str]: @tools_lib.router.post("/{tool_id}/oauth/start") async def oauth_start(tool_id: str): - """Return the OAuth start URL for this tool.""" + """Return the OAuth start URL for this tool. All built-in providers + proxy through Fly so client_secret values stay server-side.""" tool = _load(tool_id) proxied = _proxied_provider_for(tool) - _port = os.environ.get("OPENSWARM_PORT", "8324") - - if proxied: - from backend.config.install_id import get_install_id - install_id = get_install_id() - params = { - "install_id": install_id, - "tool_id": tool_id, - "local_port": _port, - } - auth_url = ( - f"{OPENSWARM_OAUTH_BASE_URL}/api/oauth/{proxied}/start?" - f"{urlencode(params)}" - ) - return {"auth_url": auth_url} - - # Local Google flow. State is a one-shot CSRF nonce keyed to the tool. - client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "") - if not client_id: + if not proxied: raise HTTPException( status_code=400, - detail="GOOGLE_OAUTH_CLIENT_ID not set in backend .env", + detail=f"No OAuth flow registered for tool '{tool.name}'.", ) - state = secrets.token_urlsafe(24) - _pending_oauth[state] = {"tool_id": tool_id} - redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback" + from backend.config.install_id import get_install_id + install_id = get_install_id() + _port = os.environ.get("OPENSWARM_PORT", "8324") params = { - "client_id": client_id, - "redirect_uri": redirect_uri, - "response_type": "code", - "scope": " ".join(GOOGLE_SCOPES), - "access_type": "offline", - "prompt": "consent", - "state": state, + "install_id": install_id, + "tool_id": tool_id, + "local_port": _port, } - auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}" + auth_url = ( + f"{OPENSWARM_OAUTH_BASE_URL}/api/oauth/{proxied}/start?" + f"{urlencode(params)}" + ) return {"auth_url": auth_url} @@ -1207,6 +1113,20 @@ async def oauth_cloud_claim( data = resp.json() tokens = data.get("tokens", {}) or {} tool = _load(tool_id) + # Google's token endpoint doesn't include the user's email — fetch it + # from userinfo so the UI can show "you connected ericzeng@gmail.com" + # rather than the generic "Google account" placeholder. + if tool.name.lower() == "google" and tokens.get("access_token") and not tokens.get("email"): + try: + async with httpx.AsyncClient(timeout=10.0) as info_client: + info_resp = await info_client.get( + GOOGLE_USERINFO_URL, + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + if info_resp.status_code == 200: + tokens["email"] = info_resp.json().get("email") or "" + except Exception as e: + logger.warning("Google userinfo lookup post-claim failed: %s", e) _persist_cloud_tokens(tool, tokens) _save(tool) return _connected_html() @@ -1242,7 +1162,8 @@ def _persist_cloud_tokens(tool: ToolDefinition, tokens: dict) -> None: "token_expiry": time.time() + (tokens.get("expires_in") or 3600), } tool.connected_account_email = ( - tokens.get("hub_domain") # HubSpot + tokens.get("email") # Google (post-userinfo enrichment) + or tokens.get("hub_domain") # HubSpot or tokens.get("workspace_name") or f"{tool.name} account" ) @@ -1302,68 +1223,13 @@ async def _refresh_via_proxy(provider: str, tool: ToolDefinition, default_expiry async def refresh_google_token(tool: ToolDefinition) -> Optional[str]: - """Refresh an expired Google OAuth token using the local client_secret. + """Refresh an expired Google access_token via the Fly cloud-proxy. - Google stays on the v1.0.25 local flow because we don't control the - Google Cloud Console for the production OAuth client and can't add the - cloud's redirect URI. The client_secret ships in the production .env — - standard "public OAuth app" pattern. + The client_secret never leaves Fly — desktop only POSTs the + refresh_token. Same pattern as Airtable/HubSpot. Pre-v1.0.29 builds + held the secret in their bundled .env; v1.0.29 removed it. """ - if tool.auth_type != "oauth2": - return None - refresh_token = tool.oauth_tokens.get("refresh_token") - if not refresh_token: - return None - expiry = tool.oauth_tokens.get("token_expiry", 0) - if time.time() < expiry - 60: - return tool.oauth_tokens.get("access_token") - - client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "") - client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "") - if not client_id or not client_secret: - return None - - try: - async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.post(GOOGLE_TOKEN_URL, data={ - "client_id": client_id, - "client_secret": client_secret, - "refresh_token": refresh_token, - "grant_type": "refresh_token", - }) - if resp.status_code != 200: - logger.warning("Google token refresh failed: HTTP %d %s", resp.status_code, resp.text[:200]) - # 400/401 → refresh_token is invalid for this client (expected - # for users upgrading across a client_id change, or after the - # user revoked access at myaccount.google.com). Mark the tool - # as expired so the UI surfaces a Reconnect prompt instead of - # silently failing every Google MCP call. - if resp.status_code in (400, 401): - tool.auth_status = "expired" - _save(tool) - return None - data = resp.json() - new_token = data.get("access_token", "") - if not new_token: - return None - tool.oauth_tokens["access_token"] = new_token - tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 3600) - if not tool.connected_account_email: - try: - async with httpx.AsyncClient(timeout=10.0) as info_client: - info_resp = await info_client.get( - GOOGLE_USERINFO_URL, - headers={"Authorization": f"Bearer {new_token}"}, - ) - if info_resp.status_code == 200: - tool.connected_account_email = info_resp.json().get("email") - except Exception: - pass - _save(tool) - return new_token - except Exception as e: - logger.warning("Google token refresh exception for tool %s: %s", tool.id, e) - return None + return await _refresh_via_proxy("google", tool, default_expiry=3600) async def refresh_airtable_token(tool: ToolDefinition) -> Optional[str]: diff --git a/scripts/build-app-win.ps1 b/scripts/build-app-win.ps1 index 009904f3..f92fbba9 100644 --- a/scripts/build-app-win.ps1 +++ b/scripts/build-app-win.ps1 @@ -330,7 +330,10 @@ Copy-Excluded ` (Join-Path $ProjectRoot 'backend') (Join-Path $Staging 'backend') ` @{ Dirs = @('__pycache__','.venv','tools','tests'); Files = @('*.pyc','.env','.env.*') } -# Production .env: only the OAuth helper base URL + local Google credentials. +# Production .env: OAuth helper base URL + Google credentials. See +# scripts/build-app.sh for the rationale; v1.0.29 cloud-proxied the OAuth flow, +# but the bundled google_workspace_mcp still needs CLIENT_SECRET at startup. +# v1.0.30 plans to fork or replace that MCP and drop the secret here. $ShipOauthBaseUrl = if ($env:OPENSWARM_OAUTH_BASE_URL_OVERRIDE) { $env:OPENSWARM_OAUTH_BASE_URL_OVERRIDE } else { @@ -345,12 +348,12 @@ if (-not $GoogleClientIdShip -or -not $GoogleClientSecretShip) { $ShipEnvPath = Join-Path $Staging 'backend\.env' New-Item -ItemType Directory -Force -Path (Split-Path $ShipEnvPath -Parent) | Out-Null @( - "# OAuth helper base URL + local Google OAuth credentials.", + "# OAuth helper base URL + Google OAuth credentials.", "OPENSWARM_OAUTH_BASE_URL=$ShipOauthBaseUrl", "GOOGLE_OAUTH_CLIENT_ID=$GoogleClientIdShip", "GOOGLE_OAUTH_CLIENT_SECRET=$GoogleClientSecretShip" ) | Set-Content -Path $ShipEnvPath -Write-Host "Staged production .env: OPENSWARM_OAUTH_BASE_URL + Google client_id/secret" +Write-Host "Staged production .env" New-Item -ItemType Directory -Force -Path (Join-Path $Staging 'backend\data\tools') | Out-Null Copy-Excluded ` diff --git a/scripts/build-app.sh b/scripts/build-app.sh index c50534c5..4c4e9145 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -358,10 +358,13 @@ rsync -a \ --exclude='.env' --exclude='.env.*' --exclude='**/.env' --exclude='**/.env.*' \ "$PROJECT_ROOT/backend/" "$STAGING_DIR/backend/" -# Production .env: only the OAuth helper base URL + the local Google -# OAuth credentials (Google's standard "public OAuth app" pattern). -# Signing keys, dev-only vars, and provider client_secrets for everything -# else are intentionally not shipped. +# Production .env: OAuth helper base URL + Google client_id and client_secret. +# v1.0.29 moved the *OAuth flow* (auth-code exchange + refresh) to the Fly +# cloud-proxy, so the OAuth flow itself no longer reads client_secret on the +# desktop. But the bundled google_workspace_mcp Python package still requires +# CLIENT_SECRET at startup to do its own token refresh per Google API call +# (see backend/apps/tools_lib/tools_lib.py for the deferred-fix note). +# Until we fork or replace that MCP in v1.0.30, the secret still ships here. SHIP_OAUTH_BASE_URL="${OPENSWARM_OAUTH_BASE_URL_OVERRIDE:-https://api.openswarm.com}" GOOGLE_CLIENT_ID_SHIP="${GOOGLE_OAUTH_CLIENT_ID:-}" GOOGLE_CLIENT_SECRET_SHIP="${GOOGLE_OAUTH_CLIENT_SECRET:-}" @@ -371,7 +374,9 @@ if [[ -z "$GOOGLE_CLIENT_ID_SHIP" || -z "$GOOGLE_CLIENT_SECRET_SHIP" ]]; then fi mkdir -p "$STAGING_DIR/backend" cat > "$STAGING_DIR/backend/.env" <