Give each clone its own directory under --clone-path (#5175)

* give each clone its own directory under --clone-path

* update docs

* address feedback: update flag descriptions
This commit is contained in:
Shahzad Haider
2026-08-04 22:15:56 +05:00
committed by GitHub
parent 82df476e75
commit 24c98ca20e
4 changed files with 159 additions and 27 deletions
+4 -4
View File
@@ -154,7 +154,7 @@ Scan bare repository (e.g. useful while using in pre-receive hooks)
Custom path where the repository should be cloned (default: temp dir). Custom path where the repository should be cloned (default: temp dir).
.TP .TP
\fB--no-cleanup\fR \fB--no-cleanup\fR
Do not delete cloned repositories after scanning (can only be used with --clone-path). Do not delete cloned repositories after scanning (can only be used with --clone-path). Each clone is kept in its own directory, so ensure sufficient disk space: usage grows with every repository scanned and every run.
.TP .TP
\fB--trust-local-git-config\fR \fB--trust-local-git-config\fR
Trust local git config. Trust local git config.
@@ -223,7 +223,7 @@ Embed authentication credentials in repository URLs instead of using secure HTTP
Custom path where the repository should be cloned (default: temp dir). Custom path where the repository should be cloned (default: temp dir).
.TP .TP
\fB--no-cleanup\fR \fB--no-cleanup\fR
Do not delete cloned repositories after scanning (can only be used with --clone-path). Do not delete cloned repositories after scanning (can only be used with --clone-path). Each clone is kept in its own directory, so ensure sufficient disk space: usage grows with every repository scanned and every run.
.TP .TP
\fB--ignore-gists\fR \fB--ignore-gists\fR
Ignore all gists in scan. Ignore all gists in scan.
@@ -280,10 +280,10 @@ Repositories to exclude in an org scan. This can also be a glob pattern. You can
Embed authentication credentials in repository URLs instead of using secure HTTP headers Embed authentication credentials in repository URLs instead of using secure HTTP headers
.TP .TP
\fB--clone-path=CLONE-PATH\fR \fB--clone-path=CLONE-PATH\fR
Custom path where the repository should be cloned (default: temp dir) Custom path where the repository should be cloned (default: temp dir).
.TP .TP
\fB--no-cleanup\fR \fB--no-cleanup\fR
Do not delete cloned repositories after scanning (can only be used with --clone-path). Do not delete cloned repositories after scanning (can only be used with --clone-path). Each clone is kept in its own directory, so ensure sufficient disk space: usage grows with every repository scanned and every run.
.SS .SS
\fBfilesystem [<flags>] [<path>...]\fR \fBfilesystem [<flags>] [<path>...]\fR
Find credentials in a filesystem. Find credentials in a filesystem.
+4 -4
View File
@@ -104,7 +104,7 @@ var (
gitScanMaxDepth = gitScan.Flag("max-depth", "Maximum depth of commits to scan.").Int() gitScanMaxDepth = gitScan.Flag("max-depth", "Maximum depth of commits to scan.").Int()
gitScanBare = gitScan.Flag("bare", "Scan bare repository (e.g. useful while using in pre-receive hooks)").Bool() gitScanBare = gitScan.Flag("bare", "Scan bare repository (e.g. useful while using in pre-receive hooks)").Bool()
gitClonePath = gitScan.Flag("clone-path", "Custom path where the repository should be cloned (default: temp dir).").String() gitClonePath = gitScan.Flag("clone-path", "Custom path where the repository should be cloned (default: temp dir).").String()
gitNoCleanup = gitScan.Flag("no-cleanup", "Do not delete cloned repositories after scanning (can only be used with --clone-path).").Bool() gitNoCleanup = gitScan.Flag("no-cleanup", "Do not delete cloned repositories after scanning (can only be used with --clone-path). Each clone is kept in its own directory, so ensure sufficient disk space: usage grows with every repository scanned and every run.").Bool()
gitTrustLocalGitConfig = gitScan.Flag("trust-local-git-config", "Trust local git config.").Bool() gitTrustLocalGitConfig = gitScan.Flag("trust-local-git-config", "Trust local git config.").Bool()
_ = gitScan.Flag("allow", "No-op flag for backwards compat.").Bool() _ = gitScan.Flag("allow", "No-op flag for backwards compat.").Bool()
_ = gitScan.Flag("entropy", "No-op flag for backwards compat.").Bool() _ = gitScan.Flag("entropy", "No-op flag for backwards compat.").Bool()
@@ -128,7 +128,7 @@ var (
githubCommentsTimeframeDays = githubScan.Flag("comments-timeframe", "Number of days in the past to review when scanning issue, PR, and gist comments.").Uint32() githubCommentsTimeframeDays = githubScan.Flag("comments-timeframe", "Number of days in the past to review when scanning issue, PR, and gist comments.").Uint32()
githubAuthInUrl = githubScan.Flag("auth-in-url", "Embed authentication credentials in repository URLs instead of using secure HTTP headers").Bool() githubAuthInUrl = githubScan.Flag("auth-in-url", "Embed authentication credentials in repository URLs instead of using secure HTTP headers").Bool()
githubClonePath = githubScan.Flag("clone-path", "Custom path where the repository should be cloned (default: temp dir).").String() 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() githubNoCleanup = githubScan.Flag("no-cleanup", "Do not delete cloned repositories after scanning (can only be used with --clone-path). Each clone is kept in its own directory, so ensure sufficient disk space: usage grows with every repository scanned and every run.").Bool()
githubIgnoreGists = githubScan.Flag("ignore-gists", "Ignore all gists in scan.").Bool() githubIgnoreGists = githubScan.Flag("ignore-gists", "Ignore all gists in scan.").Bool()
githubExcludeArchived = githubScan.Flag("exclude-archived", "Exclude archived repositories from scan.").Bool() githubExcludeArchived = githubScan.Flag("exclude-archived", "Exclude archived repositories from scan.").Bool()
@@ -153,8 +153,8 @@ var (
gitlabScanIncludeRepos = gitlabScan.Flag("include-repos", `Repositories to include in an org scan. This can also be a glob pattern. You can repeat this flag. Must use Gitlab repo full name. Example: "trufflesecurity/trufflehog", "trufflesecurity/t*"`).Strings() gitlabScanIncludeRepos = gitlabScan.Flag("include-repos", `Repositories to include in an org scan. This can also be a glob pattern. You can repeat this flag. Must use Gitlab repo full name. Example: "trufflesecurity/trufflehog", "trufflesecurity/t*"`).Strings()
gitlabScanExcludeRepos = gitlabScan.Flag("exclude-repos", `Repositories to exclude in an org scan. This can also be a glob pattern. You can repeat this flag. Must use Gitlab repo full name. Example: "trufflesecurity/driftwood", "trufflesecurity/d*"`).Strings() gitlabScanExcludeRepos = gitlabScan.Flag("exclude-repos", `Repositories to exclude in an org scan. This can also be a glob pattern. You can repeat this flag. Must use Gitlab repo full name. Example: "trufflesecurity/driftwood", "trufflesecurity/d*"`).Strings()
gitlabAuthInUrl = gitlabScan.Flag("auth-in-url", "Embed authentication credentials in repository URLs instead of using secure HTTP headers").Bool() gitlabAuthInUrl = gitlabScan.Flag("auth-in-url", "Embed authentication credentials in repository URLs instead of using secure HTTP headers").Bool()
gitlabClonePath = gitlabScan.Flag("clone-path", "Custom path where the repository should be cloned (default: temp dir)").String() gitlabClonePath = gitlabScan.Flag("clone-path", "Custom path where the repository should be cloned (default: temp dir).").String()
gitlabNoCleanup = gitlabScan.Flag("no-cleanup", "Do not delete cloned repositories after scanning (can only be used with --clone-path).").Bool() gitlabNoCleanup = gitlabScan.Flag("no-cleanup", "Do not delete cloned repositories after scanning (can only be used with --clone-path). Each clone is kept in its own directory, so ensure sufficient disk space: usage grows with every repository scanned and every run.").Bool()
filesystemScan = cli.Command("filesystem", "Find credentials in a filesystem.") filesystemScan = cli.Command("filesystem", "Find credentials in a filesystem.")
filesystemPaths = filesystemScan.Arg("path", "Path to file or directory to scan.").Strings() filesystemPaths = filesystemScan.Arg("path", "Path to file or directory to scan.").Strings()
+24 -6
View File
@@ -544,10 +544,15 @@ func isRetryableCloneError(err error) bool {
// createClonePath creates the directory a repository will be cloned into and // createClonePath creates the directory a repository will be cloned into and
// returns its path. When clonePath is set (the --clone-path flag), the // returns its path. When clonePath is set (the --clone-path flag), the
// directory is <clonePath>/trufflehog-<repo-name> with permissions 0755; // directory is <clonePath>/trufflehog-<repo-name>-<random> with permissions
// otherwise a fresh temporary directory (0700, per os.MkdirTemp) is created // 0755; otherwise a fresh temporary directory (0700, per os.MkdirTemp) is
// in the system temp path. It is called both before the first clone attempt // created in the system temp path. It is called both before the first clone
// and to replace the directory between retries. // attempt and to replace the directory between retries.
//
// Every call returns a directory of its own. Naming it after the URL's last
// segment alone collides for same-named repos in different groups, and for
// the same repo cloned twice, which lets concurrent workers clone into and
// delete each other's directories.
func createClonePath(gitURL, clonePath string) (string, error) { func createClonePath(gitURL, clonePath string) (string, error) {
if clonePath == "" { if clonePath == "" {
path, err := cleantemp.MkdirTemp() path, err := cleantemp.MkdirTemp()
@@ -557,10 +562,23 @@ func createClonePath(gitURL, clonePath string) (string, error) {
return path, nil return path, nil
} }
path := filepath.Join(clonePath, "trufflehog-"+strings.TrimSuffix(filepath.Base(gitURL), gitDirName)) if err := os.MkdirAll(clonePath, 0755); err != nil {
if err := os.MkdirAll(path, 0755); err != nil {
return "", fmt.Errorf("failed to create clone path %s: %w", clonePath, err) return "", fmt.Errorf("failed to create clone path %s: %w", clonePath, err)
} }
// The trufflehog- prefix is what cleantemp.CleanTempDirsForLegacyJSON
// sweeps, so it has to survive; the repo name is kept for readability
// under --no-cleanup.
slug := strings.TrimSuffix(filepath.Base(gitURL), gitDirName)
path, err := os.MkdirTemp(clonePath, "trufflehog-"+slug+"-")
if err != nil {
return "", fmt.Errorf("failed to create clone path in %s: %w", clonePath, err)
}
// os.MkdirTemp creates 0700; --clone-path directories are 0755.
if err := os.Chmod(path, 0755); err != nil {
return "", fmt.Errorf("failed to set permissions on clone path %s: %w", path, err)
}
return path, nil return path, nil
} }
+127 -13
View File
@@ -123,11 +123,14 @@ func TestCreateClonePath(t *testing.T) {
assert.NotEqual(t, first, second) assert.NotEqual(t, first, second)
}) })
t.Run("clonePath set builds trufflehog-<repo> subdirectory", func(t *testing.T) { t.Run("clonePath set builds trufflehog-<repo>-<random> subdirectory", func(t *testing.T) {
base := t.TempDir() base := t.TempDir()
path, err := createClonePath("https://github.com/org/repo.git", base) path, err := createClonePath("https://github.com/org/repo.git", base)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, filepath.Join(base, "trufflehog-repo"), path) assert.Equal(t, base, filepath.Dir(path))
// The trufflehog- prefix is what CleanTempDirsForLegacyJSON sweeps.
assert.True(t, strings.HasPrefix(filepath.Base(path), "trufflehog-repo-"),
"unexpected directory name %q", filepath.Base(path))
info, err := os.Stat(path) info, err := os.Stat(path)
assert.NoError(t, err) assert.NoError(t, err)
@@ -141,50 +144,161 @@ func TestCreateClonePath(t *testing.T) {
base := t.TempDir() base := t.TempDir()
path, err := createClonePath("https://github.com/org/repo", base) path, err := createClonePath("https://github.com/org/repo", base)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, filepath.Join(base, "trufflehog-repo"), path) assert.True(t, strings.HasPrefix(filepath.Base(path), "trufflehog-repo-"),
"unexpected directory name %q", filepath.Base(path))
}) })
t.Run("trailing slash in repo URL", func(t *testing.T) { t.Run("trailing slash in repo URL", func(t *testing.T) {
base := t.TempDir() base := t.TempDir()
path, err := createClonePath("https://github.com/org/repo.git/", base) path, err := createClonePath("https://github.com/org/repo.git/", base)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, filepath.Join(base, "trufflehog-repo"), path) assert.True(t, strings.HasPrefix(filepath.Base(path), "trufflehog-repo-"),
"unexpected directory name %q", filepath.Base(path))
}) })
t.Run("nonexistent clonePath parents are created", func(t *testing.T) { t.Run("nonexistent clonePath parents are created", func(t *testing.T) {
base := filepath.Join(t.TempDir(), "a", "b", "c") base := filepath.Join(t.TempDir(), "a", "b", "c")
path, err := createClonePath("https://github.com/org/repo.git", base) path, err := createClonePath("https://github.com/org/repo.git", base)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, filepath.Join(base, "trufflehog-repo"), path) assert.Equal(t, base, filepath.Dir(path))
info, err := os.Stat(path) info, err := os.Stat(path)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, info.IsDir()) assert.True(t, info.IsDir())
}) })
t.Run("second call with same arguments reuses the directory", func(t *testing.T) { t.Run("second call with same arguments gets a fresh directory", func(t *testing.T) {
// The retry path calls this again after RemoveAll; it must also // The retry path calls this again after RemoveAll, and concurrent
// tolerate the directory already existing (MkdirAll semantics). // workers may be scanning the same repo; neither may be handed a
// directory another caller already owns.
base := t.TempDir() base := t.TempDir()
first, err := createClonePath("https://github.com/org/repo.git", base) first, err := createClonePath("https://github.com/org/repo.git", base)
assert.NoError(t, err) assert.NoError(t, err)
second, err := createClonePath("https://github.com/org/repo.git", base) second, err := createClonePath("https://github.com/org/repo.git", base)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, first, second) assert.NotEqual(t, first, second)
}) })
t.Run("error when clonePath location is not writable", func(t *testing.T) { t.Run("error when clonePath location is not writable", func(t *testing.T) {
base := t.TempDir() // Create a *file* where the clone path should go so MkdirAll fails.
// Create a *file* where the subdirectory should go so MkdirAll fails. base := filepath.Join(t.TempDir(), "blocker")
blocker := filepath.Join(base, "trufflehog-repo") assert.NoError(t, os.WriteFile(base, []byte("x"), 0644))
assert.NoError(t, os.WriteFile(blocker, []byte("x"), 0644))
path, err := createClonePath("https://github.com/org/repo.git", base) path, err := createClonePath("https://github.com/org/repo.git", base)
assert.Error(t, err) assert.Error(t, err)
assert.Empty(t, path) assert.Empty(t, path)
assert.Contains(t, err.Error(), "failed to create clone path") assert.Contains(t, err.Error(), "failed to create clone path")
}) })
t.Run("distinct repos sharing a basename get distinct paths", func(t *testing.T) {
// Only the last URL segment is used as the directory slug, so repos
// that live under different groups but share a name collide. With
// concurrency > 1 two workers then clone into and delete the same
// directory, producing spurious clone errors and partial scans.
base := t.TempDir()
urls := []string{
"https://gitlab.com/group-a/api.git",
"https://gitlab.com/group-b/api.git",
"https://gitlab.com/group-b/subgroup/api.git",
"https://gitlab.example.com/other/api",
}
seen := make(map[string]string, len(urls))
for _, u := range urls {
path, err := createClonePath(u, base)
assert.NoError(t, err)
if prev, ok := seen[path]; ok {
t.Errorf("clone path collision: %q and %q both resolve to %q", prev, u, path)
}
seen[path] = u
}
})
t.Run("concurrent calls for the same repo get distinct paths", func(t *testing.T) {
// The same repo can be cloned concurrently (e.g. a unit retried while
// another worker still holds the directory). Each caller owns its
// directory, so no two callers may be handed the same one.
base := t.TempDir()
const workers = 8
var (
mu sync.Mutex
paths = make(map[string]int, workers)
wg sync.WaitGroup
start = make(chan struct{})
)
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
path, err := createClonePath("https://gitlab.com/group-a/api.git", base)
assert.NoError(t, err)
mu.Lock()
paths[path]++
mu.Unlock()
}()
}
close(start)
wg.Wait()
assert.Len(t, paths, workers, "expected %d distinct clone paths, got %v", workers, paths)
})
}
// TestCloneRepo_ConcurrentSameBasename reproduces the failure end to end: two
// workers cloning different repos that share a basename into a shared
// --clone-path. They are handed the same directory, so one clone fails
// ("already exists and is not an empty directory") or one worker's cleanup
// deletes the other's working tree mid-scan.
func TestCloneRepo_ConcurrentSameBasename(t *testing.T) {
ctx := context.Background()
clonePath := t.TempDir()
// Two distinct source repos that both end in "api".
sources := make([]string, 2)
for i, group := range []string{"group-a", "group-b"} {
repoPath := filepath.Join(t.TempDir(), group, "api")
assert.NoError(t, os.MkdirAll(filepath.Dir(repoPath), 0755))
assert.NoError(t, exec.Command("git", "init", repoPath).Run())
assert.NoError(t, exec.Command("git", "-C", repoPath, "config", "user.name", "Test User").Run())
assert.NoError(t, exec.Command("git", "-C", repoPath, "config", "user.email", "[email protected]").Run())
assert.NoError(t, exec.Command("git", "-C", repoPath, "config", "commit.gpgsign", "false").Run())
addTestFileAndCommit(t, repoPath, "secret.txt", "content for "+group)
sources[i] = "file://" + repoPath
}
var (
wg sync.WaitGroup
start = make(chan struct{})
mu sync.Mutex
errs []error
dests = make(map[string]int)
)
for _, gitURL := range sources {
wg.Add(1)
go func() {
defer wg.Done()
<-start
path, _, err := CloneRepo(ctx, nil, gitURL, clonePath, false)
mu.Lock()
defer mu.Unlock()
if err != nil {
errs = append(errs, err)
return
}
dests[path]++
// Mirrors the cleanup the callers do once a repo is scanned.
_ = os.RemoveAll(path)
}()
}
close(start)
wg.Wait()
assert.Empty(t, errs, "concurrent clones of same-named repos should not fail")
assert.Len(t, dests, len(sources), "each repo should clone into its own directory, got %v", dests)
} }
func TestSource_Scan(t *testing.T) { func TestSource_Scan(t *testing.T) {