Files
trufflehog/pkg/sources/github/connector_app.go
Kashif Khan 166bf18f3a Make GitHub App installationId optional when scanning all installations (#5275)
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.
2026-09-07 09:32:43 +05:00

369 lines
13 KiB
Go

package github
import (
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"github.com/bradleyfalzon/ghinstallation/v2"
gogit "github.com/go-git/go-git/v5"
"github.com/google/go-github/v67/github"
"github.com/shurcooL/githubv4"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/credentialspb"
"github.com/trufflesecurity/trufflehog/v3/pkg/sources/git"
)
type appConnector struct {
installationClient *github.Client
installationID int64
appID int64
appPrivateKey []byte
apiEndpoint string
mu sync.RWMutex
// clientsByInstallationID is scoped to this connector/source run and is
// bounded by the installations touched while scanning.
clientsByInstallationID map[int64]*appInstallationClients
// repoInstallationMap maps repo clone URLs to their owning installation
// ID for repos discovered from non-default installations. Clone checks
// this map to use the correct installation token.
repoInstallationMap map[string]int64
}
type appInstallationClients struct {
apiClient *github.Client
graphqlClient *githubv4.Client
}
var _ Connector = (*appConnector)(nil)
const githubHTTPTimeoutSeconds = 60
func NewAppConnector(ctx context.Context, apiEndpoint string, app *credentialspb.GitHubApp, scanAllInstallations bool) (Connector, error) {
var installationID int64
if app.InstallationId != "" {
var err error
installationID, err = strconv.ParseInt(app.InstallationId, 10, 64)
if err != nil {
return nil, fmt.Errorf("could not parse app installation ID %q: %w", app.InstallationId, err)
}
} else if !scanAllInstallations {
return nil, fmt.Errorf("githubApp.installationId is required unless scanAllInstallations is set")
}
appID, err := strconv.ParseInt(app.AppId, 10, 64)
if err != nil {
return nil, fmt.Errorf("could not parse app ID %q: %w", app.AppId, err)
}
installationTransport, err := newAppsTransport(apiEndpoint, appID, []byte(app.PrivateKey))
if err != nil {
return nil, fmt.Errorf("could not create installation client transport: %w", err)
}
installationClient, err := newGitHubClientWithTransport(apiEndpoint, installationTransport)
if err != nil {
return nil, fmt.Errorf("could not create installation client: %w", err)
}
connector := &appConnector{
installationClient: installationClient,
installationID: installationID,
appID: appID,
appPrivateKey: []byte(app.PrivateKey),
apiEndpoint: apiEndpoint,
clientsByInstallationID: make(map[int64]*appInstallationClients),
repoInstallationMap: make(map[string]int64),
}
// When no default installation is configured (installationID == 0, only
// possible with scanAllInstallations), installations are discovered and
// clients created lazily per-repo, so there is no default client to
// validate up front.
if installationID != 0 {
if _, err := connector.APIClientForInstallation(installationID); err != nil {
return nil, fmt.Errorf("could not create API client for configured installation: %w", err)
}
if _, err := connector.graphqlClientForInstallation(ctx, installationID); err != nil {
return nil, fmt.Errorf("error creating GraphQL client: %w", err)
}
}
return connector, nil
}
// HasDefaultInstallation reports whether a default installation is
// configured. False only when scanAllInstallations is set and
// githubApp.installationId was omitted, in which case there is no
// authoritative installation to fall back to for repos/gists discovered
// outside any installation's own listing.
func (c *appConnector) HasDefaultInstallation() bool {
return c.installationID != 0
}
// APIClient returns the client for the connector's default installation.
// When scanAllInstallations is configured without a githubApp.installationId
// (installationID == 0), NewAppConnector does not pre-create this client, so
// it is created lazily here rather than returning nil: several callers
// (Validate, mapRemainingAccessibleRepos, member gist/repo lookups) use this
// as a fallback client for repos/resources outside any installation listing
// and call it unconditionally.
func (c *appConnector) APIClient() *github.Client {
client, err := c.APIClientForInstallation(c.installationID)
if err != nil {
return nil
}
return client
}
func (c *appConnector) APIClientForRepo(repoURL string) (*github.Client, error) {
installID, _ := c.installationIDForRepo(repoURL)
return c.APIClientForInstallation(installID)
}
func (c *appConnector) GraphQLClientForRepo(ctx context.Context, repoURL string) (*githubv4.Client, error) {
installID, _ := c.installationIDForRepo(repoURL)
return c.graphqlClientForInstallation(ctx, installID)
}
func (c *appConnector) graphqlClientForInstallation(ctx context.Context, installID int64) (*githubv4.Client, error) {
c.mu.RLock()
if clients := c.clientsByInstallationID[installID]; clients != nil && clients.graphqlClient != nil {
client := clients.graphqlClient
c.mu.RUnlock()
return client, nil
}
c.mu.RUnlock()
apiClient, err := c.APIClientForInstallation(installID)
if err != nil {
return nil, err
}
client, err := createGraphqlClient(ctx, apiClient.Client(), c.apiEndpoint)
if err != nil {
return nil, err
}
c.mu.Lock()
defer c.mu.Unlock()
clients := c.clientsForInstallationLocked(installID)
if clients.graphqlClient != nil {
return clients.graphqlClient, nil
}
if clients.apiClient == nil {
clients.apiClient = apiClient
}
clients.graphqlClient = client
return client, nil
}
func (c *appConnector) Clone(ctx context.Context, repoURL string, args ...string) (string, *gogit.Repository, error) {
installID, _ := c.installationIDForRepo(repoURL)
if installID == 0 {
// installID falls back to the connector's default installationID
// (see installationIDForRepo/ensureRepoInstallation) for repos
// discovered outside any installation's repo listing, e.g. org
// members' personal repos with scanUsers. That default is unset (0)
// when scanAllInstallations is true and githubApp.installationId
// was omitted, since no single installation is authoritative.
return "", nil, fmt.Errorf("no GitHub App installation resolved for repo %q; set githubApp.installationId to a fallback installation to scan repos outside installation listings (e.g. member repos with scanUsers) together with scanAllInstallations", repoURL)
}
// TODO: Check rate limit for this call.
token, _, err := c.installationClient.Apps.CreateInstallationToken(
ctx,
installID,
&github.InstallationTokenOptions{})
if err != nil {
return "", nil, fmt.Errorf("could not create installation token for installation %d: %w", installID, err)
}
return git.CloneRepoUsingToken(ctx, token.GetToken(), repoURL, "", "x-access-token", true, args...)
}
// installationIDForRepo returns the mapped installation ID for repoURL. When no
// mapping exists, it falls back to the configured installation and returns false.
func (c *appConnector) installationIDForRepo(repoURL string) (int64, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if id, ok := c.repoInstallationMap[repoURL]; ok {
return id, true
}
return c.installationID, false
}
func (c *appConnector) hasRepoInstallation(repoURL string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
_, ok := c.repoInstallationMap[repoURL]
return ok
}
// setRepoInstallation records which installation owns a repo and its derived
// wiki URL so Clone uses the correct installation token for cross-org repos.
func (c *appConnector) setRepoInstallation(repoURL string, installationID int64) {
c.setRepoInstallationForWiki(repoURL, installationID, wikiCloneURLForRepo)
}
// ensureRepoInstallation maps a repo to the default installation unless an
// installation already claims it. Used for repos discovered outside
// installation listings (e.g. org members' personal repos), which no
// installation owns but which the default installation token can still
// access when they are public. The check and set happen under one lock so a
// concurrent mapping to a real installation is never overwritten.
func (c *appConnector) ensureRepoInstallation(repoURL, repoName string) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.repoInstallationMap[repoURL]; ok {
return
}
c.repoInstallationMap[repoURL] = c.installationID
if wikiURL, ok := wikiCloneURLForRepoName(repoURL, repoName); ok {
if _, taken := c.repoInstallationMap[wikiURL]; !taken {
c.repoInstallationMap[wikiURL] = c.installationID
}
}
}
func (c *appConnector) setRepoInstallationForRepoName(repoURL, repoName string, installationID int64) {
c.setRepoInstallationForWiki(repoURL, installationID, func(repoURL string) (string, bool) {
return wikiCloneURLForRepoName(repoURL, repoName)
})
}
func (c *appConnector) setRepoInstallationForWiki(repoURL string, installationID int64, wikiURLForRepo func(string) (string, bool)) {
c.mu.Lock()
defer c.mu.Unlock()
c.repoInstallationMap[repoURL] = installationID
if wikiURL, ok := wikiURLForRepo(repoURL); ok {
c.repoInstallationMap[wikiURL] = installationID
}
}
// GraphQLClient returns the GraphQL client for the connector's default
// installation, lazily creating it if needed. See APIClient for why this
// cannot simply return nil when no default installation is configured.
func (c *appConnector) GraphQLClient() *githubv4.Client {
client, err := c.graphqlClientForInstallation(context.Background(), c.installationID)
if err != nil {
return nil
}
return client
}
func (c *appConnector) InstallationClient() *github.Client {
return c.installationClient
}
// APIClientForInstallation creates a GitHub API client scoped to a specific
// installation. This is needed when a GitHub App is installed across multiple
// orgs — each org's API calls must use that org's installation token to get
// proper IP allowlist bypass and permission scoping.
func (c *appConnector) APIClientForInstallation(installationID int64) (*github.Client, error) {
c.mu.RLock()
if clients := c.clientsByInstallationID[installationID]; clients != nil && clients.apiClient != nil {
client := clients.apiClient
c.mu.RUnlock()
return client, nil
}
c.mu.RUnlock()
client, err := c.createAPIClientForInstallation(installationID)
if err != nil {
return nil, err
}
c.mu.Lock()
defer c.mu.Unlock()
clients := c.clientsForInstallationLocked(installationID)
if clients.apiClient != nil {
return clients.apiClient, nil
}
clients.apiClient = client
return client, nil
}
func (c *appConnector) clientsForInstallationLocked(installationID int64) *appInstallationClients {
clients := c.clientsByInstallationID[installationID]
if clients == nil {
clients = &appInstallationClients{}
c.clientsByInstallationID[installationID] = clients
}
return clients
}
func (c *appConnector) createAPIClientForInstallation(installationID int64) (*github.Client, error) {
appsTransport, err := newAppsTransport(c.apiEndpoint, c.appID, c.appPrivateKey)
if err != nil {
return nil, fmt.Errorf("could not create app transport for installation %d: %w", installationID, err)
}
// NewFromAppsTransport inherits BaseURL from appsTransport, which
// newAppsTransport already set correctly (incl. GHE.com). Don't override it
// with the raw endpoint here or GHE.com token refresh would 401.
transport := ghinstallation.NewFromAppsTransport(appsTransport, installationID)
client, err := newGitHubClientWithTransport(c.apiEndpoint, transport)
if err != nil {
return nil, fmt.Errorf("could not create API client for installation %d: %w", installationID, err)
}
return client, nil
}
func newAppsTransport(apiEndpoint string, appID int64, privateKey []byte) (*ghinstallation.AppsTransport, error) {
httpClient := common.RetryableHTTPClientTimeout(githubHTTPTimeoutSeconds)
appsTransport, err := ghinstallation.NewAppsTransport(httpClient.Transport, appID, privateKey)
if err != nil {
return nil, err
}
// ghinstallation uses BaseURL to build the token exchange URL
// {BaseURL}/app/installations/{id}/access_tokens. For GHE.com this must be
// the api.SUBDOMAIN.ghe.com base with no /api/v3 and no trailing slash
// (which ghinstallation does not expect), otherwise token refresh 401s.
baseURL, err := appsBaseURL(apiEndpoint)
if err != nil {
return nil, err
}
appsTransport.BaseURL = baseURL
return appsTransport, nil
}
// appsBaseURL returns the BaseURL that ghinstallation transports should use for
// token exchange/refresh. For GHE.com it resolves to the api.* subdomain with
// the trailing slash trimmed; for github.com and GHES it is the endpoint as-is.
func appsBaseURL(apiEndpoint string) (string, error) {
if isGHECloud(apiEndpoint) {
normalizedURL, err := normalizeGHECloudAPIEndpoint(apiEndpoint)
if err != nil {
return "", fmt.Errorf("could not normalize GHE.com endpoint: %w", err)
}
return strings.TrimRight(normalizedURL, "/"), nil
}
return apiEndpoint, nil
}
func newGitHubClientWithTransport(apiEndpoint string, transport http.RoundTripper) (*github.Client, error) {
httpClient := common.RetryableHTTPClientTimeout(githubHTTPTimeoutSeconds)
httpClient.Transport = transport
// GHE.com (GHEC with data residency) serves its REST API at the root of
// api.SUBDOMAIN.ghe.com (like api.github.com), NOT under the GHES /api/v3 path.
if isGHECloud(apiEndpoint) {
return createGHECloudClient(httpClient, apiEndpoint)
}
return github.NewClient(httpClient).WithEnterpriseURLs(apiEndpoint, apiEndpoint)
}