Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,17 @@ to the agent via `label_agent`. The mapping depends on the `repos` configuration
- Internal target repo → `sink-visibility: "internal"` — same as private
- Without `sink-visibility: "public"`, an agent tricked into reading private data (via prompt injection) can exfiltrate it to a public repo comment (GitLost vulnerability)

**Runtime Verification (defense-in-depth)**:
- At startup, the gateway performs a runtime check against `GET /repos/{owner}/{repo}` using the `GITHUB_REPOSITORY` environment variable
- If the API reports the repo is **public** but the configured `sink-visibility` is not `"public"`, the gateway **overrides** the configured value to `"public"` and emits a warning:
```
SINK VISIBILITY OVERRIDE: configured="private" but runtime check shows repo owner/repo is "public" — overriding to "public" to prevent potential data exfiltration
```
- This catches cases where a repo was made public **after** the workflow was compiled
- If the API check fails (network error, 404, 403), the gateway falls back to the configured value and logs a warning
- The gateway never relaxes the setting: if configured as `"public"` but the repo is actually private, it keeps `"public"` (more restrictive)


## Custom Schemas (`customSchemas`)

The `customSchemas` top-level field allows you to define custom server types beyond the built-in `"stdio"` and `"http"` types. Each custom type maps to an HTTPS schema URL that describes its configuration format.
Expand Down
127 changes: 127 additions & 0 deletions internal/githubhttp/visibility.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package githubhttp

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"

"github.com/github/gh-aw-mcpg/internal/logger"
)

var logVisibility = logger.New("githubhttp:visibility")

// RepoVisibility represents the visibility of a GitHub repository.
type RepoVisibility string

const (
// RepoVisibilityPublic indicates a public repository.
RepoVisibilityPublic RepoVisibility = "public"
// RepoVisibilityPrivate indicates a private repository.
RepoVisibilityPrivate RepoVisibility = "private"
// RepoVisibilityInternal indicates an organization-internal repository.
RepoVisibilityInternal RepoVisibility = "internal"
)

// repoResponse is the minimal subset of the GitHub repos API response we need.
type repoResponse struct {
Visibility string `json:"visibility"`
Private bool `json:"private"`
}

// FetchRepoVisibility calls GET /repos/{owner}/{repo} and returns the
// repository's visibility. The nwo parameter should be in "owner/repo" format.
// apiBaseURL is the API root (e.g. "https://api.github.com") and authHeader
// is the full Authorization header value (e.g. "token xyz").
//
// Returns RepoVisibilityPublic, RepoVisibilityPrivate, or RepoVisibilityInternal.
// On API errors (network, 404, 403) returns an error — callers should treat
// this as non-fatal and fall back to the configured value.
func FetchRepoVisibility(ctx context.Context, apiBaseURL, nwo, authHeader string) (RepoVisibility, error) {
parts := strings.SplitN(nwo, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", fmt.Errorf("invalid repository nwo: %q (expected owner/repo)", nwo)
}

path := fmt.Sprintf("/repos/%s/%s", parts[0], parts[1])
logVisibility.Printf("Fetching repo visibility: nwo=%s, apiBaseURL=%s", nwo, apiBaseURL)

resp, err := DoGitHubGET(ctx, apiBaseURL, path, authHeader)
if err != nil {
return "", fmt.Errorf("failed to fetch repo visibility for %s: %w", nwo, err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
logVisibility.Printf("Repo visibility check failed: nwo=%s, status=%d", nwo, resp.StatusCode)
return "", fmt.Errorf("repo visibility check for %s returned status %d", nwo, resp.StatusCode)
}

var repo repoResponse
if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
return "", fmt.Errorf("failed to decode repo response for %s: %w", nwo, err)
}

// The "visibility" field is available on GitHub.com and GHES 3.x+.
// Fall back to the boolean "private" field for older API versions.
var vis RepoVisibility
switch strings.ToLower(repo.Visibility) {
case "public":
vis = RepoVisibilityPublic
case "internal":
vis = RepoVisibilityInternal
case "private":
vis = RepoVisibilityPrivate
default:
// Fallback: use the "private" boolean
if repo.Private {
vis = RepoVisibilityPrivate
} else {
vis = RepoVisibilityPublic
}
}

logVisibility.Printf("Repo visibility resolved: nwo=%s, visibility=%s", nwo, vis)
return vis, nil
}

// VerifySinkVisibility compares the configured sink-visibility against the
// actual repository visibility from the GitHub API. If the actual visibility
// is more public than configured, it returns the actual (more restrictive)
// visibility to prevent exfiltration.
//
// Returns:
// - The effective visibility to use (may override configured value)
// - Whether an override occurred
// - Any error encountered (non-fatal — callers should log and use configured value)
func VerifySinkVisibility(ctx context.Context, apiBaseURL, nwo, authHeader, configuredVisibility string) (string, bool, error) {
if nwo == "" {
return configuredVisibility, false, fmt.Errorf("no repository configured for sink visibility verification")
}

actual, err := FetchRepoVisibility(ctx, apiBaseURL, nwo, authHeader)
if err != nil {
return configuredVisibility, false, err
}

actualStr := string(actual)
configured := strings.ToLower(strings.TrimSpace(configuredVisibility))

// If actual is "public" but configured is not "public" (or unset),
// override to "public" — this is the security-critical case.
if actual == RepoVisibilityPublic && configured != "public" {
logger.LogWarn("difc", "Sink visibility override: configured=%q but repo %s is actually PUBLIC — overriding to \"public\" to prevent exfiltration", configured, nwo)
return actualStr, true, nil
}
Comment on lines +111 to +116

// If configured says public but actual says private/internal,
// keep "public" (the more restrictive setting) — defense in depth.
if configured == "public" && actual != RepoVisibilityPublic {
logVisibility.Printf("Sink visibility: configured=public but repo %s is %s — keeping public (more restrictive)", nwo, actual)
return "public", false, nil
}

logVisibility.Printf("Sink visibility verified: configured=%q, actual=%s — no override needed", configured, actual)
return actualStr, false, nil
Comment on lines +118 to +126
}
222 changes: 222 additions & 0 deletions internal/githubhttp/visibility_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package githubhttp

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestFetchRepoVisibility(t *testing.T) {
tests := []struct {
name string
nwo string
response repoResponse
statusCode int
wantVis RepoVisibility
wantErr bool
}{
{
name: "public repo via visibility field",
nwo: "octo/public-repo",
response: repoResponse{Visibility: "public", Private: false},
statusCode: http.StatusOK,
wantVis: RepoVisibilityPublic,
},
{
name: "private repo via visibility field",
nwo: "octo/private-repo",
response: repoResponse{Visibility: "private", Private: true},
statusCode: http.StatusOK,
wantVis: RepoVisibilityPrivate,
},
{
name: "internal repo via visibility field",
nwo: "octo/internal-repo",
response: repoResponse{Visibility: "internal", Private: true},
statusCode: http.StatusOK,
wantVis: RepoVisibilityInternal,
},
{
name: "fallback to private boolean when visibility empty",
nwo: "octo/old-ghes-repo",
response: repoResponse{Visibility: "", Private: true},
statusCode: http.StatusOK,
wantVis: RepoVisibilityPrivate,
},
{
name: "fallback to public when visibility empty and not private",
nwo: "octo/old-public-repo",
response: repoResponse{Visibility: "", Private: false},
statusCode: http.StatusOK,
wantVis: RepoVisibilityPublic,
},
{
name: "404 returns error",
nwo: "octo/missing-repo",
statusCode: http.StatusNotFound,
wantErr: true,
},
{
name: "403 returns error",
nwo: "octo/forbidden-repo",
statusCode: http.StatusForbidden,
wantErr: true,
},
{
name: "invalid nwo",
nwo: "no-slash",
wantErr: true,
},
{
name: "empty nwo",
nwo: "",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.nwo == "" || tt.nwo == "no-slash" {
// These fail before making a request
_, err := FetchRepoVisibility(context.Background(), "http://unused", tt.nwo, "token test")
require.Error(t, err)
return
}

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
if tt.statusCode == http.StatusOK {
json.NewEncoder(w).Encode(tt.response)
}
}))
defer server.Close()

vis, err := FetchRepoVisibility(context.Background(), server.URL, tt.nwo, "token test-token")
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantVis, vis)
})
}
}

func TestVerifySinkVisibility(t *testing.T) {
tests := []struct {
name string
configured string
actualVis string // JSON visibility field from API
actualPrivate bool
wantEffective string
wantOverridden bool
wantErr bool
}{
{
name: "configured private but repo is public — override to public",
configured: "private",
actualVis: "public",
actualPrivate: false,
wantEffective: "public",
wantOverridden: true,
},
{
name: "configured empty but repo is public — override to public",
configured: "",
actualVis: "public",
actualPrivate: false,
wantEffective: "public",
wantOverridden: true,
},
{
name: "configured internal but repo is public — override to public",
configured: "internal",
actualVis: "public",
actualPrivate: false,
wantEffective: "public",
wantOverridden: true,
},
{
name: "configured public and repo is public — no override",
configured: "public",
actualVis: "public",
actualPrivate: false,
wantEffective: "public",
wantOverridden: false,
},
{
name: "configured public but repo is private — keep public (more restrictive)",
configured: "public",
actualVis: "private",
actualPrivate: true,
wantEffective: "public",
wantOverridden: false,
},
{
name: "configured private and repo is private — no override",
configured: "private",
actualVis: "private",
actualPrivate: true,
wantEffective: "private",
wantOverridden: false,
},
{
name: "configured private and repo is internal — no override",
configured: "private",
actualVis: "internal",
actualPrivate: true,
wantEffective: "internal",
wantOverridden: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(repoResponse{
Visibility: tt.actualVis,
Private: tt.actualPrivate,
})
}))
defer server.Close()

effective, overridden, err := VerifySinkVisibility(
context.Background(), server.URL, "octo/test-repo", "token xyz", tt.configured,
)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantEffective, effective)
assert.Equal(t, tt.wantOverridden, overridden)
})
}
}

func TestVerifySinkVisibility_EmptyNWO(t *testing.T) {
_, _, err := VerifySinkVisibility(context.Background(), "http://unused", "", "token xyz", "private")
assert.Error(t, err)
assert.Contains(t, err.Error(), "no repository configured")
}

func TestVerifySinkVisibility_APIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()

effective, overridden, err := VerifySinkVisibility(
context.Background(), server.URL, "octo/broken", "token xyz", "private",
)
assert.Error(t, err)
// On error, returns configured value unchanged
assert.Equal(t, "private", effective)
assert.False(t, overridden)
}
Loading
Loading