diff --git a/internal/cmd/proxy.go b/internal/cmd/proxy.go index e9da1cea..bd3f1e85 100644 --- a/internal/cmd/proxy.go +++ b/internal/cmd/proxy.go @@ -3,6 +3,7 @@ package cmd import ( "context" "crypto/tls" + "encoding/json" "fmt" "net" "net/http" @@ -15,6 +16,7 @@ import ( "github.com/github/gh-aw-mcpg/internal/config" "github.com/github/gh-aw-mcpg/internal/difc" "github.com/github/gh-aw-mcpg/internal/envutil" + "github.com/github/gh-aw-mcpg/internal/githubhttp" "github.com/github/gh-aw-mcpg/internal/guard" "github.com/github/gh-aw-mcpg/internal/logger" "github.com/github/gh-aw-mcpg/internal/proxy" @@ -33,21 +35,22 @@ var tlsTrustEnvKeys = []string{ // Proxy subcommand flag variables var ( - proxyGuardWasm string - proxyPolicy string - proxyToken string - proxyListen string - proxyLogDir string - proxyWasmCacheDir string - proxyDIFCMode string - proxyAPIURL string - proxyTLS bool - proxyTLSDir string - proxyTrustedBots []string - proxyTrustedUsers []string - proxyOTLPEndpoint string - proxyOTLPService string - proxyOTLPSampleRate float64 + proxyGuardWasm string + proxyPolicy string + proxyToken string + proxyListen string + proxyLogDir string + proxyWasmCacheDir string + proxyDIFCMode string + proxyAPIURL string + proxyTLS bool + proxyTLSDir string + proxyTrustedBots []string + proxyTrustedUsers []string + proxyOTLPEndpoint string + proxyOTLPService string + proxyOTLPSampleRate float64 + proxyForcePublicRepo bool ) func init() { @@ -116,6 +119,7 @@ Local usage: cmd.Flags().StringVar(&proxyWasmCacheDir, "wasm-cache-dir", resolveWasmCacheDir(false, "", defaultProxyLogDir), "Directory for disk-backed wazero compilation cache (default: sibling of , named wazero-cache)") cmd.Flags().StringVar(&proxyDIFCMode, "guards-mode", difc.DefaultEnforcementMode(), "DIFC enforcement mode: strict, filter, propagate") cmd.Flags().StringVar(&proxyAPIURL, "github-api-url", "", "Upstream GitHub API URL (default: auto-derived from GITHUB_API_URL or GITHUB_SERVER_URL, falls back to https://api.github.com)") + cmd.Flags().BoolVar(&proxyForcePublicRepo, "force-public-repos", envutil.GetEnvBool(config.EnvForcePublicRepos, true), "When true (default), forces repos=\"public\" at runtime if the workflow repo is public. Set to false to disable.") cmd.Flags().BoolVar(&proxyTLS, "tls", false, "Enable HTTPS with auto-generated self-signed certificates") cmd.Flags().StringVar(&proxyTLSDir, "tls-dir", "", "Directory for TLS certificates (default: /proxy-tls)") cmd.Flags().StringSliceVar(&proxyTrustedBots, "trusted-bots", nil, "Additional trusted bot usernames (comma-separated, extends built-in list)") @@ -220,12 +224,20 @@ func runProxy(cmd *cobra.Command, args []string) error { logger.LogInfo("startup", "Upstream GitHub API URL: %s", apiURL) logProxyCmd.Printf("Resolved GitHub API URL: %s, explicit flag=%v", apiURL, proxyAPIURL != "") + // Defense-in-depth: force repos="public" when running in a public repository. + // This overrides the compiled policy to prevent agents from reading private + // repos, even if the compiler misconfigured the allow-only scope. + effectivePolicy := proxyPolicy + if effectivePolicy != "" { + effectivePolicy = proxyForcePublicReposIfNeeded(ctx, effectivePolicy, token, apiURL) + } + // Create the proxy server logProxyCmd.Printf("Creating proxy server: guard=%s, hasPolicy=%v, mode=%s, trustedBots=%d, trustedUsers=%d", - proxyGuardWasm, proxyPolicy != "", proxyDIFCMode, len(proxyTrustedBots), len(proxyTrustedUsers)) + proxyGuardWasm, effectivePolicy != "", proxyDIFCMode, len(proxyTrustedBots), len(proxyTrustedUsers)) proxySrv, err := proxy.New(ctx, proxy.Config{ WasmPath: proxyGuardWasm, - Policy: proxyPolicy, + Policy: effectivePolicy, GitHubToken: token, GitHubAPIURL: apiURL, DIFCMode: proxyDIFCMode, @@ -357,3 +369,79 @@ func clientAddr(addr string) string { } return addr } + +// proxyForcePublicReposIfNeeded checks if GITHUB_REPOSITORY is public and, if so, +// overrides the allow-only policy's repos field to "public". This prevents agents +// in public-repo workflows from reading private repos through the proxy. +// +// Skipped when: +// - --force-public-repos=false (or MCP_GATEWAY_FORCE_PUBLIC_REPOS=false) +// - GITHUB_REPOSITORY is not set +// - No GitHub token is available +// - The API call fails (fail-open) +// - The repository is not public +func proxyForcePublicReposIfNeeded(ctx context.Context, policyJSON, token, apiURL string) string { + if !proxyForcePublicRepo { + logProxyCmd.Print("forcePublicRepos: disabled by flag") + return policyJSON + } + + nwo := os.Getenv("GITHUB_REPOSITORY") + if nwo == "" { + logProxyCmd.Print("forcePublicRepos: GITHUB_REPOSITORY not set — skipping") + return policyJSON + } + + authToken := token + if authToken == "" { + authToken = envutil.LookupGitHubToken() + } + if authToken == "" { + logProxyCmd.Print("forcePublicRepos: no GitHub token available — skipping") + return policyJSON + } + + vis, err := githubhttp.FetchRepoVisibility(ctx, apiURL, nwo, "token "+authToken) + if err != nil { + logger.LogWarn("difc", "forcePublicRepos: failed to determine visibility for %s (fail-open): %v", nwo, err) + return policyJSON + } + + if vis != githubhttp.RepoVisibilityPublic { + logProxyCmd.Printf("forcePublicRepos: repo %s is %s — no override needed", nwo, vis) + return policyJSON + } + + // Repository is public — override policy to repos="public" + var policyMap map[string]interface{} + if err := json.Unmarshal([]byte(policyJSON), &policyMap); err != nil { + logger.LogWarn("difc", "forcePublicRepos: failed to parse policy JSON (using original): %v", err) + return policyJSON + } + + // Find the allow-only section (canonical or legacy key) + var allowOnly map[string]interface{} + if ao, ok := policyMap["allow-only"]; ok { + allowOnly, _ = ao.(map[string]interface{}) + } else if ao, ok := policyMap["allowonly"]; ok { + allowOnly, _ = ao.(map[string]interface{}) + } + + if allowOnly == nil { + policyMap["allow-only"] = map[string]interface{}{ + "repos": "public", + "min-integrity": "none", + } + } else { + allowOnly["repos"] = "public" + } + + overridden, err := json.Marshal(policyMap) + if err != nil { + logger.LogWarn("difc", "forcePublicRepos: failed to marshal overridden policy (using original): %v", err) + return policyJSON + } + + logger.LogWarn("difc", "FORCED REPOS=PUBLIC: workflow repo %s is public — overriding proxy allow-only scope to prevent private data reads", nwo) + return string(overridden) +} diff --git a/test/integration/proxy_force_public_repos_test.go b/test/integration/proxy_force_public_repos_test.go new file mode 100644 index 00000000..5b6b54c5 --- /dev/null +++ b/test/integration/proxy_force_public_repos_test.go @@ -0,0 +1,308 @@ +package integration + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// startProxyWithEnv starts the awmg proxy with additional args and env vars. +func startProxyWithEnv(t *testing.T, policyJSON string, port string, extraArgs []string, extraEnv []string) *proxyTestEnv { + t.Helper() + + binaryPath := findBinary(t) + wasmPath := findWasmGuard(t) + token := skipIfNoGitHubToken(t) + + logDir, err := os.MkdirTemp("", "awmg-proxy-force-public-*") + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + + listenAddr := "127.0.0.1:" + port + + args := []string{ + "proxy", + "--guard-wasm", wasmPath, + "--policy", policyJSON, + "--github-token", token, + "--listen", listenAddr, + "--log-dir", logDir, + "--guards-mode", "filter", + } + args = append(args, extraArgs...) + + cmd := exec.CommandContext(ctx, binaryPath, args...) + cmd.Env = append(os.Environ(), extraEnv...) + + env := &proxyTestEnv{ + cmd: cmd, + port: port, + baseURL: "http://" + listenAddr, + token: token, + cancel: cancel, + logDir: logDir, + } + + cmd.Stdout = &env.stdout + cmd.Stderr = &env.stderr + + err = cmd.Start() + require.NoError(t, err, "Failed to start proxy") + + healthURL := env.baseURL + "/api/v3/health" + if !waitForServer(t, healthURL, 15*time.Second) { + t.Logf("STDOUT: %s", env.stdout.String()) + t.Logf("STDERR: %s", env.stderr.String()) + t.Fatal("Proxy did not start in time") + } + + t.Logf("✓ Proxy started at %s", listenAddr) + return env +} + +// readLogs reads the proxy's mcp-gateway.log for assertion. +func (e *proxyTestEnv) readLogs(t *testing.T) string { + t.Helper() + logPath := e.logDir + "/mcp-gateway.log" + data, err := os.ReadFile(logPath) + if err != nil { + return "" + } + return string(data) +} + +// ghAPIWithStatus is a convenience wrapper for ghAPI that also returns body as string. +func (e *proxyTestEnv) ghAPIWithBody(t *testing.T, method, path string) (int, string) { + t.Helper() + url := e.baseURL + "/api/v3" + path + req, err := http.NewRequest(method, url, nil) + require.NoError(t, err) + req.Header.Set("Authorization", "token "+e.token) + req.Header.Set("Accept", "application/vnd.github+json") + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return resp.StatusCode, string(body) +} + +// ============================================================================ +// Test: Public repo workflow → forces repos="public" +// ============================================================================ + +// TestProxyForcePublicRepos_PublicWorkflowRepo verifies that when +// GITHUB_REPOSITORY identifies a public repo, the proxy overrides the +// allow-only policy to repos="public" — even if the compiled policy is +// more permissive (repos="all" or repos=["specific-repos"]). +func TestProxyForcePublicRepos_PublicWorkflowRepo(t *testing.T) { + if testing.Short() { + t.Skip("Skipping proxy integration test in short mode") + } + + // Permissive policy: repos="all" — would allow access to everything + policy := `{"allow-only":{"repos":"all","min-integrity":"none"}}` + + // Set GITHUB_REPOSITORY to a known public repo + env := startProxyWithEnv(t, policy, "18920", nil, []string{ + "GITHUB_REPOSITORY=octocat/Hello-World", + }) + defer env.stop(t) + + // Public repo should be accessible (octocat/Hello-World is public) + t.Run("PublicRepo_Allowed", func(t *testing.T) { + status, _ := env.ghAPIWithBody(t, "GET", "/repos/octocat/Hello-World/commits?per_page=1") + assert.Equal(t, 200, status, "Public repo should be accessible") + }) + + // Verify logs show the override was applied + t.Run("LogShowsOverride", func(t *testing.T) { + // Give a moment for logs to flush + time.Sleep(500 * time.Millisecond) + logs := env.readLogs(t) + stderr := env.stderr.String() + combined := logs + stderr + assert.Contains(t, combined, "FORCED REPOS=PUBLIC", + "Should log that repos=public was forced") + }) +} + +// ============================================================================ +// Test: --force-public-repos=false disables the override +// ============================================================================ + +// TestProxyForcePublicRepos_FlagDisabled verifies that passing +// --force-public-repos=false prevents the runtime override, even when +// GITHUB_REPOSITORY is a public repo. +func TestProxyForcePublicRepos_FlagDisabled(t *testing.T) { + if testing.Short() { + t.Skip("Skipping proxy integration test in short mode") + } + + // Permissive policy allowing a specific public repo + policy := `{"allow-only":{"repos":["octocat/hello-world"],"min-integrity":"none"}}` + + // Disable force-public-repos via flag + env := startProxyWithEnv(t, policy, "18921", + []string{"--force-public-repos=false"}, + []string{"GITHUB_REPOSITORY=octocat/Hello-World"}, + ) + defer env.stop(t) + + // The policy should work as compiled — specific repo allowed + t.Run("SpecificRepo_Allowed", func(t *testing.T) { + status, _ := env.ghAPIWithBody(t, "GET", "/repos/octocat/Hello-World/commits?per_page=1") + assert.Equal(t, 200, status, "Specifically allowed repo should be accessible") + }) + + // Verify logs show the flag disabled the override + t.Run("LogShowsDisabled", func(t *testing.T) { + time.Sleep(500 * time.Millisecond) + stderr := env.stderr.String() + assert.Contains(t, stderr, "forcePublicRepos: disabled by flag", + "Should log that force-public-repos was disabled") + }) +} + +// ============================================================================ +// Test: MCP_GATEWAY_FORCE_PUBLIC_REPOS=false env var disables the override +// ============================================================================ + +// TestProxyForcePublicRepos_EnvVarDisabled verifies that setting +// MCP_GATEWAY_FORCE_PUBLIC_REPOS=false disables the runtime override. +func TestProxyForcePublicRepos_EnvVarDisabled(t *testing.T) { + if testing.Short() { + t.Skip("Skipping proxy integration test in short mode") + } + + policy := `{"allow-only":{"repos":["octocat/hello-world"],"min-integrity":"none"}}` + + // Disable via env var (flag defaults from this) + env := startProxyWithEnv(t, policy, "18922", nil, []string{ + "GITHUB_REPOSITORY=octocat/Hello-World", + "MCP_GATEWAY_FORCE_PUBLIC_REPOS=false", + }) + defer env.stop(t) + + t.Run("SpecificRepo_Allowed", func(t *testing.T) { + status, _ := env.ghAPIWithBody(t, "GET", "/repos/octocat/Hello-World/commits?per_page=1") + assert.Equal(t, 200, status, "Specifically allowed repo should be accessible") + }) + + t.Run("LogShowsDisabled", func(t *testing.T) { + time.Sleep(500 * time.Millisecond) + stderr := env.stderr.String() + assert.Contains(t, stderr, "forcePublicRepos: disabled by flag", + "Should log that force-public-repos was disabled by env var default") + }) +} + +// ============================================================================ +// Test: No GITHUB_REPOSITORY → no override +// ============================================================================ + +// TestProxyForcePublicRepos_NoGithubRepo verifies that when +// GITHUB_REPOSITORY is not set, no override is applied. +func TestProxyForcePublicRepos_NoGithubRepo(t *testing.T) { + if testing.Short() { + t.Skip("Skipping proxy integration test in short mode") + } + + // Permissive policy + policy := `{"allow-only":{"repos":["octocat/hello-world"],"min-integrity":"none"}}` + + // Explicitly unset GITHUB_REPOSITORY by filtering it out + filteredEnv := []string{} + for _, e := range os.Environ() { + if !bytes.HasPrefix([]byte(e), []byte("GITHUB_REPOSITORY=")) { + filteredEnv = append(filteredEnv, e) + } + } + + binaryPath := findBinary(t) + wasmPath := findWasmGuard(t) + token := skipIfNoGitHubToken(t) + + logDir, err := os.MkdirTemp("", "awmg-proxy-norepo-*") + require.NoError(t, err) + defer os.RemoveAll(logDir) + + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + listenAddr := "127.0.0.1:18923" + args := []string{ + "proxy", + "--guard-wasm", wasmPath, + "--policy", policy, + "--github-token", token, + "--listen", listenAddr, + "--log-dir", logDir, + "--guards-mode", "filter", + } + + cmd := exec.CommandContext(ctx, binaryPath, args...) + cmd.Env = filteredEnv + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Start() + require.NoError(t, err) + defer cmd.Process.Kill() + + healthURL := "http://" + listenAddr + "/api/v3/health" + if !waitForServer(t, healthURL, 15*time.Second) { + t.Logf("STDERR: %s", stderr.String()) + t.Fatal("Proxy did not start in time") + } + + // Verify logs show skipped check + time.Sleep(500 * time.Millisecond) + assert.Contains(t, stderr.String(), "GITHUB_REPOSITORY not set", + "Should log that GITHUB_REPOSITORY is not set") +} + +// ============================================================================ +// Test: Private repo → no override +// ============================================================================ + +// TestProxyForcePublicRepos_PrivateRepo verifies that when +// GITHUB_REPOSITORY identifies a private repo, no override is applied. +func TestProxyForcePublicRepos_PrivateRepo(t *testing.T) { + if testing.Short() { + t.Skip("Skipping proxy integration test in short mode") + } + + // Use a private repo that the token has access to (github/gh-aw-mcpg) + policy := `{"allow-only":{"repos":["github/gh-aw-mcpg"],"min-integrity":"none"}}` + + env := startProxyWithEnv(t, policy, "18924", nil, []string{ + "GITHUB_REPOSITORY=github/gh-aw-mcpg", + }) + defer env.stop(t) + + t.Run("PrivateRepo_Allowed", func(t *testing.T) { + status, _ := env.ghAPIWithBody(t, "GET", "/repos/github/gh-aw-mcpg/commits?per_page=1") + assert.Equal(t, 200, status, "Private repo (in policy) should be accessible") + }) + + t.Run("NoOverrideLogged", func(t *testing.T) { + time.Sleep(500 * time.Millisecond) + logs := env.readLogs(t) + stderr := env.stderr.String() + combined := logs + stderr + assert.NotContains(t, combined, "FORCED REPOS=PUBLIC", + "Should NOT force repos=public for private repo") + }) +}