feat: Support archived repo exclusion from GH org scans (#4875)

* feat: Support archived repo exclusion from GH org scans

Signed-off-by: Shubham Hibare <[email protected]>

* feat: Support archived repo exclusion from GH org scans

Signed-off-by: Shubham Hibare <[email protected]>

* fix checks

Signed-off-by: Shubham Hibare <[email protected]>

---------

Signed-off-by: Shubham Hibare <[email protected]>
Signed-off-by: Shubham Hibare <[email protected]>
Co-authored-by: Shubham Hibare <[email protected]>
Co-authored-by: Shahzad Haider <[email protected]>
This commit is contained in:
Shubham Hibare
2026-07-08 12:13:08 +05:00
committed by GitHub
co-authored by Shubham Hibare Shahzad Haider
parent f845f184e4
commit d7dcc6d3fe
11 changed files with 813 additions and 523 deletions
+22 -16
View File
@@ -212,7 +212,13 @@ Timestamp: 2022-06-16 10:17:40 -0700 PDT
trufflehog github --org=trufflesecurity --results=verified
```
## 3: Scan a GitHub Repo for only verified secrets and get JSON output
## 3: Scan a GitHub Org excluding archived repositories
```bash
trufflehog github --org=trufflesecurity --exclude-archived
```
## 4: Scan a GitHub Repo for only verified secrets and get JSON output
Command:
@@ -227,37 +233,37 @@ Expected output:
...
```
## 4: Scan a GitHub Repo + its Issues and Pull Requests
## 5: Scan a GitHub Repo + its Issues and Pull Requests
```bash
trufflehog github --repo=https://github.com/trufflesecurity/test_keys --issue-comments --pr-comments
```
## 5: Scan an S3 bucket for high-confidence results (verified + unknown)
## 6: Scan an S3 bucket for high-confidence results (verified + unknown)
```bash
trufflehog s3 --bucket=<bucket name> --results=verified,unknown
```
## 6: Scan S3 buckets using IAM Roles
## 7: Scan S3 buckets using IAM Roles
```bash
trufflehog s3 --role-arn=<iam role arn>
```
## 7: Scan a Github Repo using SSH authentication in Docker
## 8: Scan a Github Repo using SSH authentication in Docker
```bash
docker run --rm -v "$HOME/.ssh:/root/.ssh:ro" trufflesecurity/trufflehog:latest git ssh://github.com/trufflesecurity/test_keys
```
## 8: Scan individual files or directories
## 9: Scan individual files or directories
```bash
trufflehog filesystem path/to/file1.txt path/to/file2.txt path/to/dir
```
## 9: Scan a local git repo
## 10: Scan a local git repo
Clone the git repo. For example [test keys]([email protected]:trufflesecurity/test_keys.git) repo.
```bash
@@ -271,13 +277,13 @@ trufflehog git file://test_keys --results=verified,unknown
To guard against malicious git configs in local scanning (see CVE-2025-41390), TruffleHog clones local git repositories to a temporary directory prior to scanning. This follows [Git's security best practices](https://git-scm.com/docs/git#_security). If you want to specify a custom path to clone the repository to (instead of tmp), you can use the `--clone-path` flag. If you'd like to skip the local cloning process and scan the repository directly (only do this for trusted repos), you can use the `--trust-local-git-config` flag.
## 10: Scan GCS buckets for only verified secrets
## 11: Scan GCS buckets for only verified secrets
```bash
trufflehog gcs --project-id=<project-ID> --cloud-environment --results=verified
```
## 11: Scan a Docker image for only verified secrets
## 12: Scan a Docker image for only verified secrets
Use the `--image` flag multiple times to scan multiple images.
@@ -292,7 +298,7 @@ trufflehog docker --image docker://new_image:tag --results=verified
trufflehog docker --image file://path_to_image.tar --results=verified
```
## 12: Scan in CI
## 13: Scan in CI
Set the `--since-commit` flag to your default branch that people merge into (ex: "main"). Set the `--branch` flag to your PR's branch name (ex: "feature-1"). Depending on the CI/CD platform you use, this value can be pulled in dynamically (ex: [CIRCLE_BRANCH in Circle CI](https://circleci.com/docs/variables/) and [TRAVIS_PULL_REQUEST_BRANCH in Travis CI](https://docs.travis-ci.com/user/environment-variables/)). If the repo is cloned and the target branch is already checked out during the CI/CD workflow, then `--branch HEAD` should be sufficient. The `--fail` flag will return an 183 error code if valid credentials are found.
@@ -300,7 +306,7 @@ Set the `--since-commit` flag to your default branch that people merge into (ex:
trufflehog git file://. --since-commit main --branch feature-1 --results=verified,unknown --fail
```
## 13: Scan a Postman workspace
## 14: Scan a Postman workspace
Use the `--workspace-id`, `--collection-id`, `--environment` flags multiple times to scan multiple targets.
@@ -308,13 +314,13 @@ Use the `--workspace-id`, `--collection-id`, `--environment` flags multiple time
trufflehog postman --token=<postman api token> --workspace-id=<workspace id>
```
## 14: Scan a Jenkins server
## 15: Scan a Jenkins server
```bash
trufflehog jenkins --url https://jenkins.example.com --username admin --password admin
```
## 15: Scan an Elasticsearch server
## 16: Scan an Elasticsearch server
### Scan a Local Cluster
@@ -342,7 +348,7 @@ trufflehog elasticsearch \
--api-key 'MlVtVjBZ...ZSYlduYnF1djh3NG5FQQ=='
```
## 16. Scan a GitHub Repository for Cross Fork Object References and Deleted Commits
## 17. Scan a GitHub Repository for Cross Fork Object References and Deleted Commits
The following command will enumerate deleted and hidden commits on a GitHub repository and then scan them for secrets. This is an alpha release feature.
@@ -356,7 +362,7 @@ In addition to the normal TruffleHog output, the `--object-discovery` flag creat
For more information on Cross Fork Object References, please [read our blog post](https://trufflesecurity.com/blog/anyone-can-access-deleted-and-private-repo-data-github).
## 17. Scan Hugging Face
## 18. Scan Hugging Face
### Scan a Hugging Face Model, Dataset, Space or Bucket
@@ -382,7 +388,7 @@ trufflehog huggingface --org <orgname> --user <username>
trufflehog huggingface --model <model_id> --include-discussions --include-prs
```
## 18. Scan stdin Input
## 19. Scan stdin Input
```bash
aws s3 cp s3://example/gzipped/data.gz - | gunzip -c | trufflehog stdin
+3
View File
@@ -227,6 +227,9 @@ Do not delete cloned repositories after scanning (can only be used with --clone-
.TP
\fB--ignore-gists\fR
Ignore all gists in scan.
.TP
\fB--exclude-archived\fR
Exclude archived repositories from scan.
.SS
\fBgithub-experimental --repo=REPO [<flags>]\fR
Run an experimental GitHub scan. Must specify at least one experimental sub-module to run: object-discovery.
+2
View File
@@ -130,6 +130,7 @@ var (
githubClonePath = githubScan.Flag("clone-path", "Custom path where the repository should be cloned (default: temp dir).").String()
githubNoCleanup = githubScan.Flag("no-cleanup", "Do not delete cloned repositories after scanning (can only be used with --clone-path).").Bool()
githubIgnoreGists = githubScan.Flag("ignore-gists", "Ignore all gists in scan.").Bool()
githubExcludeArchived = githubScan.Flag("exclude-archived", "Exclude archived repositories from scan.").Bool()
// GitHub Cross Fork Object Reference Experimental Feature
githubExperimentalScan = cli.Command("github-experimental", "Run an experimental GitHub scan. Must specify at least one experimental sub-module to run: object-discovery.")
@@ -915,6 +916,7 @@ func runSingleScan(ctx context.Context, cmd string, cfg engine.Config) (metrics,
ClonePath: *githubClonePath,
NoCleanup: *githubNoCleanup,
IgnoreGists: *githubIgnoreGists,
ExcludeArchived: *githubExcludeArchived,
PrintLegacyJSON: *jsonLegacy,
}
+1
View File
@@ -32,6 +32,7 @@ func (e *Engine) ScanGitHub(ctx context.Context, c sources.GithubConfig) (source
ClonePath: c.ClonePath,
NoCleanup: c.NoCleanup,
IgnoreGists: c.IgnoreGists,
ExcludeArchived: c.ExcludeArchived,
PrintLegacyJson: c.PrintLegacyJSON,
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -2592,6 +2592,8 @@ func (m *GitHub) validate(all bool) error {
// no validation rules for ScanAllInstallations
// no validation rules for ExcludeArchived
switch v := m.Credential.(type) {
case *GitHub_GithubApp:
if v == nil {
@@ -72,6 +72,86 @@ func TestSource_Token(t *testing.T) {
assert.NoError(t, err)
}
func TestSource_ExcludeArchived(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*300)
defer cancel()
secret, err := common.GetTestSecret(ctx)
if err != nil {
t.Fatal(fmt.Errorf("failed to access secret: %v", err))
}
githubPrivateKeyB64New := secret.MustGetField("GITHUB_PRIVATE_KEY_NEW")
githubPrivateKeyBytesNew, err := base64.StdEncoding.DecodeString(githubPrivateKeyB64New)
if err != nil {
t.Fatal(err)
}
githubPrivateKeyNew := string(githubPrivateKeyBytesNew)
githubInstallationIDNew := secret.MustGetField("GITHUB_INSTALLATION_ID_NEW")
githubAppIDNew := secret.MustGetField("GITHUB_APP_ID_NEW")
srcWithExclude := &sourcespb.GitHub{
Endpoint: "https://api.github.com",
Credential: &sourcespb.GitHub_GithubApp{
GithubApp: &credentialspb.GitHubApp{
PrivateKey: githubPrivateKeyNew,
InstallationId: githubInstallationIDNew,
AppId: githubAppIDNew,
},
},
ExcludeArchived: true,
}
connWithExclude, err := anypb.New(srcWithExclude)
if err != nil {
panic(err)
}
sWithExclude := Source{
conn: srcWithExclude,
memberCache: map[string]struct{}{},
repoInfoCache: newRepoInfoCache(),
}
sWithExclude.Init(ctx, "github integration test source with exclude", 0, 0, false, connWithExclude, 1)
sWithExclude.filteredRepoCache = sWithExclude.newFilteredRepoCache(ctx, simple.NewCache[string](), nil, nil)
err = sWithExclude.enumerateWithApp(ctx, sWithExclude.connector.(*appConnector), noopReporter())
assert.NoError(t, err)
srcWithoutExclude := &sourcespb.GitHub{
Endpoint: "https://api.github.com",
Credential: &sourcespb.GitHub_GithubApp{
GithubApp: &credentialspb.GitHubApp{
PrivateKey: githubPrivateKeyNew,
InstallationId: githubInstallationIDNew,
AppId: githubAppIDNew,
},
},
ExcludeArchived: false,
}
connWithoutExclude, err := anypb.New(srcWithoutExclude)
if err != nil {
panic(err)
}
sWithoutExclude := Source{
conn: srcWithoutExclude,
memberCache: map[string]struct{}{},
repoInfoCache: newRepoInfoCache(),
}
sWithoutExclude.Init(ctx, "github integration test source without exclude", 0, 0, false, connWithoutExclude, 1)
sWithoutExclude.filteredRepoCache = sWithoutExclude.newFilteredRepoCache(ctx, simple.NewCache[string](), nil, nil)
err = sWithoutExclude.enumerateWithApp(ctx, sWithoutExclude.connector.(*appConnector), noopReporter())
assert.NoError(t, err)
assert.Less(t, sWithExclude.filteredRepoCache.Count(), sWithoutExclude.filteredRepoCache.Count(),
"ExcludeArchived should result in strictly fewer repos (test org must have at least one archived repo). WithExclude: %d, WithoutExclude: %d",
sWithExclude.filteredRepoCache.Count(), sWithoutExclude.filteredRepoCache.Count())
t.Logf("Repos with ExcludeArchived=true: %d", sWithExclude.filteredRepoCache.Count())
t.Logf("Repos with ExcludeArchived=false: %d", sWithoutExclude.filteredRepoCache.Count())
}
func TestSource_ScanComments(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
+167
View File
@@ -2431,3 +2431,170 @@ func TestExtractRepoNameFromURL(t *testing.T) {
})
}
}
func TestSource_ExcludeArchivedRepositories(t *testing.T) {
tests := []struct {
name string
excludeArchived bool
reposJSON string
wantRepoCount int
wantRepos []string
}{
{
name: "exclude archived repos when flag is true",
excludeArchived: true,
reposJSON: `[
{"full_name": "test-org/active-repo", "clone_url": "https://github.com/test-org/active-repo.git", "size": 1, "archived": false},
{"full_name": "test-org/archived-repo", "clone_url": "https://github.com/test-org/archived-repo.git", "size": 1, "archived": true},
{"full_name": "test-org/another-active", "clone_url": "https://github.com/test-org/another-active.git", "size": 1, "archived": false}
]`,
wantRepoCount: 2, // Only non-archived
wantRepos: []string{"test-org/active-repo", "test-org/another-active"},
},
{
name: "include archived repos when flag is false",
excludeArchived: false,
reposJSON: `[
{"full_name": "test-org/active-repo", "clone_url": "https://github.com/test-org/active-repo.git", "size": 1, "archived": false},
{"full_name": "test-org/archived-repo", "clone_url": "https://github.com/test-org/archived-repo.git", "size": 1, "archived": true},
{"full_name": "test-org/another-active", "clone_url": "https://github.com/test-org/another-active.git", "size": 1, "archived": false}
]`,
wantRepoCount: 3, // All repos
wantRepos: []string{"test-org/active-repo", "test-org/archived-repo", "test-org/another-active"},
},
{
name: "handle all archived repos",
excludeArchived: true,
reposJSON: `[
{"full_name": "test-org/archived-1", "clone_url": "https://github.com/test-org/archived-1.git", "size": 1, "archived": true},
{"full_name": "test-org/archived-2", "clone_url": "https://github.com/test-org/archived-2.git", "size": 1, "archived": true}
]`,
wantRepoCount: 0, // None included
wantRepos: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer gock.Off()
gock.New("https://api.github.com").
Get("/orgs/test-org/repos").
Reply(200).
JSON(tt.reposJSON)
s := initTestSource(&sourcespb.GitHub{
Credential: &sourcespb.GitHub_Token{
Token: "test-token",
},
ExcludeArchived: tt.excludeArchived,
})
err := s.getReposByOrg(context.Background(), "test-org", noopReporter())
assert.Nil(t, err)
assert.Equal(t, tt.wantRepoCount, s.filteredRepoCache.Count())
// Verify expected repos are in cache
for _, repo := range tt.wantRepos {
ok := s.filteredRepoCache.Exists(repo)
assert.True(t, ok, "expected repo %s to be in cache", repo)
}
// Verify archived repos are NOT in cache when excluding
if tt.excludeArchived {
allRepos := []string{"test-org/archived-repo", "test-org/archived-1", "test-org/archived-2"}
for _, repo := range allRepos {
if !slices.Contains(tt.wantRepos, repo) {
ok := s.filteredRepoCache.Exists(repo)
assert.False(t, ok, "archived repo %s should not be in cache when excluding", repo)
}
}
}
assert.False(t, gock.HasUnmatchedRequest())
assert.True(t, gock.IsDone())
})
}
}
func TestSource_ExcludeArchivedForkInteraction(t *testing.T) {
tests := []struct {
name string
includeForks bool
excludeArchived bool
reposJSON string
wantRepoCount int
wantRepos []string
notWantRepos []string
}{
{
name: "fork+archived skipped when forks included and archived excluded",
includeForks: true,
excludeArchived: true,
reposJSON: `[
{"full_name": "test-org/active", "clone_url": "https://github.com/test-org/active.git", "size": 1, "archived": false, "fork": false},
{"full_name": "test-org/fork-active", "clone_url": "https://github.com/test-org/fork-active.git", "size": 1, "archived": false, "fork": true},
{"full_name": "test-org/fork-archived", "clone_url": "https://github.com/test-org/fork-archived.git", "size": 1, "archived": true, "fork": true}
]`,
wantRepoCount: 2,
wantRepos: []string{"test-org/active", "test-org/fork-active"},
notWantRepos: []string{"test-org/fork-archived"},
},
{
name: "fork+archived included when archived allowed",
includeForks: true,
excludeArchived: false,
reposJSON: `[
{"full_name": "test-org/active", "clone_url": "https://github.com/test-org/active.git", "size": 1, "archived": false, "fork": false},
{"full_name": "test-org/fork-archived", "clone_url": "https://github.com/test-org/fork-archived.git", "size": 1, "archived": true, "fork": true}
]`,
wantRepoCount: 2,
wantRepos: []string{"test-org/active", "test-org/fork-archived"},
},
{
name: "fork+archived dropped by fork filter when forks excluded",
includeForks: false,
excludeArchived: true,
reposJSON: `[
{"full_name": "test-org/active", "clone_url": "https://github.com/test-org/active.git", "size": 1, "archived": false, "fork": false},
{"full_name": "test-org/fork-archived", "clone_url": "https://github.com/test-org/fork-archived.git", "size": 1, "archived": true, "fork": true}
]`,
wantRepoCount: 1,
wantRepos: []string{"test-org/active"},
notWantRepos: []string{"test-org/fork-archived"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer gock.Off()
gock.New("https://api.github.com").
Get("/orgs/test-org/repos").
Reply(200).
JSON(tt.reposJSON)
s := initTestSource(&sourcespb.GitHub{
Credential: &sourcespb.GitHub_Token{
Token: "test-token",
},
IncludeForks: tt.includeForks,
ExcludeArchived: tt.excludeArchived,
})
err := s.getReposByOrg(context.Background(), "test-org", noopReporter())
assert.Nil(t, err)
assert.Equal(t, tt.wantRepoCount, s.filteredRepoCache.Count())
for _, repo := range tt.wantRepos {
assert.True(t, s.filteredRepoCache.Exists(repo), "expected repo %s to be in cache", repo)
}
for _, repo := range tt.notWantRepos {
assert.False(t, s.filteredRepoCache.Exists(repo), "repo %s should not be in cache", repo)
}
assert.False(t, gock.HasUnmatchedRequest())
assert.True(t, gock.IsDone())
})
}
}
+22 -7
View File
@@ -246,8 +246,9 @@ func (s *Source) processRepos(ctx context.Context, target string, reporter sourc
opts := listOpts.getListOptions()
var (
numRepos, numForks int
uniqueOrgs = map[string]struct{}{}
numRepos, numForks, numArchived int
numArchivedSkipped int
uniqueOrgs = map[string]struct{}{}
)
// loop to handle pagination.
@@ -262,12 +263,26 @@ func (s *Source) processRepos(ctx context.Context, target string, reporter sourc
ctx.Logger().V(2).Info("Listed repos", "page", opts.Page, "last_page", res.LastPage)
for _, r := range someRepos {
if r.GetFork() {
if !s.conn.IncludeForks {
continue
}
isFork, isArchived := r.GetFork(), r.GetArchived()
if isFork && !s.conn.IncludeForks {
continue
}
if isArchived && s.conn.ExcludeArchived {
numArchivedSkipped++
logger.V(3).Info("skipping archived repository", "repo", r.GetFullName())
continue
}
if isFork {
numForks++
}
if isArchived {
numArchived++
}
numRepos++
// track unique organizations.
@@ -299,7 +314,7 @@ func (s *Source) processRepos(ctx context.Context, target string, reporter sourc
}
// final logging of repository stats.
logger.V(2).Info("found repos", "total", numRepos, "num_forks", numForks, "num_orgs", len(uniqueOrgs))
logger.V(2).Info("found repos", "total", numRepos, "num_forks", numForks, "num_archived", numArchived, "num_archived_skipped", numArchivedSkipped, "num_orgs", len(uniqueOrgs))
githubOrgsEnumerated.WithLabelValues(s.name).Add(float64(len(uniqueOrgs)))
return nil
+2
View File
@@ -338,6 +338,8 @@ type GithubConfig struct {
// NoCleanup allows to keeps cloned repositories in ClonePath after scanning instead of removing them.
NoCleanup bool
IgnoreGists bool
// ExcludeArchived indicates whether to exclude archived repositories from the scan.
ExcludeArchived bool
// PrintLegacyJSON indicates whether to print legacy JSON output format for this source.
PrintLegacyJSON bool
}
+1
View File
@@ -283,6 +283,7 @@ message GitHub {
bool print_legacy_json = 25;
// When using GitHub App credentials, enumerate repositories from every app installation.
bool scan_all_installations = 26;
bool exclude_archived = 27;
}
message GitHubExperimental {