diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..41edd987 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: swift + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: bundler + directory: /ios/CodexPhone + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..9811eea1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,54 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "23 4 * * 2" + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: actions + os: ubuntu-latest + build-mode: none + - language: python + os: ubuntu-latest + build-mode: none + - language: ruby + os: ubuntu-latest + build-mode: none + - language: swift + os: macos-latest + build-mode: autobuild + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Autobuild + if: matrix.build-mode == 'autobuild' + uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + + - name: Analyze + uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 diff --git a/Sources/CodexAccountSwitcher/main.swift b/Sources/CodexAccountSwitcher/main.swift index 116b3c58..62e5254d 100644 --- a/Sources/CodexAccountSwitcher/main.swift +++ b/Sources/CodexAccountSwitcher/main.swift @@ -2569,8 +2569,12 @@ enum SwitchError: LocalizedError { } enum CodePilotGatewayHealthProbe { - static func request() -> URLRequest { - URLRequest(url: URL(string: "http://127.0.0.1:18790/api/health")!) + static func request(token: String?) -> URLRequest { + var request = URLRequest(url: URL(string: "http://127.0.0.1:18790/api/health")!) + if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + return request } static func requirement(from data: Data?) -> CodePilotSetupRequirement { @@ -2725,7 +2729,12 @@ struct CodePilotSetupStatus { } private static func gatewayHealthStatus() -> CodePilotGatewayHealthStatus { - CodePilotGatewayHealthProbe.status(from: synchronousData(for: CodePilotGatewayHealthProbe.request())) + let tokenPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex-account-switcher/phone-gateway-token") + let token = try? String(contentsOf: tokenPath, encoding: .utf8) + return CodePilotGatewayHealthProbe.status( + from: synchronousData(for: CodePilotGatewayHealthProbe.request(token: token)) + ) } private static func synchronousData(for request: URLRequest) -> Data? { diff --git a/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift b/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift index f92353c7..6fdbf924 100644 --- a/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift +++ b/Tests/CodexAccountSwitcherTests/SetupStatusTests.swift @@ -79,7 +79,7 @@ final class SetupStatusTests: XCTestCase { } func testGatewayHealthProbeUsesPublicHealthWithoutBearerToken() { - let request = CodePilotGatewayHealthProbe.request() + let request = CodePilotGatewayHealthProbe.request(token: nil) XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) let runningPayload = #"{"gateway":{"running":true}}"#.data(using: .utf8) @@ -90,6 +90,12 @@ final class SetupStatusTests: XCTestCase { XCTAssertEqual(CodePilotGatewayHealthProbe.requirement(from: Data("not json".utf8)), .gatewayStopped) } + func testGatewayHealthProbeUsesTokenForPrivateDiagnostics() { + let request = CodePilotGatewayHealthProbe.request(token: " private-token\n") + + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer private-token") + } + func testGatewayHealthProbeReportsNotificationReadiness() { let configuredPayload = #"{"gateway":{"running":true},"notifications":{"configured":true}}"#.data(using: .utf8) XCTAssertEqual( diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 023e3609..c9b1d55e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -25,7 +25,7 @@ Recommended: The iOS client refuses non-HTTPS gateway URLs except for `localhost`, `::1`, and syntactically valid `127.x.x.x` loopback addresses. DNS names that merely begin with `127.` are not loopback exceptions and still require HTTPS. The client also rejects gateway URLs containing user information, query strings, or fragments, and credential-bearing requests do not follow cross-origin redirects. -The gateway health response is intentionally public-safe, but it is still operational metadata. Treat the gateway URL and token as private. +The unauthenticated gateway health response contains only a liveness flag. Version, account, job, notification, and remote-access diagnostics are returned only when the request includes the iOS connection token. Treat the gateway URL and token as private. Do not place infrastructure or connector management tokens in `phone-gateway.env`. The gateway service deliberately does not load `SUPABASE_ACCESS_TOKEN`, and gateway-launched Codex processes scrub gateway-only credentials from their environment. Configure sensitive connectors in a separately trusted local Codex session instead of exposing their bearer tokens to remotely initiated turns. diff --git a/gateway/codex_phone_gateway.py b/gateway/codex_phone_gateway.py index c8f3babb..fb25daa3 100755 --- a/gateway/codex_phone_gateway.py +++ b/gateway/codex_phone_gateway.py @@ -2303,6 +2303,13 @@ def app_server_auth_status(self) -> dict: } def public_health(self) -> dict: + return { + "gateway": { + "running": True, + }, + } + + def diagnostic_health(self) -> dict: remote_desktop_status = {"available": False} remote_desktop = self.remote_desktop_gateway if remote_desktop is not None: @@ -4873,7 +4880,10 @@ def run_turn_exec( class Handler(BaseHTTPRequestHandler): - server_version = "CodexPhoneGateway/0.1" + server_version = "CodePilotGateway" + + def version_string(self) -> str: + return self.server_version def state(self) -> GatewayState: return self.server.state @@ -4888,9 +4898,7 @@ def end_headers(self): super().end_headers() def authenticate(self) -> bool: - expected = self.state().token - header = self.headers.get("authorization", "") - if secrets.compare_digest(header, f"Bearer {expected}"): + if self.is_authenticated(): return True json_error( self, @@ -4901,6 +4909,11 @@ def authenticate(self) -> bool: ) return False + def is_authenticated(self) -> bool: + expected = self.state().token + header = self.headers.get("authorization", "") + return secrets.compare_digest(header, f"Bearer {expected}") + def do_GET(self): parsed = urllib.parse.urlparse(self.path) path = parsed.path.rstrip("/") or "/" @@ -4917,21 +4930,26 @@ def do_GET(self): self, 404, "local_web_unavailable", - str(exc), + "Local web session not found or expired", "Open the localhost link again from CodePilot; local web sessions expire after a short time.", ) - except Exception as exc: + except Exception: json_error( self, 502, "local_web_unavailable", - str(exc), + "The selected local web server is unavailable", "Make sure the local web server is running on the Mac, then reopen the link.", ) return if path == "/health" or path == "/api/health": - json_response(self, 200, self.state().public_health()) + payload = ( + self.state().diagnostic_health() + if self.is_authenticated() + else self.state().public_health() + ) + json_response(self, 200, payload) return if not self.authenticate(): diff --git a/gateway/test_codex_phone_gateway.py b/gateway/test_codex_phone_gateway.py index f6bc3e8c..52c2802d 100644 --- a/gateway/test_codex_phone_gateway.py +++ b/gateway/test_codex_phone_gateway.py @@ -6,9 +6,9 @@ import subprocess import tempfile import threading +import urllib.error import urllib.request import unittest -import urllib.request from unittest import mock from pathlib import Path @@ -1254,7 +1254,7 @@ def process_factory(arguments, **_kwargs): finally: gateway.JOBS.pop(job_id, None) - def test_public_health_exposes_safe_gateway_status(self): + def test_public_health_exposes_only_non_identifying_liveness(self): with tempfile.TemporaryDirectory() as tmp: original_switcher_home = gateway.DEFAULT_SWITCHER_HOME switcher_home = Path(tmp) / "switcher" @@ -1271,12 +1271,31 @@ def test_public_health_exposes_safe_gateway_status(self): gateway.DEFAULT_SWITCHER_HOME = original_switcher_home self.assertTrue(health["gateway"]["running"]) + self.assertEqual(set(health), {"gateway"}) + self.assertEqual(set(health["gateway"]), {"running"}) + self.assertNotIn("secret-token", json.dumps(health)) + + def test_diagnostic_health_retains_authenticated_setup_status(self): + with tempfile.TemporaryDirectory() as tmp: + original_switcher_home = gateway.DEFAULT_SWITCHER_HOME + switcher_home = Path(tmp) / "switcher" + codex_home = Path(tmp) / "codex" + try: + gateway.DEFAULT_SWITCHER_HOME = switcher_home + switcher_home.mkdir() + codex_home.mkdir() + (switcher_home / "active-account.txt").write_text("main", encoding="utf-8") + state = GatewayState(codex_home, "secret-token", Path("/missing-codex"), False) + + health = state.diagnostic_health() + finally: + gateway.DEFAULT_SWITCHER_HOME = original_switcher_home + self.assertEqual(health["accounts"]["active"], "main") self.assertIn("auth", health["accounts"]) self.assertIn("notifications", health) self.assertIn("remoteDesktop", health) self.assertIn("localWeb", health) - self.assertNotIn("secret-token", json.dumps(health)) def test_health_endpoint_is_available_without_bearer_token(self): with tempfile.TemporaryDirectory() as tmp: @@ -1298,6 +1317,7 @@ def test_health_endpoint_is_available_without_bearer_token(self): url = f"http://127.0.0.1:{server.server_address[1]}/api/health" with urllib.request.urlopen(url, timeout=2) as response: status = response.status + server_header = response.headers.get("Server") payload = json.loads(response.read().decode("utf-8")) finally: if server is not None: @@ -1308,9 +1328,69 @@ def test_health_endpoint_is_available_without_bearer_token(self): gateway.DEFAULT_SWITCHER_HOME = original_switcher_home self.assertEqual(status, 200) + self.assertEqual(server_header, "CodePilotGateway") self.assertTrue(payload["gateway"]["running"]) + self.assertEqual(set(payload), {"gateway"}) + self.assertEqual(set(payload["gateway"]), {"running"}) self.assertNotIn("secret-token", json.dumps(payload)) + def test_local_web_endpoint_hides_backend_failure_details(self): + state = GatewayState( + Path("/tmp/codex"), + "secret-token", + Path("/missing-codex"), + False, + local_web_fetcher=mock.Mock(side_effect=RuntimeError("private backend detail")), + ) + session = state.start_local_web_session("http://localhost:3000/") + server = gateway.GatewayServer(("127.0.0.1", 0), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.server_address[1]}{session['path']}" + with self.assertRaises(urllib.error.HTTPError) as raised: + urllib.request.urlopen(url, timeout=2) + payload = json.loads(raised.exception.read().decode("utf-8")) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + self.assertEqual(raised.exception.code, 502) + self.assertEqual(payload["error"]["message"], "The selected local web server is unavailable") + self.assertNotIn("private backend detail", json.dumps(payload)) + + def test_health_endpoint_returns_diagnostics_with_bearer_token(self): + with tempfile.TemporaryDirectory() as tmp: + original_switcher_home = gateway.DEFAULT_SWITCHER_HOME + switcher_home = Path(tmp) / "switcher" + codex_home = Path(tmp) / "codex" + server = None + thread = None + try: + gateway.DEFAULT_SWITCHER_HOME = switcher_home + switcher_home.mkdir() + codex_home.mkdir() + state = GatewayState(codex_home, "secret-token", Path("/missing-codex"), False) + server = gateway.GatewayServer(("127.0.0.1", 0), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + url = f"http://127.0.0.1:{server.server_address[1]}/api/health" + request = urllib.request.Request(url, headers={"Authorization": "Bearer secret-token"}) + with urllib.request.urlopen(request, timeout=2) as response: + payload = json.loads(response.read().decode("utf-8")) + finally: + if server is not None: + server.shutdown() + server.server_close() + if thread is not None: + thread.join(timeout=2) + gateway.DEFAULT_SWITCHER_HOME = original_switcher_home + + self.assertIn("accounts", payload) + self.assertIn("notifications", payload) + def test_error_payload_has_stable_code_message_and_recovery(self): payload = gateway.error_payload( "local_web_invalid_target", diff --git a/ops/agents/escalations/security-scan.md b/ops/agents/escalations/security-scan.md index 32488095..2e8a37a7 100644 --- a/ops/agents/escalations/security-scan.md +++ b/ops/agents/escalations/security-scan.md @@ -6,11 +6,12 @@ Detailed security findings are intentionally not stored in the public repository Before public launch, maintainer coordination is still required for: -- Reviewing and resolving the open GitHub secret-scanning alert through the private security channel, including revocation or rotation where applicable. - A confidential review before any currently disabled privileged remote-access feature is re-enabled. - A credential and edge-access architecture review for the remotely reachable gateway. - Sanitizing historical private identifiers and commit metadata, followed by coordinated clone migration. -- Enabling repository dependency alerts and reviewing the initial results. +- Protecting the default branch with required reviews and passing checks. +- Enabling private vulnerability reporting before inviting public security reports. +- Enabling dependency alerts and security updates, plus non-provider secret patterns and validity checks, then reviewing the initial Dependabot, CodeQL, and secret-scanning results. - Shipping pending credential-storage changes through an authorized build channel and rotating affected beta credentials. Keep fail-closed controls enabled until the corresponding private review is complete. Do not copy private findings into public issues or pull requests.