Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions pkg/sources/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -1472,6 +1472,9 @@ func PrepareRepo(ctx context.Context, uriString, clonePath string, trustLocalGit
// GetSafeRemoteURL is a helper function that will attempt to get a safe URL first
// from the preferred remote name, falling back to the first remote name
// available, or an empty string if there are no remotes.
// If the resolved remote URL points to a local path (e.g. file:// scheme),
// it will attempt to open that repository and resolve its remote URL instead,
// so that clones of local repos report the upstream remote rather than the local path.
func GetSafeRemoteURL(repo *git.Repository, preferred string) string {
remote, err := repo.Remote(preferred)
if err != nil {
Expand All @@ -1489,9 +1492,64 @@ func GetSafeRemoteURL(repo *git.Repository, preferred string) string {
if err != nil {
return ""
}

// If the remote URL is a local path, try to resolve the real upstream remote
// from the original repository. This handles the case where TruffleHog clones
// a local repo to a temp directory — the clone's origin points to the local
// path, but we want the original repo's actual remote URL.
if localPath := localRepoPath(safeURL); localPath != "" {
if resolved := resolveUpstreamRemote(localPath, preferred); resolved != "" {
return resolved
}
}

return safeURL
}

// localRepoPath returns the filesystem path if the URL refers to a local
// repository (file:// scheme or an absolute path), or empty string otherwise.
func localRepoPath(rawURL string) string {
if strings.HasPrefix(rawURL, "file://") {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
return u.Path
}
if filepath.IsAbs(rawURL) {
if _, err := os.Stat(rawURL); err == nil {
return rawURL
}
}
return ""
}

// resolveUpstreamRemote opens the repo at localPath and returns its remote URL,
// but only if that remote is not itself a local path (to avoid infinite recursion).
func resolveUpstreamRemote(localPath, preferred string) string {
origRepo, err := RepoFromPath(localPath)
if err != nil {
return ""
}
origRemote, err := origRepo.Remote(preferred)
if err != nil {
remotes, err := origRepo.Remotes()
if err != nil || len(remotes) == 0 {
return ""
}
origRemote = remotes[0]
}
resolved, _, err := stripPassword(origRemote.Config().URLs[0])
if err != nil {
return ""
}
// Only return if the resolved URL is not itself a local path.
if localRepoPath(resolved) != "" {
return ""
}
return resolved
}

func HandleBinary(
ctx context.Context,
gitDir string,
Expand Down
136 changes: 136 additions & 0 deletions pkg/sources/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1163,3 +1163,139 @@ func TestPrepareRepoWithNormalizationBare(t *testing.T) {
})
}
}

func TestGetSafeRemoteURL_ResolvesUpstreamFromLocalClone(t *testing.T) {
t.Parallel()

const expectedRemote = "https://dev.azure.com/org/project/_git/repo"

// Create the "original" repo with a real remote configured.
origPath := setupTestRepo(t, "original-repo")
addTestFileAndCommit(t, origPath, "file.txt", "content")
assert.NoError(t, exec.Command("git", "-C", origPath, "remote", "add", "origin", expectedRemote).Run())

// Clone the original repo locally (simulates what TruffleHog does).
cloneDir := t.TempDir()
clonePath := filepath.Join(cloneDir, "clone")
assert.NoError(t, exec.Command("git", "clone", origPath, clonePath).Run())

// The clone's origin points to the local origPath, NOT the real remote.
cloneRepo, err := RepoFromPath(clonePath)
assert.NoError(t, err)

got := GetSafeRemoteURL(cloneRepo, "origin")
assert.Equal(t, expectedRemote, got, "should resolve through the local clone to the original repo's upstream remote")
}

func TestGetSafeRemoteURL_FileSchemeResolvesUpstream(t *testing.T) {
t.Parallel()

const expectedRemote = "https://github.com/example/repo.git"

// Create the "original" repo with a real remote.
origPath := setupTestRepo(t, "original-repo")
addTestFileAndCommit(t, origPath, "file.txt", "content")
assert.NoError(t, exec.Command("git", "-C", origPath, "remote", "add", "origin", expectedRemote).Run())

// Clone using a file:// URI (this is how the user triggers the bug).
cloneDir := t.TempDir()
clonePath := filepath.Join(cloneDir, "clone")
assert.NoError(t, exec.Command("git", "clone", "file://"+origPath, clonePath).Run())

cloneRepo, err := RepoFromPath(clonePath)
assert.NoError(t, err)

got := GetSafeRemoteURL(cloneRepo, "origin")
assert.Equal(t, expectedRemote, got, "should resolve file:// origin to the upstream remote")
}

func TestGetSafeRemoteURL_NoRemote(t *testing.T) {
t.Parallel()

// Create a repo with no remotes at all.
repoPath := setupTestRepo(t, "no-remote-repo")
addTestFileAndCommit(t, repoPath, "file.txt", "content")

repo, err := RepoFromPath(repoPath)
assert.NoError(t, err)

got := GetSafeRemoteURL(repo, "origin")
assert.Empty(t, got, "should return empty string when no remotes are configured")
}

func TestGetSafeRemoteURL_LocalOriginNoUpstream(t *testing.T) {
t.Parallel()

// Create the "original" repo with NO remotes.
origPath := setupTestRepo(t, "original-no-upstream")
addTestFileAndCommit(t, origPath, "file.txt", "content")

// Clone it locally — clone's origin points to origPath, which itself has no remote.
cloneDir := t.TempDir()
clonePath := filepath.Join(cloneDir, "clone")
assert.NoError(t, exec.Command("git", "clone", origPath, clonePath).Run())

cloneRepo, err := RepoFromPath(clonePath)
assert.NoError(t, err)

got := GetSafeRemoteURL(cloneRepo, "origin")
// The original repo has no upstream, so we fall back to the local path.
assert.Equal(t, origPath, got, "should fall back to local path when original repo has no upstream remote")
}

func TestGetSafeRemoteURL_RealRemote(t *testing.T) {
t.Parallel()

const expectedRemote = "https://github.com/example/repo.git"

// A repo whose origin is already a real remote URL (the normal case).
repoPath := setupTestRepo(t, "real-remote-repo")
addTestFileAndCommit(t, repoPath, "file.txt", "content")
assert.NoError(t, exec.Command("git", "-C", repoPath, "remote", "add", "origin", expectedRemote).Run())

repo, err := RepoFromPath(repoPath)
assert.NoError(t, err)

got := GetSafeRemoteURL(repo, "origin")
assert.Equal(t, expectedRemote, got, "should return the remote URL directly when it's not a local path")
}

func TestGetSafeRemoteURL_FallsBackToFirstRemote(t *testing.T) {
t.Parallel()

const expectedRemote = "https://github.com/example/repo.git"

repoPath := setupTestRepo(t, "fallback-remote-repo")
addTestFileAndCommit(t, repoPath, "file.txt", "content")
// Add a remote named "upstream" (not "origin").
assert.NoError(t, exec.Command("git", "-C", repoPath, "remote", "add", "upstream", expectedRemote).Run())

repo, err := RepoFromPath(repoPath)
assert.NoError(t, err)

// Ask for "origin" which doesn't exist — should fall back to "upstream".
got := GetSafeRemoteURL(repo, "origin")
assert.Equal(t, expectedRemote, got, "should fall back to first available remote when preferred remote doesn't exist")
}

func TestGetSafeRemoteURL_BareCloneResolvesUpstream(t *testing.T) {
t.Parallel()

const expectedRemote = "https://dev.azure.com/org/project/_git/repo"

// Create original repo with a real remote.
origPath := setupTestRepo(t, "original-for-bare")
addTestFileAndCommit(t, origPath, "file.txt", "content")
assert.NoError(t, exec.Command("git", "-C", origPath, "remote", "add", "origin", expectedRemote).Run())

// Create a bare clone (simulates --mirror).
cloneDir := t.TempDir()
barePath := filepath.Join(cloneDir, "bare-clone")
assert.NoError(t, exec.Command("git", "clone", "--bare", origPath, barePath).Run())

bareRepo, err := RepoFromPath(barePath)
assert.NoError(t, err)

got := GetSafeRemoteURL(bareRepo, "origin")
assert.Equal(t, expectedRemote, got, "should resolve bare clone's local origin to the upstream remote")
}
Loading