🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

mirror-github.paniser.workers.dev/github/github-mcp-server

Package Overview
Dependencies
Versions
77
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

mirror-github.paniser.workers.dev/github/github-mcp-server - go Package Compare versions

Comparing version
v1.1.1
to
v1.1.2
+1
-1
internal/ghmcp/server.go

@@ -106,3 +106,3 @@ package ghmcp

}
repoAccessCache = lockdown.GetInstance(gqlClient, restClient, opts...)
repoAccessCache = lockdown.NewRepoAccessCache(gqlClient, restClient, opts...)
}

@@ -109,0 +109,0 @@

@@ -402,3 +402,3 @@ package github

// Create repo access cache
instance := lockdown.GetInstance(gqlClient, restClient, d.RepoAccessOpts...)
instance := lockdown.NewRepoAccessCache(gqlClient, restClient, d.RepoAccessOpts...)
return instance, nil

@@ -405,0 +405,0 @@ }

@@ -5,2 +5,3 @@ package lockdown

"encoding/json"
"errors"
"net/http"

@@ -24,6 +25,12 @@ "net/http/httptest"

type repoMetadataQuery struct {
type viewerLoginQuery struct {
Viewer struct {
Login githubv4.String
}
}
type repoAccessQuery struct {
Viewer struct {
Login githubv4.String
}
Repository struct {

@@ -53,7 +60,3 @@ IsPrivate githubv4.Boolean

func newMockRepoAccessCache(t *testing.T, ttl time.Duration) (*RepoAccessCache, *countingTransport) {
t.Helper()
var query repoMetadataQuery
func newMockGQLClient(viewerLogin string, isPrivate bool) (*githubv4.Client, *countingTransport) {
variables := map[string]any{

@@ -64,20 +67,29 @@ "owner": githubv4.String(testOwner),

response := githubv4mock.DataResponse(map[string]any{
"viewer": map[string]any{
"login": testUser,
},
"repository": map[string]any{
"isPrivate": false,
},
})
httpClient := githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher(query, variables, response))
httpClient := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
viewerLoginQuery{},
nil,
githubv4mock.DataResponse(map[string]any{
"viewer": map[string]any{"login": viewerLogin},
}),
),
githubv4mock.NewQueryMatcher(
repoAccessQuery{},
variables,
githubv4mock.DataResponse(map[string]any{
"viewer": map[string]any{"login": viewerLogin},
"repository": map[string]any{"isPrivate": isPrivate},
}),
),
)
counting := &countingTransport{next: httpClient.Transport}
httpClient.Transport = counting
gqlClient := githubv4.NewClient(httpClient)
return gqlClient, counting
}
func newMockRESTServer(t *testing.T, permission string) *gogithub.Client {
t.Helper()
restServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
resp := gogithub.RepositoryPermissionLevel{
Permission: gogithub.Ptr("write"),
}
resp := gogithub.RepositoryPermissionLevel{Permission: gogithub.Ptr(permission)}
w.Header().Set("Content-Type", "application/json")

@@ -89,4 +101,16 @@ _ = json.NewEncoder(w).Encode(resp)

require.NoError(t, err)
return restClient
}
return NewRepoAccessCache(gqlClient, restClient, WithTTL(ttl)), counting
func newMockRepoAccessCache(t *testing.T, ttl time.Duration) (*RepoAccessCache, *countingTransport) {
t.Helper()
gqlClient, counting := newMockGQLClient(testUser, false)
restClient := newMockRESTServer(t, "write")
cache := NewRepoAccessCache(
gqlClient,
restClient,
WithTTL(ttl),
WithCacheName(t.Name()),
)
return cache, counting
}

@@ -100,3 +124,3 @@

require.NoError(t, err)
require.Equal(t, testUser, info.ViewerLogin)
require.False(t, info.IsPrivate)
require.True(t, info.HasPushAccess)

@@ -109,5 +133,93 @@ require.EqualValues(t, 1, transport.CallCount())

require.NoError(t, err)
require.Equal(t, testUser, info.ViewerLogin)
require.False(t, info.IsPrivate)
require.True(t, info.HasPushAccess)
require.EqualValues(t, 2, transport.CallCount())
}
func TestRepoAccessCacheIsolatesViewerPerInstance(t *testing.T) {
ctx := t.Context()
cacheName := t.Name()
restClient := newMockRESTServer(t, "read")
attackerGQL, _ := newMockGQLClient("attacker", false)
attackerCache := NewRepoAccessCache(attackerGQL, restClient, WithCacheName(cacheName))
safe, err := attackerCache.IsSafeContent(ctx, "attacker", testOwner, testRepo)
require.NoError(t, err)
require.True(t, safe)
victimGQL, _ := newMockGQLClient("victim", false)
victimCache := NewRepoAccessCache(victimGQL, restClient, WithCacheName(cacheName))
safe, err = victimCache.IsSafeContent(ctx, "attacker", testOwner, testRepo)
require.NoError(t, err)
require.False(t, safe, "attacker-authored content must not be safe for the victim")
safe, err = victimCache.IsSafeContent(ctx, "victim", testOwner, testRepo)
require.NoError(t, err)
require.True(t, safe)
}
type flakyTransport struct {
mu sync.Mutex
failN int
calls int
next http.RoundTripper
}
func (f *flakyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
f.mu.Lock()
f.calls++
shouldFail := f.calls <= f.failN
f.mu.Unlock()
if shouldFail {
return nil, errors.New("simulated transient failure")
}
return f.next.RoundTrip(req)
}
func TestRepoAccessCacheRetriesViewerLoginAfterTransientError(t *testing.T) {
ctx := t.Context()
httpClient := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
viewerLoginQuery{},
nil,
githubv4mock.DataResponse(map[string]any{
"viewer": map[string]any{"login": testUser},
}),
),
)
flaky := &flakyTransport{next: httpClient.Transport, failN: 1}
httpClient.Transport = flaky
gqlClient := githubv4.NewClient(httpClient)
cache := NewRepoAccessCache(gqlClient, nil, WithCacheName(t.Name()))
_, err := cache.viewerLoginFor(ctx)
require.Error(t, err, "first call should surface the transient failure")
login, err := cache.viewerLoginFor(ctx)
require.NoError(t, err, "second call must retry, not return the cached error")
require.Equal(t, testUser, login)
}
func TestRepoAccessCacheRejectsEmptyViewerLogin(t *testing.T) {
ctx := t.Context()
httpClient := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
viewerLoginQuery{},
nil,
githubv4mock.DataResponse(map[string]any{
"viewer": map[string]any{"login": ""},
}),
),
)
gqlClient := githubv4.NewClient(httpClient)
cache := NewRepoAccessCache(gqlClient, nil, WithCacheName(t.Name()))
_, err := cache.viewerLoginFor(ctx)
require.Error(t, err)
require.Contains(t, err.Error(), "empty")
}

@@ -7,2 +7,3 @@ package lockdown

"log/slog"
"maps"
"strings"

@@ -19,6 +20,7 @@ "sync"

// multiple tools can reuse the same access information safely across goroutines.
// In HTTP mode each request must construct its own instance so viewer-scoped
// lookups run under the requesting user's credentials.
type RepoAccessCache struct {
client *githubv4.Client
restClient *github.Client
mu sync.Mutex
cache *cache2go.CacheTable

@@ -28,8 +30,10 @@ ttl time.Duration

trustedBotLogins map[string]struct{}
viewerMu sync.Mutex
viewerLogin string
}
type repoAccessCacheEntry struct {
isPrivate bool
knownUsers map[string]bool // normalized login -> has push access
viewerLogin string
isPrivate bool
knownUsers map[string]bool // normalized login -> has push access
}

@@ -41,3 +45,2 @@

HasPushAccess bool
ViewerLogin string
}

@@ -50,7 +53,2 @@

var (
instance *RepoAccessCache
instanceMu sync.Mutex
)
// RepoAccessOption configures RepoAccessCache at construction time.

@@ -74,4 +72,4 @@ type RepoAccessOption func(*RepoAccessCache)

// WithCacheName overrides the cache table name used for storing entries. This option is intended for tests
// that need isolated cache instances.
// WithCacheName overrides the cache table name used for storing entries.
// Use this to isolate cache entries between tenants or in tests.
func WithCacheName(name string) RepoAccessOption {

@@ -85,21 +83,4 @@ return func(c *RepoAccessCache) {

// GetInstance returns the singleton instance of RepoAccessCache.
// It initializes the instance on first call with the provided client and options.
// Subsequent calls ignore the client and options parameters and return the existing instance.
// This is the preferred way to access the cache in production code.
func GetInstance(client *githubv4.Client, restClient *github.Client, opts ...RepoAccessOption) *RepoAccessCache {
instanceMu.Lock()
defer instanceMu.Unlock()
if instance == nil {
instance = newRepoAccessCache(client, restClient, opts...)
}
return instance
}
// NewRepoAccessCache creates a standalone cache instance, used for tests.
// NewRepoAccessCache creates a RepoAccessCache bound to the supplied clients.
func NewRepoAccessCache(client *githubv4.Client, restClient *github.Client, opts ...RepoAccessOption) *RepoAccessCache {
return newRepoAccessCache(client, restClient, opts...)
}
func newRepoAccessCache(client *githubv4.Client, restClient *github.Client, opts ...RepoAccessOption) *RepoAccessCache {
c := &RepoAccessCache{

@@ -123,9 +104,2 @@ client: client,

// SetLogger updates the logger used for cache diagnostics.
func (c *RepoAccessCache) SetLogger(logger *slog.Logger) {
c.mu.Lock()
c.logger = logger
c.mu.Unlock()
}
// CacheStats summarizes cache activity counters.

@@ -161,8 +135,53 @@ type CacheStats struct {

if repoInfo.IsPrivate || repoInfo.ViewerLogin == strings.ToLower(username) {
if repoInfo.IsPrivate {
return true, nil
}
return repoInfo.HasPushAccess, nil
if repoInfo.HasPushAccess {
return true, nil
}
viewerLogin, err := c.viewerLoginFor(ctx)
if err != nil {
return false, err
}
return viewerLogin == strings.ToLower(username), nil
}
func (c *RepoAccessCache) viewerLoginFor(ctx context.Context) (string, error) {
c.viewerMu.Lock()
defer c.viewerMu.Unlock()
if c.viewerLogin != "" {
return c.viewerLogin, nil
}
if c.client == nil {
return "", fmt.Errorf("nil GraphQL client")
}
var query struct {
Viewer struct {
Login githubv4.String
}
}
if err := c.client.Query(ctx, &query, nil); err != nil {
return "", fmt.Errorf("failed to query viewer login: %w", err)
}
login := strings.ToLower(string(query.Viewer.Login))
if login == "" {
return "", fmt.Errorf("viewer login returned empty")
}
c.viewerLogin = login
return c.viewerLogin, nil
}
// setViewerLogin seeds the cached viewer login from a piggy-backed query response.
func (c *RepoAccessCache) setViewerLogin(login string) {
if login == "" {
return
}
c.viewerMu.Lock()
defer c.viewerMu.Unlock()
if c.viewerLogin == "" {
c.viewerLogin = strings.ToLower(login)
}
}
func (c *RepoAccessCache) getRepoAccessInfo(ctx context.Context, username, owner, repo string) (RepoAccessInfo, error) {

@@ -175,8 +194,6 @@ if c == nil {

userKey := strings.ToLower(username)
c.mu.Lock()
defer c.mu.Unlock()
// Try to get entry from cache - this will keep the item alive if it exists
cacheItem, err := c.cache.Value(key)
if err == nil {
// Entries are immutable once added: the cache table is shared across instances,
// so we publish a fresh entry with a cloned knownUsers map on every miss.
if cacheItem, err := c.cache.Value(key); err == nil {
entry := cacheItem.Data().(*repoAccessCacheEntry)

@@ -188,3 +205,2 @@ if cachedHasPush, known := entry.knownUsers[userKey]; known {

HasPushAccess: cachedHasPush,
ViewerLogin: entry.viewerLogin,
}, nil

@@ -200,4 +216,9 @@ }

entry.knownUsers[userKey] = hasPush
c.cache.Add(key, c.ttl, entry)
users := make(map[string]bool, len(entry.knownUsers)+1)
maps.Copy(users, entry.knownUsers)
users[userKey] = hasPush
c.cache.Add(key, c.ttl, &repoAccessCacheEntry{
isPrivate: entry.isPrivate,
knownUsers: users,
})

@@ -207,3 +228,2 @@ return RepoAccessInfo{

HasPushAccess: hasPush,
ViewerLogin: entry.viewerLogin,
}, nil

@@ -214,25 +234,28 @@ }

info, queryErr := c.queryRepoAccessInfo(ctx, username, owner, repo)
isPrivate, viewerLogin, queryErr := c.queryRepoAccessInfo(ctx, owner, repo)
if queryErr != nil {
return RepoAccessInfo{}, queryErr
}
c.setViewerLogin(viewerLogin)
// Create new entry
entry := &repoAccessCacheEntry{
knownUsers: map[string]bool{userKey: info.HasPushAccess},
isPrivate: info.IsPrivate,
viewerLogin: info.ViewerLogin,
hasPush, pushErr := c.checkPushAccess(ctx, username, owner, repo)
if pushErr != nil {
return RepoAccessInfo{}, pushErr
}
c.cache.Add(key, c.ttl, entry)
c.cache.Add(key, c.ttl, &repoAccessCacheEntry{
knownUsers: map[string]bool{userKey: hasPush},
isPrivate: isPrivate,
})
return RepoAccessInfo{
IsPrivate: entry.isPrivate,
HasPushAccess: entry.knownUsers[userKey],
ViewerLogin: entry.viewerLogin,
IsPrivate: isPrivate,
HasPushAccess: hasPush,
}, nil
}
func (c *RepoAccessCache) queryRepoAccessInfo(ctx context.Context, username, owner, repo string) (RepoAccessInfo, error) {
// queryRepoAccessInfo fetches repository visibility and the viewer login in a single GraphQL round-trip.
func (c *RepoAccessCache) queryRepoAccessInfo(ctx context.Context, owner, repo string) (bool, string, error) {
if c.client == nil {
return RepoAccessInfo{}, fmt.Errorf("nil GraphQL client")
return false, "", fmt.Errorf("nil GraphQL client")
}

@@ -255,18 +278,8 @@

if err := c.client.Query(ctx, &query, variables); err != nil {
return RepoAccessInfo{}, fmt.Errorf("failed to query repository metadata: %w", err)
return false, "", fmt.Errorf("failed to query repository metadata: %w", err)
}
hasPush, err := c.checkPushAccess(ctx, username, owner, repo)
if err != nil {
return RepoAccessInfo{}, err
}
c.logDebug(ctx, fmt.Sprintf("queried repo access info for %s/%s: isPrivate=%t", owner, repo, bool(query.Repository.IsPrivate)))
c.logDebug(ctx, fmt.Sprintf("queried repo access info for user %s to %s/%s: isPrivate=%t, hasPushAccess=%t, viewerLogin=%s",
username, owner, repo, bool(query.Repository.IsPrivate), hasPush, query.Viewer.Login))
return RepoAccessInfo{
IsPrivate: bool(query.Repository.IsPrivate),
HasPushAccess: hasPush,
ViewerLogin: string(query.Viewer.Login),
}, nil
return bool(query.Repository.IsPrivate), string(query.Viewer.Login), nil
}

@@ -273,0 +286,0 @@

Sorry, the diff of this file is too big to display