package github import ( "bytes" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" "errors" "fmt" "net/http" "net/url" "reflect" "slices" "strconv" "strings" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/google/go-github/v67/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" "google.golang.org/protobuf/types/known/anypb" "gopkg.in/h2non/gock.v1" "github.com/trufflesecurity/trufflehog/v3/pkg/cache/simple" "github.com/trufflesecurity/trufflehog/v3/pkg/context" "github.com/trufflesecurity/trufflehog/v3/pkg/pb/credentialspb" "github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb" "github.com/trufflesecurity/trufflehog/v3/pkg/sources" ) func createPrivateKey() string { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { panic(err) } data := x509.MarshalPKCS1PrivateKey(key) var pemKey bytes.Buffer if err := pem.Encode(&pemKey, &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: data, }); err != nil { panic(err) } return pemKey.String() } func createTestSource(src *sourcespb.GitHub) (*Source, *anypb.Any) { s := &Source{} conn, err := anypb.New(src) if err != nil { panic(err) } return s, conn } func initTestSource(src *sourcespb.GitHub) *Source { s, conn := createTestSource(src) if err := s.Init(context.Background(), "test - github", 0, 1337, false, conn, 1); err != nil { panic(err) } gock.InterceptClient(s.connector.APIClient().Client()) if appConnector, ok := s.connector.(*appConnector); ok { gock.InterceptClient(appConnector.InstallationClient().Client()) } return s } func TestInit(t *testing.T) { source, conn := createTestSource(&sourcespb.GitHub{ Repositories: []string{"https://github.com/dustin-decker/secretsandstuff.git"}, Credential: &sourcespb.GitHub_Token{ Token: "super secret token", }, }) err := source.Init(context.Background(), "test - github", 0, 1337, false, conn, 1) assert.Nil(t, err) // TODO: test error case } func TestAddReposByOrg(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/orgs/super-secret-org/repos"). Reply(200). JSON([]map[string]string{ {"clone_url": "https://github.com/super-secret-repo.git", "full_name": "super-secret-repo"}, {"clone_url": "https://github.com/super-secret-repo2.git", "full_name": "secret/super-secret-repo2"}, }) s := initTestSource(&sourcespb.GitHub{ Credential: &sourcespb.GitHub_Token{ Token: "super secret token", }, Repositories: nil, IgnoreRepos: []string{"secret/super-*-repo2"}, CommentsTimeframeDays: 10, }) err := s.getReposByOrg(context.Background(), "super-secret-org", noopReporter()) assert.Nil(t, err) assert.Equal(t, 1, s.filteredRepoCache.Count()) ok := s.filteredRepoCache.Exists("super-secret-repo") assert.True(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestAddReposByOrg_Repositories(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/orgs/super-secret-org/repos"). Reply(200). JSON(`[ {"full_name": "super-secret-org/super-secret-repo", "clone_url": "https://github.com/super-secret-org/super-secret-repo.git", "size": 1}, {"full_name": "super-secret-org/super-secret-repo2", "clone_url": "https://github.com/super-secret-org/super-secret-repo2.git", "size": 1}, {"full_name": "super-secret-org/not-super-secret-repo", "clone_url": "https://github.com/super-secret-org/not-super-secret-repo.git", "size": 1} ]`) s := initTestSource(&sourcespb.GitHub{ Credential: &sourcespb.GitHub_Token{ Token: "super secret token", }, Repositories: []string{"super-secret-org/super-secret-repo", "super-secret-org/super-secret-repo2"}, Organizations: []string{"super-secret-org"}, }) err := s.getReposByOrg(context.Background(), "super-secret-org", noopReporter()) assert.Nil(t, err) assert.Equal(t, 2, s.filteredRepoCache.Count()) ok := s.filteredRepoCache.Exists("super-secret-org/super-secret-repo") assert.True(t, ok) ok = s.filteredRepoCache.Exists("super-secret-org/super-secret-repo2") assert.True(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestAddReposByOrg_IncludeRepos(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/orgs/super-secret-org/repos"). Reply(200). JSON(`[ {"full_name": "super-secret-org/super-secret-repo", "clone_url": "https://github.com/super-secret-org/super-secret-repo.git", "size": 1}, {"full_name": "super-secret-org/super-secret-repo2", "clone_url": "https://github.com/super-secret-org/super-secret-repo2.git", "size": 1}, {"full_name": "super-secret-org/not-super-secret-repo", "clone_url": "https://github.com/super-secret-org/not-super-secret-repo.git", "size": 1} ]`) s := initTestSource(&sourcespb.GitHub{ Credential: &sourcespb.GitHub_Token{ Token: "super secret token", }, IncludeRepos: []string{"super-secret-org/super*"}, Organizations: []string{"super-secret-org"}, }) err := s.getReposByOrg(context.Background(), "super-secret-org", noopReporter()) assert.Nil(t, err) assert.Equal(t, 2, s.filteredRepoCache.Count()) ok := s.filteredRepoCache.Exists("super-secret-org/super-secret-repo") assert.True(t, ok) ok = s.filteredRepoCache.Exists("super-secret-org/super-secret-repo2") assert.True(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestAddReposByUser(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/users/super-secret-user/repos"). Reply(200). JSON([]map[string]string{ {"full_name": "super-secret-user/super-secret-repo", "clone_url": "https://github.com/super-secret-user/super-secret-repo.git"}, {"full_name": "super-secret-user/super-secret-repo2", "clone_url": "https://github.com/super-secret-user/super-secret-repo2.git"}, }) s := initTestSource(&sourcespb.GitHub{ Credential: &sourcespb.GitHub_Token{ Token: "super secret token", }, IgnoreRepos: []string{"super-secret-user/super-secret-repo2"}, }) err := s.getReposByUser(context.Background(), "super-secret-user", false, noopReporter()) assert.Nil(t, err) assert.Equal(t, 1, s.filteredRepoCache.Count()) ok := s.filteredRepoCache.Exists("super-secret-user/super-secret-repo") assert.True(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestAddGistsByUser(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/users/super-secret-user/gists"). Reply(200). JSON([]map[string]string{{"id": "aa5a315d61ae9438b18d", "git_pull_url": "https://gist.github.com/aa5a315d61ae9438b18d.git"}}) s := initTestSource(&sourcespb.GitHub{Credential: &sourcespb.GitHub_Unauthenticated{}}) err := s.addUserGistsToCache(context.Background(), "super-secret-user", noopReporter()) assert.Nil(t, err) assert.Equal(t, 1, s.filteredRepoCache.Count()) ok := s.filteredRepoCache.Exists("aa5a315d61ae9438b18d") assert.True(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestIgnoreGistsByUser(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/users/super-secret-user/gists"). Reply(200). JSON([]map[string]string{{"id": "aa5a315d61ae9438b18d", "git_pull_url": "https://gist.github.com/aa5a315d61ae9438b18d.git"}}) s := initTestSource(&sourcespb.GitHub{Credential: &sourcespb.GitHub_Unauthenticated{}}) s.ignoreGists = true err := s.addUserGistsToCache(context.Background(), "super-secret-user", noopReporter()) assert.Nil(t, err) assert.Equal(t, 0, s.filteredRepoCache.Count()) ok := s.filteredRepoCache.Exists("aa5a315d61ae9438b18d") assert.False(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.False(t, gock.IsDone()) } func TestAddMembersByOrg(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/orgs/org1/members"). Reply(200). JSON([]map[string]string{ {"login": "testman1"}, {"login": "testman2"}, }) s := initTestSource(&sourcespb.GitHub{Credential: &sourcespb.GitHub_Unauthenticated{}}) err := s.addMembersByOrg(context.Background(), "org1", noopReporter()) assert.Nil(t, err) assert.Equal(t, 2, len(s.memberCache)) _, ok := s.memberCache["testman1"] assert.True(t, ok) _, ok = s.memberCache["testman2"] assert.True(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestAddMembersByOrg_AuthFailure(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/orgs/org1/members"). Reply(401). JSON([]map[string]string{{ "message": "Bad credentials", "documentation_url": "https://docs.github.com/rest", "status": "401", }}) s := initTestSource(&sourcespb.GitHub{Credential: &sourcespb.GitHub_Unauthenticated{}}) err := s.addMembersByOrg(context.Background(), "org1", noopReporter()) assert.True(t, strings.HasPrefix(err.Error(), "could not list organization")) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestAddMembersByOrg_NoMembers(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/orgs/org1/members"). Reply(200). JSON([]map[string]string{}) s := initTestSource(&sourcespb.GitHub{Credential: &sourcespb.GitHub_Unauthenticated{}}) err := s.addMembersByOrg(context.Background(), "org1", noopReporter()) assert.Equal(t, fmt.Sprintf("organization (%q) had 0 members: account may not have access to list organization members", "org1"), err.Error()) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestAddMembersByApp(t *testing.T) { defer gock.Off() privateKey := createPrivateKey() gock.New("https://api.github.com"). Get("/app/installations"). Reply(200). JSON([]map[string]any{ {"account": map[string]string{"login": "super-secret-org", "type": "Organization"}}, }) gock.New("https://api.github.com"). Post("/app/installations/1337/access_tokens"). Reply(200). JSON(map[string]string{"token": "dontlook"}) gock.New("https://api.github.com"). Get("/orgs/super-secret-org/members"). Reply(200). JSON([]map[string]any{ {"login": "ssm1"}, {"login": "ssm2"}, {"login": "ssm3"}, }) s := initTestSource(&sourcespb.GitHub{ Endpoint: "https://api.github.com", Credential: &sourcespb.GitHub_GithubApp{ GithubApp: &credentialspb.GitHubApp{ PrivateKey: privateKey, InstallationId: "1337", AppId: "4141", }, }}) err := s.addMembersByApp(context.Background(), s.connector.(*appConnector).InstallationClient(), noopReporter()) assert.Nil(t, err) assert.Equal(t, 3, len(s.memberCache)) _, ok := s.memberCache["ssm1"] assert.True(t, ok) _, ok = s.memberCache["ssm2"] assert.True(t, ok) _, ok = s.memberCache["ssm3"] assert.True(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestAddReposByApp(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/installation/repositories"). Reply(200). JSON(map[string]any{ "repositories": []map[string]string{ {"clone_url": "https://github/ssr1.git", "full_name": "ssr1"}, {"clone_url": "https://github/ssr2.git", "full_name": "ssr2"}, }, }) s := initTestSource(&sourcespb.GitHub{Credential: &sourcespb.GitHub_Unauthenticated{}}) err := s.getReposByApp(context.Background(), noopReporter()) assert.Nil(t, err) assert.Equal(t, 2, s.filteredRepoCache.Count()) ok := s.filteredRepoCache.Exists("ssr1") assert.True(t, ok) ok = s.filteredRepoCache.Exists("ssr2") assert.True(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestAddOrgsByUser(t *testing.T) { defer gock.Off() // NOTE: addOrgsByUser calls /user/orgs to get the orgs of the // authenticated user gock.New("https://api.github.com"). Get("/user/orgs"). Reply(200). JSON([]map[string]any{ {"login": "sso2"}, }) s := initTestSource(&sourcespb.GitHub{Credential: &sourcespb.GitHub_Unauthenticated{}}) s.addOrgsByUser(context.Background(), "super-secret-user", noopReporter()) assert.Equal(t, 1, s.orgsCache.Count()) ok := s.orgsCache.Exists("sso2") assert.True(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestNormalizeRepos(t *testing.T) { defer gock.Off() tests := []struct { name string setup func() repos []string expected map[string]struct{} wantErr bool }{ { name: "repo url", setup: func() {}, repos: []string{"https://github.com/super-secret-user/super-secret-repo"}, expected: map[string]struct{}{ "https://github.com/super-secret-user/super-secret-repo.git": {}, }, }, { name: "not found", setup: func() { gock.New("https://api.github.com"). Get("/users/not-found/gists"). Reply(404) gock.New("https://api.github.com"). Get("/users/not-found/repos"). Reply(404) }, repos: []string{"not-found"}, expected: map[string]struct{}{}, wantErr: true, }, { name: "unexpected format", setup: func() {}, repos: []string{"/foo/"}, expected: map[string]struct{}{}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { defer gock.Off() tt.setup() s := initTestSource(&sourcespb.GitHub{Credential: &sourcespb.GitHub_Unauthenticated{}}) got, err := s.normalizeRepo(tt.repos[0]) if (err != nil) != tt.wantErr { t.Errorf("normalizeRepo() error = %v, wantErr %v", err, tt.wantErr) return } if got != "" { for k := range tt.expected { assert.Equal(t, got, k) } } res := make(map[string]struct{}, s.filteredRepoCache.Count()) for _, v := range s.filteredRepoCache.Keys() { res[v] = struct{}{} } if got == "" && !cmp.Equal(res, tt.expected) { t.Errorf("normalizeRepo() got = %v, want %v", s.repos, tt.expected) } }) } } func TestNormalizeRepo(t *testing.T) { // Test that normalizeRepo correctly identifies URLs with protocols source := &Source{} // Test case 1: HTTP URL result, err := source.normalizeRepo("https://github.com/org/repo.git") assert.NoError(t, err) assert.Contains(t, result, "github.com/org/repo") // Test case 2: HTTP URL without .git result, err = source.normalizeRepo("http://github.com/org/repo") assert.NoError(t, err) assert.Contains(t, result, "github.com/org/repo") // Test case 3: Git protocol URL result, err = source.normalizeRepo("git://github.com/org/repo.git") assert.NoError(t, err) assert.Contains(t, result, "github.com/org/repo") // Test case 4: SSH URL result, err = source.normalizeRepo("ssh://git@github.com/org/repo.git") assert.NoError(t, err) assert.Contains(t, result, "github.com/org/repo") // Test case 5: Org/repo format (should convert to full URL) result, err = source.normalizeRepo("org/repo") assert.NoError(t, err) assert.Contains(t, result, "github.com/org/repo") // Test case 6: Invalid format (no protocol, no slash) _, err = source.normalizeRepo("invalid") assert.Error(t, err) assert.Contains(t, err.Error(), "no repositories found") } func TestNormalizeRepo_Enterprise(t *testing.T) { tests := []struct { name string endpoint string wantResult string }{ { name: "only host", endpoint: "https://example.com", wantResult: "https://example.com/org/repo.git", }, { name: "host with path", endpoint: "https://example.com/api/v3", wantResult: "https://example.com/org/repo.git", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { source := Source{ conn: &sourcespb.GitHub{ Endpoint: tt.endpoint, }, } result, err := source.normalizeRepo("org/repo") assert.NoError(t, err) assert.Equal(t, tt.wantResult, result) }) } } func TestHandleRateLimit(t *testing.T) { s := initTestSource(&sourcespb.GitHub{Credential: &sourcespb.GitHub_Unauthenticated{}}) ctx := context.Background() assert.False(t, s.handleRateLimit(ctx, nil)) // Request reqUrl, _ := url.Parse("https://github.com/trufflesecurity/trufflehog") res := &github.Response{ Response: &http.Response{ StatusCode: 429, Header: make(http.Header), Request: &http.Request{ Method: "GET", URL: reqUrl, }, }, } res.Header.Set("x-ratelimit-remaining", "0") res.Header.Set("x-ratelimit-reset", strconv.FormatInt(time.Now().Unix()+1, 10)) // Error resetTime := github.Timestamp{ Time: time.Now().Add(time.Millisecond), } err := &github.RateLimitError{ Rate: github.Rate{ Limit: 5000, Remaining: 0, Reset: resetTime, }, Response: res.Response, Message: "Too Many Requests", } assert.True(t, s.handleRateLimit(ctx, err)) } func TestEnumerateUnauthenticated(t *testing.T) { defer gock.Off() apiEndpoint := "https://api.github.com" gock.New(apiEndpoint). Get("/orgs/super-secret-org/repos"). Reply(200). JSON([]map[string]string{{"full_name": "super-secret-org/super-secret-repo", "clone_url": "https://github.com/super-secret-org/super-secret-repo.git"}}) s := initTestSource(&sourcespb.GitHub{ Endpoint: apiEndpoint, Credential: &sourcespb.GitHub_Unauthenticated{}, }) s.orgsCache = simple.NewCache[string]() s.orgsCache.Set("super-secret-org", "super-secret-org") // s.enumerateUnauthenticated(context.Background(), apiEndpoint) s.enumerateUnauthenticated(context.Background(), noopReporter()) assert.Equal(t, 1, s.filteredRepoCache.Count()) ok := s.filteredRepoCache.Exists("super-secret-org/super-secret-repo") assert.True(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestEnumerateWithToken(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/user"). Reply(200). JSON(map[string]string{"login": "super-secret-user"}) gock.New("https://api.github.com"). Get("/user/repos"). MatchParam("per_page", "100"). Reply(200). JSON([]map[string]string{{"clone_url": "https://github.com/super-secret-user/super-secret-repo.git", "full_name": "super-secret-user/super-secret-repo"}}) gock.New("https://api.github.com"). Get("/user/orgs"). MatchParam("per_page", "100"). Reply(200). JSON(`[]`) gock.New("https://api.github.com"). Get("/users/super-secret-user/gists"). Reply(200). JSON([]map[string]string{{"id": "super-secret-gist", "git_pull_url": "https://gist.github.com/super-secret-gist.git"}}) s := initTestSource(&sourcespb.GitHub{ Endpoint: "https://api.github.com", Credential: &sourcespb.GitHub_Token{ Token: "token", }, }) err := s.enumerateWithToken(context.Background(), false, noopReporter()) assert.Nil(t, err) assert.Equal(t, 2, s.filteredRepoCache.Count()) ok := s.filteredRepoCache.Exists("super-secret-user/super-secret-repo") assert.True(t, ok) ok = s.filteredRepoCache.Exists("super-secret-gist") assert.True(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func BenchmarkEnumerateWithToken(b *testing.B) { defer gock.Off() gock.New("https://api.github.com"). Get("/user"). Reply(200). JSON(map[string]string{"login": "super-secret-user"}) gock.New("https://api.github.com"). Get("/users/super-secret-user/repos"). Reply(200). JSON([]map[string]string{{"clone_url": "https://github.com/super-secret-repo.git"}}) gock.New("https://api.github.com"). Get("/user/orgs"). MatchParam("per_page", "100"). Reply(200). JSON([]map[string]string{{"clone_url": "https://github.com/super-secret-repo.git"}}) gock.New("https://api.github.com"). Get("/users/super-secret-user/gists"). Reply(200). JSON([]map[string]string{{"git_pull_url": "https://github.com/super-secret-gist.git"}}) s := initTestSource(&sourcespb.GitHub{ Endpoint: "https://api.github.com", Credential: &sourcespb.GitHub_Token{ Token: "token", }, }) b.ResetTimer() for i := 0; i < b.N; i++ { _ = s.enumerateWithToken(context.Background(), false, noopReporter()) } } func TestEnumerate(t *testing.T) { defer gock.Off() // Arrange gock.New("https://api.github.com"). Get("/user"). Reply(200). JSON(map[string]string{"login": "super-secret-user"}) // gock.New("https://api.github.com"). Get("/user/repos"). Reply(200). JSON(`[{"name": "super-secret-repo", "full_name": "super-secret-user/super-secret-repo", "owner": {"login": "super-secret-user"}, "clone_url": "https://github.com/super-secret-user/super-secret-repo.git", "has_wiki": false, "size": 1}]`) gock.New("https://api.github.com"). Get("/user/orgs"). MatchParam("per_page", "100"). Reply(200). JSON([]map[string]string{{"clone_url": "https://github.com/super-secret-user/super-secret-repo.git", "full_name": "super-secret-user/super-secret-repo"}}) gock.New("https://api.github.com"). Get("/users/super-secret-user/gists"). Reply(200). JSON(`[{"git_pull_url": "https://gist.github.com/2801a2b0523099d0614a951579d99ba9.git", "id": "2801a2b0523099d0614a951579d99ba9"}]`) s := initTestSource(&sourcespb.GitHub{ Endpoint: "https://api.github.com", Credential: &sourcespb.GitHub_Token{ Token: "super secret token", }, }) // Manually cache a repository to ensure that enumerate // doesn't make duplicate API calls. // See https://github.com/trufflesecurity/trufflehog/pull/2625 repo := func() *github.Repository { var ( name = "cached-repo" fullName = "cached-user/cached-repo" login = "cached-user" cloneUrl = "https://github.com/cached-user/cached-repo.git" owner = &github.User{ Login: &login, } hasWiki = false size = 1234 ) return &github.Repository{ Name: &name, FullName: &fullName, Owner: owner, HasWiki: &hasWiki, Size: &size, CloneURL: &cloneUrl, } }() s.cacheRepoInfo(repo) s.filteredRepoCache.Set(repo.GetFullName(), repo.GetCloneURL()) var reportedRepos []string reporter := sources.VisitorReporter{ VisitUnit: func(ctx context.Context, su sources.SourceUnit) error { url, _ := su.SourceUnitID() reportedRepos = append(reportedRepos, url) return nil }, } // Act err := s.Enumerate(context.Background(), reporter) slices.Sort(reportedRepos) // Assert assert.Nil(t, err) // Enumeration found all repos. assert.Equal(t, 3, s.filteredRepoCache.Count()) assert.True(t, s.filteredRepoCache.Exists("super-secret-user/super-secret-repo")) assert.True(t, s.filteredRepoCache.Exists("cached-user/cached-repo")) assert.True(t, s.filteredRepoCache.Exists("2801a2b0523099d0614a951579d99ba9")) assert.Equal(t, 3, len(s.repos)) assert.Equal(t, s.repos, reportedRepos) // Enumeration cached all repos. assert.Equal(t, 3, len(s.repoInfoCache.cache)) _, ok := s.repoInfoCache.get("https://github.com/super-secret-user/super-secret-repo.git") assert.True(t, ok) _, ok = s.repoInfoCache.get("https://github.com/cached-user/cached-repo.git") assert.True(t, ok) _, ok = s.repoInfoCache.get("https://gist.github.com/2801a2b0523099d0614a951579d99ba9.git") assert.True(t, ok) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func setupMocks(b *testing.B) { b.Helper() gock.New("https://api.github.com"). Get("/user"). Reply(200). JSON(map[string]string{"login": "super-secret-user"}) gock.New("https://api.github.com"). Get("/users/super-secret-user/repos"). Reply(200). JSON(mockRepos()) gock.New("https://api.github.com"). Get("/user/orgs"). MatchParam("per_page", "100"). Reply(200). JSON([]map[string]string{{"clone_url": "https://github.com/super-secret-repo.git"}}) gock.New("https://api.github.com"). Get("/users/super-secret-user/gists"). Reply(200). JSON(mockGists()) } func mockRepos() []map[string]string { res := make([]map[string]string, 0, 10000) for i := 0; i < 10000; i++ { res = append(res, map[string]string{"clone_url": fmt.Sprintf("https://githu/super-secret-repo-%d.git", i)}) } return res } func mockGists() []map[string]string { res := make([]map[string]string, 0, 100) for i := 0; i < 100; i++ { res = append(res, map[string]string{"git_pull_url": fmt.Sprintf("https://githu/super-secret-gist-%d.git", i)}) } return res } func BenchmarkEnumerate(b *testing.B) { for i := 0; i < b.N; i++ { s := initTestSource(&sourcespb.GitHub{ Endpoint: "https://api.github.com", Credential: &sourcespb.GitHub_Token{ Token: "super secret token", }, }) setupMocks(b) b.StartTimer() _ = s.Enumerate(context.Background(), noopReporter()) } } func TestEnumerateWithToken_Repositories(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/user"). Reply(200). JSON(map[string]string{"login": "super-secret-user"}) s := initTestSource(&sourcespb.GitHub{ Endpoint: "https://api.github.com", Credential: &sourcespb.GitHub_Token{ Token: "token", }, }) s.repos = []string{"some-special-repo"} err := s.enumerateWithToken(context.Background(), false, noopReporter()) assert.Nil(t, err) assert.Equal(t, 1, len(s.repos)) assert.Equal(t, []string{"some-special-repo"}, s.repos) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestEnumerateWithToken_IncludeRepos(t *testing.T) { defer gock.Off() gock.New("https://api.github.com"). Get("/user"). Reply(200). JSON(map[string]string{"login": "super-secret-user"}) s := initTestSource(&sourcespb.GitHub{ Endpoint: "https://api.github.com", Credential: &sourcespb.GitHub_Token{ Token: "token", }, }) s.repos = []string{"some-special-repo"} err := s.enumerateWithToken(context.Background(), false, noopReporter()) assert.Nil(t, err) assert.Equal(t, 1, len(s.repos)) assert.Equal(t, []string{"some-special-repo"}, s.repos) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } func TestEnumerateWithApp(t *testing.T) { defer gock.Off() privateKey := createPrivateKey() gock.New("https://api.github.com"). Post("/app/installations/1337/access_tokens"). Reply(200). JSON(map[string]string{"token": "dontlook"}) gock.New("https://api.github.com"). Get("/installation/repositories"). Reply(200). JSON(map[string]string{}) s := initTestSource(&sourcespb.GitHub{ Endpoint: "https://api.github.com", Credential: &sourcespb.GitHub_GithubApp{ GithubApp: &credentialspb.GitHubApp{ PrivateKey: privateKey, InstallationId: "1337", AppId: "4141", }, }, }) err := s.enumerateWithApp(context.Background(), s.connector.(*appConnector).InstallationClient(), noopReporter()) assert.Nil(t, err) assert.Equal(t, 0, len(s.repos)) assert.False(t, gock.HasUnmatchedRequest()) assert.True(t, gock.IsDone()) } // This only tests the resume info slice portion of setProgressCompleteWithRepo. func Test_setProgressCompleteWithRepo_resumeInfo(t *testing.T) { tests := []struct { startingResumeInfoSlice []string repoURL string wantResumeInfoSlice []string }{ { startingResumeInfoSlice: []string{}, repoURL: "a", wantResumeInfoSlice: []string{"a"}, }, { startingResumeInfoSlice: []string{"b"}, repoURL: "a", wantResumeInfoSlice: []string{"a", "b"}, }, } s := &Source{ repos: []string{}, } for _, tt := range tests { s.resumeInfoSlice = tt.startingResumeInfoSlice s.setProgressCompleteWithRepo(0, 0, tt.repoURL) if !reflect.DeepEqual(s.resumeInfoSlice, tt.wantResumeInfoSlice) { t.Errorf("s.setProgressCompleteWithRepo() got: %v, want: %v", s.resumeInfoSlice, tt.wantResumeInfoSlice) } } } func Test_setProgressCompleteWithRepo_Progress(t *testing.T) { repos := []string{"a", "b", "c", "d", "e"} tests := map[string]struct { repos []string index int offset int wantPercentComplete int64 wantSectionsCompleted int32 wantSectionsRemaining int32 }{ "starting from the beginning, no offset": { repos: repos, index: 0, offset: 0, wantPercentComplete: 0, wantSectionsCompleted: 0, wantSectionsRemaining: 5, }, "resume from the third, offset 2": { repos: repos[2:], index: 0, offset: 2, wantPercentComplete: 40, wantSectionsCompleted: 2, wantSectionsRemaining: 5, }, "resume from the third, on last repo, offset 2": { repos: repos[2:], index: 2, offset: 2, wantPercentComplete: 80, wantSectionsCompleted: 4, wantSectionsRemaining: 5, }, } for _, tt := range tests { s := &Source{ repos: tt.repos, } s.setProgressCompleteWithRepo(tt.index, tt.offset, "") gotProgress := s.GetProgress() if gotProgress.PercentComplete != tt.wantPercentComplete { t.Errorf("s.setProgressCompleteWithRepo() PercentComplete got: %v want: %v", gotProgress.PercentComplete, tt.wantPercentComplete) } if gotProgress.SectionsCompleted != tt.wantSectionsCompleted { t.Errorf("s.setProgressCompleteWithRepo() PercentComplete got: %v want: %v", gotProgress.SectionsCompleted, tt.wantSectionsCompleted) } if gotProgress.SectionsRemaining != tt.wantSectionsRemaining { t.Errorf("s.setProgressCompleteWithRepo() PercentComplete got: %v want: %v", gotProgress.SectionsRemaining, tt.wantSectionsRemaining) } } } func Test_scan_SetProgressComplete(t *testing.T) { testCases := []struct { name string repos []string wantComplete bool wantErr bool }{ { name: "no repos", wantComplete: true, }, { name: "one valid repo", repos: []string{"https://github.com/super-secret-user/super-secret-repo.git"}, wantComplete: true, wantErr: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { src := initTestSource(&sourcespb.GitHub{ Repositories: tc.repos, Credential: &sourcespb.GitHub_Unauthenticated{}, }) src.jobPool = &errgroup.Group{} _ = src.scan(context.Background(), nil) if !tc.wantErr { assert.Equal(t, "", src.GetProgress().EncodedResumeInfo) } gotComplete := src.GetProgress().PercentComplete == 100 if gotComplete != tc.wantComplete { t.Errorf("got: %v, want: %v", gotComplete, tc.wantComplete) } }) } } func TestGetRepoURLParts(t *testing.T) { repoURLs := []string{ "https://github.com/trufflesecurity/trufflehog.git", "git+https://github.com/trufflesecurity/trufflehog.git", "ssh://github.com/trufflesecurity/trufflehog.git", "ssh://git@github.com/trufflesecurity/trufflehog.git", "git+ssh://git@github.com/trufflesecurity/trufflehog.git", "git://github.com/trufflesecurity/trufflehog.git", } expected := []string{"github.com", "trufflesecurity", "trufflehog"} for _, tt := range repoURLs { _, parts, err := getRepoURLParts(tt) if err != nil { t.Fatalf("failed: %v", err) } assert.Equal(t, expected, parts) } gistURLs := map[string][]string{ // Gists "ssh://github.com/6df198861306313246466d23aa4102aa.git": nil, "ssh://gist.github.com/6df198861306313246466d23aa4102aa.git": {"gist.github.com", "6df198861306313246466d23aa4102aa"}, "https://gist.github.com/6df198861306313246466d23aa4102aa.git": {"gist.github.com", "6df198861306313246466d23aa4102aa"}, "https://gist.github.com/john-smith/6df198861306313246466d23aa4102aa.git": {"gist.github.com", "john-smith", "6df198861306313246466d23aa4102aa"}, "ssh://github.contoso.com/gist/6df198861306313246466d23aa4102aa.git": {"github.contoso.com", "gist", "6df198861306313246466d23aa4102aa"}, "https://github.contoso.com/gist/6df198861306313246466d23aa4102aa.git": {"github.contoso.com", "gist", "6df198861306313246466d23aa4102aa"}, "https://github.contoso.com/gist/john-smith/6df198861306313246466d23aa4102aa.git": {"github.contoso.com", "gist", "john-smith", "6df198861306313246466d23aa4102aa"}, "https://github.com/gist/john-smith/6df198861306313246466d23aa4102aa.git": nil, } for tt, expected := range gistURLs { _, parts, err := getRepoURLParts(tt) if err != nil { if expected == nil { continue } t.Fatalf("failed: %v", err) } assert.Equal(t, expected, parts) } } func TestGetRepoURLPartsWithTrailingHyphen(t *testing.T) { // Test for https://github.com/trufflesecurity/trufflehog/issues/4679 // Repository names ending with a hyphen should be preserved correctly. testCases := []struct { name string url string expected []string }{ { name: "https with trailing hyphen", url: "https://github.com/MYORG/my-repo-name-.git", expected: []string{"github.com", "MYORG", "my-repo-name-"}, }, { name: "https with trailing hyphen no .git", url: "https://github.com/MYORG/my-repo-.git", expected: []string{"github.com", "MYORG", "my-repo-"}, }, { name: "ssh with trailing hyphen", url: "ssh://git@github.com/MYORG/test-repo-.git", expected: []string{"github.com", "MYORG", "test-repo-"}, }, { name: "multiple hyphens with trailing", url: "https://github.com/org-name/my-test-repo-.git", expected: []string{"github.com", "org-name", "my-test-repo-"}, }, { name: "single trailing hyphen repo", url: "https://github.com/Org/-.git", expected: []string{"github.com", "Org", "-"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { _, parts, err := getRepoURLParts(tc.url) if err != nil { t.Fatalf("unexpected error: %v", err) } assert.Equal(t, tc.expected, parts) }) } } func TestGetGistID(t *testing.T) { tests := []struct { trimmedURL []string expected string }{ {[]string{"https://gist.github.com", "12345"}, "12345"}, {[]string{"https://gist.github.com", "owner", "12345"}, "12345"}, } for _, tt := range tests { got := extractGistID(tt.trimmedURL) assert.Equal(t, tt.expected, got) } } // This isn't really a GitHub test, but GitHub is the only source that supports scan targeting right now, so this is // where I've put this targeted scan test. func Test_ScanMultipleTargets_MultipleErrors(t *testing.T) { s := &Source{conn: &sourcespb.GitHub{}} // This test doesn't require initialization ctx := context.Background() chunksChan := make(chan *sources.Chunk) targets := []sources.ChunkingTarget{ {SecretID: 1}, {SecretID: 2}, } // The specific error text doesn't matter for the test, but it has to match what the source generates want := []*sources.TargetedScanError{ {SecretID: 1, Err: errors.New("unable to cast metadata type for targeted scan")}, {SecretID: 2, Err: errors.New("unable to cast metadata type for targeted scan")}, } err := s.Chunks(ctx, chunksChan, targets...) unwrappable, ok := err.(interface{ Unwrap() []error }) if assert.True(t, ok, "returned error was not unwrappable") { got := unwrappable.Unwrap() assert.ElementsMatch(t, got, want) } } func TestRepositoryFiltering(t *testing.T) { // Test that the filteredRepoCache correctly filters repositories source := &Source{} // Test case 1: No filters specified (should include everything) cache1 := source.newFilteredRepoCache(context.Background(), simple.NewCache[string](), []string{}, []string{}) assert.True(t, cache1.wantRepo("org/repo1")) assert.True(t, cache1.wantRepo("org/repo2")) assert.True(t, cache1.wantRepo("org/repo3")) // Test case 2: Include filter specified (should only include matching repos) cache2 := source.newFilteredRepoCache(context.Background(), simple.NewCache[string](), []string{"org/repo1", "org/repo2"}, []string{}) assert.True(t, cache2.wantRepo("org/repo1")) assert.True(t, cache2.wantRepo("org/repo2")) assert.False(t, cache2.wantRepo("org/repo3")) // Test case 3: Exclude filter specified (should exclude matching repos) cache3 := source.newFilteredRepoCache(context.Background(), simple.NewCache[string](), []string{}, []string{"org/repo1"}) assert.False(t, cache3.wantRepo("org/repo1")) assert.True(t, cache3.wantRepo("org/repo2")) assert.True(t, cache3.wantRepo("org/repo3")) // Test case 4: Both include and exclude filters (exclude takes precedence) cache4 := source.newFilteredRepoCache(context.Background(), simple.NewCache[string](), []string{"org/repo1"}, []string{"org/repo1"}) assert.False(t, cache4.wantRepo("org/repo1")) // Test case 5: Wildcard patterns cache5 := source.newFilteredRepoCache(context.Background(), simple.NewCache[string](), []string{"org/*"}, []string{}) assert.True(t, cache5.wantRepo("org/repo1")) assert.True(t, cache5.wantRepo("org/repo2")) assert.False(t, cache5.wantRepo("other/repo1")) } func TestExplicitRepositoryBypass(t *testing.T) { // Test that explicit repositories are included in enumeration ctx := context.Background() // Set up mocks for the API calls gock.New("https://api.github.com"). Get("/user"). Reply(200). JSON(map[string]string{"login": "test-user"}) gock.New("https://api.github.com"). Get("/repos/org/explicit-repo"). Reply(200). JSON(map[string]any{ "full_name": "org/explicit-repo", "clone_url": "https://github.com/org/explicit-repo.git", "size": 1, }) gock.New("https://api.github.com"). Get("/repos/org/another-explicit"). Reply(200). JSON(map[string]any{ "full_name": "org/another-explicit", "clone_url": "https://github.com/org/another-explicit.git", "size": 1, }) // Create a source with explicit repositories source := initTestSource(&sourcespb.GitHub{ Credential: &sourcespb.GitHub_Token{ Token: "super secret token", }, Repositories: []string{ "https://github.com/org/explicit-repo.git", "https://github.com/org/another-explicit.git", }, }) // Test the Enumerate method err := source.Enumerate(ctx, noopReporter()) require.NoError(t, err) // Verify that explicit repositories are included in the enumeration assert.Len(t, source.repos, 2, "Should have 2 explicit repositories") assert.Contains(t, source.repos, "https://github.com/org/explicit-repo.git") assert.Contains(t, source.repos, "https://github.com/org/another-explicit.git") } func noopReporter() sources.UnitReporter { return sources.VisitorReporter{ VisitUnit: func(context.Context, sources.SourceUnit) error { return nil }, } } // This tests reproduces a bug where both VisitUnit and VisitErr were called // for the same repository when caching the repository info failed. func TestFixBothUnitErrAndUnitOKCalled(t *testing.T) { cache := simple.NewCache[string]() cache.Set("myorg/myrepo", "an invalid url that will cause an error") s := &Source{ filteredRepoCache: &filteredRepoCache{ Cache: cache, }, conn: &sourcespb.GitHub{ Repositories: []string{"myorg/myrepo"}, }, orgsCache: simple.NewCache[string](), } var okCalled, errCalled bool reporter := sources.VisitorReporter{ VisitUnit: func(ctx context.Context, su sources.SourceUnit) error { okCalled = true return nil }, VisitErr: func(ctx context.Context, err error) error { errCalled = true return nil }, } err := s.Enumerate(context.Background(), reporter) require.NoError(t, err) // expectation is that only VisitErr is called assert.True(t, errCalled) assert.False(t, okCalled) } func TestExtractRepoNameFromURL(t *testing.T) { tests := []struct { name string url string expected string }{ { name: "git URL", url: "https://github.com/org/repo.git", expected: "org/repo", }, { name: "git URL with trailing slash", url: "https://github.com/org/repo.git/", expected: "org/repo", }, { name: "git URL without .git", url: "https://github.com/org/repo", expected: "org/repo", }, { name: "git enterprise URL", url: "https://example-enterprise.com/org/repo.git", expected: "org/repo", }, { name: "just org/repo", url: "org/repo", expected: "org/repo", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := extractRepoNameFromUrl(tt.url) assert.Equal(t, tt.expected, result) }) } }