When scanAllInstallations is true, the scanner no longer requires githubApp.installationId in the config. Repos, gists, and org members outside any installation's listing now fail with a clear error instead of the app hitting a nil client or a bad request.
438 lines
14 KiB
Go
438 lines
14 KiB
Go
package github
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/google/go-github/v67/github"
|
|
"github.com/shurcooL/githubv4"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
trContext "github.com/trufflesecurity/trufflehog/v3/pkg/context"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/credentialspb"
|
|
)
|
|
|
|
func generateTestPrivateKey(t *testing.T) []byte {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
return pem.EncodeToMemory(&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
|
})
|
|
}
|
|
|
|
func TestAPIClientForInstallation(t *testing.T) {
|
|
privKey := generateTestPrivateKey(t)
|
|
|
|
var mu sync.Mutex
|
|
var tokenRequestPaths []string
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if r.Method == "POST" && strings.Contains(r.URL.Path, "access_tokens") {
|
|
mu.Lock()
|
|
tokenRequestPaths = append(tokenRequestPaths, r.URL.Path)
|
|
mu.Unlock()
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"token": "test-token",
|
|
"expires_at": "2099-01-01T00:00:00Z",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Default: return empty JSON array for any list endpoint.
|
|
_ = json.NewEncoder(w).Encode([]interface{}{})
|
|
}))
|
|
defer server.Close()
|
|
|
|
connector := &appConnector{
|
|
appID: 12345,
|
|
appPrivateKey: privKey,
|
|
apiEndpoint: server.URL,
|
|
clientsByInstallationID: make(map[int64]*appInstallationClients),
|
|
}
|
|
|
|
t.Run("creates distinct clients for different installations", func(t *testing.T) {
|
|
client1, err := connector.APIClientForInstallation(111)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, client1)
|
|
|
|
client2, err := connector.APIClientForInstallation(222)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, client2)
|
|
|
|
assert.NotSame(t, client1, client2, "should be different client instances")
|
|
})
|
|
|
|
t.Run("reuses clients for the same installation", func(t *testing.T) {
|
|
client1, err := connector.APIClientForInstallation(333)
|
|
require.NoError(t, err)
|
|
client2, err := connector.APIClientForInstallation(333)
|
|
require.NoError(t, err)
|
|
|
|
assert.Same(t, client1, client2)
|
|
})
|
|
|
|
t.Run("returned client uses the correct installation ID", func(t *testing.T) {
|
|
mu.Lock()
|
|
tokenRequestPaths = nil
|
|
mu.Unlock()
|
|
|
|
client, err := connector.APIClientForInstallation(42)
|
|
require.NoError(t, err)
|
|
|
|
ctx := trContext.Background()
|
|
_, _, _ = client.Organizations.ListMembers(ctx, "test-org", nil)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
require.Len(t, tokenRequestPaths, 1)
|
|
assert.Contains(t, tokenRequestPaths[0], "/app/installations/42/access_tokens")
|
|
})
|
|
|
|
t.Run("different installations use different tokens", func(t *testing.T) {
|
|
mu.Lock()
|
|
tokenRequestPaths = nil
|
|
mu.Unlock()
|
|
|
|
ctx := trContext.Background()
|
|
|
|
client1, err := connector.APIClientForInstallation(100)
|
|
require.NoError(t, err)
|
|
_, _, _ = client1.Organizations.ListMembers(ctx, "org-a", nil)
|
|
|
|
client2, err := connector.APIClientForInstallation(200)
|
|
require.NoError(t, err)
|
|
_, _, _ = client2.Organizations.ListMembers(ctx, "org-b", nil)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
require.Len(t, tokenRequestPaths, 2)
|
|
assert.Contains(t, tokenRequestPaths[0], "/installations/100/")
|
|
assert.Contains(t, tokenRequestPaths[1], "/installations/200/")
|
|
})
|
|
}
|
|
|
|
func TestNewAppConnectorDefaultAPIClientUsesConfiguredInstallation(t *testing.T) {
|
|
privKey := generateTestPrivateKey(t)
|
|
|
|
var mu sync.Mutex
|
|
var tokenRequestPaths []string
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if r.Method == "POST" && strings.Contains(r.URL.Path, "access_tokens") {
|
|
mu.Lock()
|
|
tokenRequestPaths = append(tokenRequestPaths, r.URL.Path)
|
|
mu.Unlock()
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"token": "configured-installation-token",
|
|
"expires_at": "2099-01-01T00:00:00Z",
|
|
})
|
|
return
|
|
}
|
|
|
|
_ = json.NewEncoder(w).Encode([]map[string]string{{"login": "alice"}})
|
|
}))
|
|
defer server.Close()
|
|
|
|
connector, err := NewAppConnector(trContext.Background(), server.URL, &credentialspb.GitHubApp{
|
|
PrivateKey: string(privKey),
|
|
InstallationId: "4242",
|
|
AppId: "12345",
|
|
}, false)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, connector.APIClient())
|
|
require.NotNil(t, connector.GraphQLClient())
|
|
|
|
gotAPIClient, err := connector.APIClientForRepo("https://github.com/default-org/repo.git")
|
|
require.NoError(t, err)
|
|
assert.Same(t, connector.APIClient(), gotAPIClient)
|
|
|
|
gotGraphQLClient, err := connector.GraphQLClientForRepo(trContext.Background(), "https://github.com/default-org/repo.git")
|
|
require.NoError(t, err)
|
|
assert.Same(t, connector.GraphQLClient(), gotGraphQLClient)
|
|
|
|
_, _, err = connector.APIClient().Organizations.ListMembers(trContext.Background(), "test-org", nil)
|
|
require.NoError(t, err)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
require.Len(t, tokenRequestPaths, 1)
|
|
assert.Contains(t, tokenRequestPaths[0], "/app/installations/4242/access_tokens")
|
|
}
|
|
|
|
func TestNewAppConnectorInstallationIDOptionalWithScanAllInstallations(t *testing.T) {
|
|
privKey := generateTestPrivateKey(t)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode([]map[string]string{})
|
|
}))
|
|
defer server.Close()
|
|
|
|
// scanAllInstallations=true with no installationId configured should succeed.
|
|
connector, err := NewAppConnector(trContext.Background(), server.URL, &credentialspb.GitHubApp{
|
|
PrivateKey: string(privKey),
|
|
AppId: "12345",
|
|
}, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, connector)
|
|
|
|
// Without scanAllInstallations, installationId is still required.
|
|
_, err = NewAppConnector(trContext.Background(), server.URL, &credentialspb.GitHubApp{
|
|
PrivateKey: string(privKey),
|
|
AppId: "12345",
|
|
}, false)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// TestAPIClientAndGraphQLClientNonNilWithoutDefaultInstallation guards against
|
|
// a nil-pointer panic: APIClient/GraphQLClient must never return nil, since
|
|
// callers like Validate, mapRemainingAccessibleRepos, and member gist/repo
|
|
// lookups call them unconditionally and dereference the result. Before this
|
|
// fix, an appConnector with no default installation (installationID == 0,
|
|
// the case when scanAllInstallations is true and installationId is omitted)
|
|
// returned nil from a bare map lookup instead of lazily creating a client.
|
|
func TestAPIClientAndGraphQLClientNonNilWithoutDefaultInstallation(t *testing.T) {
|
|
privKey := generateTestPrivateKey(t)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode([]map[string]string{})
|
|
}))
|
|
defer server.Close()
|
|
|
|
connector, err := NewAppConnector(trContext.Background(), server.URL, &credentialspb.GitHubApp{
|
|
PrivateKey: string(privKey),
|
|
AppId: "12345",
|
|
}, true)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotNil(t, connector.APIClient())
|
|
assert.NotNil(t, connector.GraphQLClient())
|
|
}
|
|
|
|
// TestCloneErrorsWithoutResolvedInstallation covers repos that fall back to
|
|
// the connector's default installationID (e.g. member personal repos found
|
|
// outside any installation's repo listing, see ensureRepoInstallation). When
|
|
// scanAllInstallations is true and no githubApp.installationId is
|
|
// configured, that default is unset (0); Clone must fail with an actionable
|
|
// error instead of attempting to use installation ID 0.
|
|
func TestCloneErrorsWithoutResolvedInstallation(t *testing.T) {
|
|
connector := &appConnector{
|
|
installationID: 0,
|
|
clientsByInstallationID: make(map[int64]*appInstallationClients),
|
|
repoInstallationMap: make(map[string]int64),
|
|
}
|
|
|
|
_, _, err := connector.Clone(trContext.Background(), "https://github.com/some-member/personal-repo.git")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "no GitHub App installation resolved")
|
|
}
|
|
|
|
func TestAPIClientForInstallationUsesConfiguredClient(t *testing.T) {
|
|
defaultClient := github.NewClient(nil)
|
|
connector := &appConnector{
|
|
installationID: 100,
|
|
clientsByInstallationID: map[int64]*appInstallationClients{
|
|
100: {apiClient: defaultClient},
|
|
},
|
|
}
|
|
|
|
got, err := connector.APIClientForInstallation(100)
|
|
require.NoError(t, err)
|
|
assert.Same(t, defaultClient, got)
|
|
}
|
|
|
|
func TestCloneUsesRepoInstallationMap(t *testing.T) {
|
|
connector := &appConnector{
|
|
installationID: 100,
|
|
repoInstallationMap: make(map[string]int64),
|
|
}
|
|
|
|
t.Run("uses default installation when no mapping exists", func(t *testing.T) {
|
|
installationID, mapped := connector.installationIDForRepo("https://github.com/default-org/repo.git")
|
|
assert.False(t, mapped)
|
|
assert.Equal(t, int64(100), installationID)
|
|
})
|
|
|
|
t.Run("uses mapped installation for cross-org repos", func(t *testing.T) {
|
|
connector.setRepoInstallation("https://github.com/other-org/repo.git", 999)
|
|
installationID, mapped := connector.installationIDForRepo("https://github.com/other-org/repo.git")
|
|
assert.True(t, mapped)
|
|
assert.Equal(t, int64(999), installationID)
|
|
})
|
|
|
|
t.Run("uses mapped installation for derived wiki URLs", func(t *testing.T) {
|
|
connector.setRepoInstallation("https://github.com/wiki-org/repo.git", 888)
|
|
installationID, mapped := connector.installationIDForRepo("https://github.com/wiki-org/repo.wiki.git")
|
|
assert.True(t, mapped)
|
|
assert.Equal(t, int64(888), installationID)
|
|
})
|
|
}
|
|
|
|
func TestAPIClientForRepoUsesRepoInstallationMap(t *testing.T) {
|
|
privKey := generateTestPrivateKey(t)
|
|
|
|
var mu sync.Mutex
|
|
var tokenRequestPaths []string
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "access_tokens") {
|
|
mu.Lock()
|
|
tokenRequestPaths = append(tokenRequestPaths, r.URL.Path)
|
|
mu.Unlock()
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"token": "test-token",
|
|
"expires_at": "2099-01-01T00:00:00Z",
|
|
})
|
|
return
|
|
}
|
|
|
|
_ = json.NewEncoder(w).Encode([]map[string]string{{"login": "alice"}})
|
|
}))
|
|
defer server.Close()
|
|
|
|
connector := &appConnector{
|
|
installationID: 100,
|
|
appID: 12345,
|
|
appPrivateKey: privKey,
|
|
apiEndpoint: server.URL,
|
|
clientsByInstallationID: make(map[int64]*appInstallationClients),
|
|
repoInstallationMap: make(map[string]int64),
|
|
}
|
|
connector.setRepoInstallation("https://github.com/other-org/repo.git", 999)
|
|
|
|
client, err := connector.APIClientForRepo("https://github.com/other-org/repo.git")
|
|
require.NoError(t, err)
|
|
cachedClient, err := connector.APIClientForRepo("https://github.com/other-org/repo.git")
|
|
require.NoError(t, err)
|
|
assert.Same(t, client, cachedClient)
|
|
|
|
_, _, _ = client.Organizations.ListMembers(trContext.Background(), "test-org", nil)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
require.Len(t, tokenRequestPaths, 1)
|
|
assert.Contains(t, tokenRequestPaths[0], "/app/installations/999/access_tokens")
|
|
}
|
|
|
|
func TestGraphQLClientForRepoCachesClients(t *testing.T) {
|
|
privKey := generateTestPrivateKey(t)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{})
|
|
}))
|
|
defer server.Close()
|
|
|
|
defaultGraphQLClient := &githubv4.Client{}
|
|
connector := &appConnector{
|
|
installationID: 100,
|
|
appID: 12345,
|
|
appPrivateKey: privKey,
|
|
apiEndpoint: server.URL,
|
|
clientsByInstallationID: map[int64]*appInstallationClients{
|
|
100: {graphqlClient: defaultGraphQLClient},
|
|
},
|
|
repoInstallationMap: make(map[string]int64),
|
|
}
|
|
connector.setRepoInstallation("https://github.com/other-org/repo.git", 999)
|
|
|
|
ctx := trContext.Background()
|
|
|
|
t.Run("returns default client for default installation repos", func(t *testing.T) {
|
|
got, err := connector.GraphQLClientForRepo(ctx, "https://github.com/default-org/repo.git")
|
|
require.NoError(t, err)
|
|
assert.Same(t, defaultGraphQLClient, got)
|
|
})
|
|
|
|
t.Run("caches clients for non-default installations", func(t *testing.T) {
|
|
client1, err := connector.GraphQLClientForRepo(ctx, "https://github.com/other-org/repo.git")
|
|
require.NoError(t, err)
|
|
assert.NotSame(t, defaultGraphQLClient, client1)
|
|
|
|
client2, err := connector.GraphQLClientForRepo(ctx, "https://github.com/other-org/repo.git")
|
|
require.NoError(t, err)
|
|
assert.Same(t, client1, client2)
|
|
|
|
apiClient, err := connector.APIClientForRepo("https://github.com/other-org/repo.git")
|
|
require.NoError(t, err)
|
|
|
|
connector.mu.RLock()
|
|
cachedClients := connector.clientsByInstallationID[999]
|
|
connector.mu.RUnlock()
|
|
require.NotNil(t, cachedClients)
|
|
assert.Same(t, apiClient, cachedClients.apiClient)
|
|
assert.Same(t, client1, cachedClients.graphqlClient)
|
|
})
|
|
}
|
|
|
|
func TestDefaultConnectorsReturnDefaultClientsForRepo(t *testing.T) {
|
|
ctx := trContext.Background()
|
|
apiClient := github.NewClient(nil)
|
|
graphqlClient := &githubv4.Client{}
|
|
|
|
connectors := []Connector{
|
|
&basicAuthConnector{apiClient: apiClient, graphqlClient: graphqlClient},
|
|
&tokenConnector{apiClient: apiClient, graphqlClient: graphqlClient},
|
|
&unauthenticatedConnector{apiClient: apiClient, graphqlClient: graphqlClient},
|
|
}
|
|
|
|
for _, connector := range connectors {
|
|
gotAPIClient, err := connector.APIClientForRepo("https://github.com/trufflesecurity/trufflehog.git")
|
|
require.NoError(t, err)
|
|
assert.Same(t, apiClient, gotAPIClient)
|
|
|
|
gotGraphQLClient, err := connector.GraphQLClientForRepo(ctx, "https://github.com/trufflesecurity/trufflehog.git")
|
|
require.NoError(t, err)
|
|
assert.Same(t, graphqlClient, gotGraphQLClient)
|
|
}
|
|
}
|
|
|
|
func TestAddMembersByOrgWithClient(t *testing.T) {
|
|
strPtr := func(s string) *string { return &s }
|
|
memberPage := []*github.User{
|
|
{Login: strPtr("alice")},
|
|
{Login: strPtr("bob")},
|
|
{Login: strPtr("charlie")},
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(memberPage)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client, err := github.NewClient(nil).WithEnterpriseURLs(server.URL, server.URL)
|
|
require.NoError(t, err)
|
|
|
|
s := &Source{
|
|
memberCache: make(map[string]struct{}),
|
|
}
|
|
|
|
ctx := trContext.Background()
|
|
err = s.addMembersByOrgWithClient(ctx, client, "test-org", nil)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, s.memberCache, 3)
|
|
assert.Contains(t, s.memberCache, "alice")
|
|
assert.Contains(t, s.memberCache, "bob")
|
|
assert.Contains(t, s.memberCache, "charlie")
|
|
}
|