Files
trufflehog/pkg/sources/github/connector_app.go
Dustin Decker 39a1435e80 Add scan_all_installations option for multi-org GitHub App scanning (#4775)
* Support scanning all GitHub App installations

Add scan_all_installations handling for GitHub App sources so multi-installation scans use the correct per-installation clients, token mapping, and fail-closed repo ownership checks.

* Cache per-installation GitHub App API clients

Avoid creating a new client and token negotiation on every repo operation by caching clients per installation ID, reusing the configured default client, and giving each cached client its own apps transport to avoid races on the shared transport's token-refresh path.

* Cache per-installation GraphQL clients and hoist URL scheme regex

Reuse GraphQL clients for non-default installations the same way REST clients are cached, and compile the URL scheme regex once at package level instead of on every call.

* Log per-installation mapping errors on early return

When all requested repos get mapped, failures from other installations no longer disappear silently; they're logged before the success return.

* Report per-installation enumeration failures without aborting scan-all

A transient failure in one installation no longer aborts the whole multi-org enumeration; it's reported via the unit reporter and remaining installations continue. Also avoid nested mutex acquisition in GraphQLClientForRepo with a double-check cache pattern.

* Report member enumeration failures and use nil-safe installation getters

Surface per-org member enumeration failures through the unit reporter instead of only logging them, and use GetAccount() getters for installation account access.

* Simplify GitHub App installation setup

* Address GitHub App connector review followups

Unify per-installation client state, centralize GitHub URL helpers, and tighten wiki/endpoint handling so multi-installation scans keep auth routing explicit and maintainable.

* Harden GitHub App multi-installation scans

Surface partial installation enumeration failures, tighten wiki URL disambiguation, and make rate-limit waits cancellation-aware so scan-all behavior is safer and easier to diagnose.

* Fix GitHub App ID parse error message

Report the original app ID string when parsing fails so invalid configuration errors are actionable.

* Avoid bogus wiki alias mappings

Only derive double-wiki installation aliases when repository metadata confirms the repo itself is named with a .wiki suffix.
2026-06-24 17:07:01 -07:00

280 lines
8.9 KiB
Go

package github
import (
"fmt"
"net/http"
"strconv"
"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) (Connector, 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)
}
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),
}
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
}
func (c *appConnector) APIClient() *github.Client {
c.mu.RLock()
defer c.mu.RUnlock()
if clients := c.clientsByInstallationID[c.installationID]; clients != nil {
return clients.apiClient
}
return nil
}
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)
// 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)
}
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
}
}
func (c *appConnector) GraphQLClient() *githubv4.Client {
c.mu.RLock()
defer c.mu.RUnlock()
if clients := c.clientsByInstallationID[c.installationID]; clients != nil {
return clients.graphqlClient
}
return nil
}
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)
}
transport := ghinstallation.NewFromAppsTransport(appsTransport, installationID)
transport.BaseURL = c.apiEndpoint
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
}
appsTransport.BaseURL = apiEndpoint
return appsTransport, nil
}
func newGitHubClientWithTransport(apiEndpoint string, transport http.RoundTripper) (*github.Client, error) {
httpClient := common.RetryableHTTPClientTimeout(githubHTTPTimeoutSeconds)
httpClient.Transport = transport
return github.NewClient(httpClient).WithEnterpriseURLs(apiEndpoint, apiEndpoint)
}