Local Git Config Sanitization (#4502)
Lint / golangci-lint (push) Waiting to run
Lint / semgrep (push) Waiting to run
Release / Release (push) Waiting to run
Scan for secrets / test (push) Waiting to run
Test / test (push) Waiting to run
Test / test-community (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Lint / semgrep (push) Waiting to run
Release / Release (push) Waiting to run
Scan for secrets / test (push) Waiting to run
Test / test (push) Waiting to run
Test / test-community (push) Waiting to run
This commit prevents TruffleHog from executing arbitrary commands located in archived malicious git repositories. Thanks to Adam Reiser at Cisco Talos for pointing this out! This approach uses Git's recommended best practice for sanitizing untrusted git configs: git clone all local file:// git repos prior to scanning. Executing git clone does not execute any of the potentially malicious git configs in the untrusted repo directory, and the output only includes "safe" default configs, similar to what we see when cloning from remote. We explored a few other approaches (allowlist, denylist, etc), but those carried lots of complications. A few notes about how this works: This only applies to local repositories scanned using the git subcommand. Remote git targets are not impacted. Local git targets are now cloned to temp by default prior to scanning. Users can specify a --clone-path argument if they don't want to use the default temp dir. Users can specify --trust-local-git-config if they want to trust the repo as is and bypass cloning. Local --bare repos are handled appropriately. This approach knocks out (most...all?) of this class of malicious git config vulnerabilities. Testing: There's coverage for most of the new code, including: test cases for the specific issue reported, local bare clones, local repos with staged commits, etc. All are passing.
This commit is contained in:
@@ -269,6 +269,8 @@ Run trufflehog from the parent directory (outside the git repo).
|
||||
$ 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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -91,20 +91,21 @@ var (
|
||||
skipAdditionalRefs = cli.Flag("skip-additional-refs", "Skip additional references.").Bool()
|
||||
userAgentSuffix = cli.Flag("user-agent-suffix", "Suffix to add to User-Agent.").String()
|
||||
|
||||
gitScan = cli.Command("git", "Find credentials in git repositories.")
|
||||
gitScanURI = gitScan.Arg("uri", "Git repository URL. https://, file://, or ssh:// schema expected.").Required().String()
|
||||
gitScanIncludePaths = gitScan.Flag("include-paths", "Path to file with newline separated regexes for files to include in scan.").Short('i').String()
|
||||
gitScanExcludePaths = gitScan.Flag("exclude-paths", "Path to file with newline separated regexes for files to exclude in scan.").Short('x').String()
|
||||
gitScanExcludeGlobs = gitScan.Flag("exclude-globs", "Comma separated list of globs to exclude in scan. This option filters at the `git log` level, resulting in faster scans.").String()
|
||||
gitScanSinceCommit = gitScan.Flag("since-commit", "Commit to start scan from.").String()
|
||||
gitScanBranch = gitScan.Flag("branch", "Branch to scan.").String()
|
||||
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()
|
||||
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()
|
||||
_ = gitScan.Flag("allow", "No-op flag for backwards compat.").Bool()
|
||||
_ = gitScan.Flag("entropy", "No-op flag for backwards compat.").Bool()
|
||||
_ = gitScan.Flag("regex", "No-op flag for backwards compat.").Bool()
|
||||
gitScan = cli.Command("git", "Find credentials in git repositories.")
|
||||
gitScanURI = gitScan.Arg("uri", "Git repository URL. https://, file://, or ssh:// schema expected.").Required().String()
|
||||
gitScanIncludePaths = gitScan.Flag("include-paths", "Path to file with newline separated regexes for files to include in scan.").Short('i').String()
|
||||
gitScanExcludePaths = gitScan.Flag("exclude-paths", "Path to file with newline separated regexes for files to exclude in scan.").Short('x').String()
|
||||
gitScanExcludeGlobs = gitScan.Flag("exclude-globs", "Comma separated list of globs to exclude in scan. This option filters at the `git log` level, resulting in faster scans.").String()
|
||||
gitScanSinceCommit = gitScan.Flag("since-commit", "Commit to start scan from.").String()
|
||||
gitScanBranch = gitScan.Flag("branch", "Branch to scan.").String()
|
||||
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()
|
||||
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()
|
||||
gitTrustLocalGitConfig = gitScan.Flag("trust-local-git-config", "Trust local git config.").Bool()
|
||||
_ = gitScan.Flag("allow", "No-op flag for backwards compat.").Bool()
|
||||
_ = gitScan.Flag("entropy", "No-op flag for backwards compat.").Bool()
|
||||
_ = gitScan.Flag("regex", "No-op flag for backwards compat.").Bool()
|
||||
|
||||
githubScan = cli.Command("github", "Find credentials in GitHub repositories.")
|
||||
githubScanEndpoint = githubScan.Flag("endpoint", "GitHub endpoint.").Default("https://api.github.com").String()
|
||||
@@ -735,35 +736,24 @@ func runSingleScan(ctx context.Context, cmd string, cfg engine.Config) (metrics,
|
||||
var refs []sources.JobProgressRef
|
||||
switch cmd {
|
||||
case gitScan.FullCommand():
|
||||
gitCloneTempPath = *gitClonePath
|
||||
// validate the commit for local repository only
|
||||
if *gitScanSinceCommit != "" && strings.HasPrefix(*gitScanURI, "file") {
|
||||
if !isValidCommit(*gitScanURI, *gitScanSinceCommit) {
|
||||
ctx.Logger().Info("Warning: The provided commit hash appears to be invalid.")
|
||||
}
|
||||
}
|
||||
|
||||
// clone path is only supported for HTTPS repository URLs, not for local repositories.
|
||||
if *gitClonePath != "" && strings.HasPrefix(*gitScanURI, "file://") {
|
||||
return scanMetrics, fmt.Errorf("invalid configuration: --clone-path cannot be used with a local repository URL")
|
||||
}
|
||||
|
||||
if err := validateClonePath(*gitClonePath, *gitNoCleanup); err != nil {
|
||||
return scanMetrics, err
|
||||
}
|
||||
|
||||
gitCfg := sources.GitConfig{
|
||||
URI: *gitScanURI,
|
||||
IncludePathsFile: *gitScanIncludePaths,
|
||||
ExcludePathsFile: *gitScanExcludePaths,
|
||||
HeadRef: *gitScanBranch,
|
||||
BaseRef: *gitScanSinceCommit,
|
||||
MaxDepth: *gitScanMaxDepth,
|
||||
Bare: *gitScanBare,
|
||||
ExcludeGlobs: *gitScanExcludeGlobs,
|
||||
ClonePath: *gitClonePath,
|
||||
NoCleanup: *gitNoCleanup,
|
||||
PrintLegacyJSON: *jsonLegacy,
|
||||
URI: *gitScanURI,
|
||||
IncludePathsFile: *gitScanIncludePaths,
|
||||
ExcludePathsFile: *gitScanExcludePaths,
|
||||
HeadRef: *gitScanBranch,
|
||||
BaseRef: *gitScanSinceCommit,
|
||||
MaxDepth: *gitScanMaxDepth,
|
||||
Bare: *gitScanBare,
|
||||
ExcludeGlobs: *gitScanExcludeGlobs,
|
||||
ClonePath: *gitClonePath,
|
||||
NoCleanup: *gitNoCleanup,
|
||||
PrintLegacyJSON: *jsonLegacy,
|
||||
TrustLocalGitConfig: *gitTrustLocalGitConfig,
|
||||
}
|
||||
if ref, err := eng.ScanGit(ctx, gitCfg); err != nil {
|
||||
return scanMetrics, fmt.Errorf("failed to scan Git: %v", err)
|
||||
@@ -1168,18 +1158,6 @@ func printAverageDetectorTime(e *engine.Engine) {
|
||||
}
|
||||
}
|
||||
|
||||
// Function to check if the commit is valid
|
||||
func isValidCommit(uri, commit string) bool {
|
||||
// handle file:// urls
|
||||
repoPath, _ := strings.CutPrefix(uri, "file://") // remove the prefix to validate against the repo path
|
||||
output, err := exec.Command("git", "-C", repoPath, "cat-file", "-t", commit).Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(output)) == "commit"
|
||||
}
|
||||
|
||||
// validateClonePath ensures that --clone-path, if provided, exists and is a directory.
|
||||
// It also verifies that --no-cleanup is only allowed when --clone-path is set.
|
||||
// Note: without a custom clone path, repositories are cloned into temporary directories, which should never be retained.
|
||||
|
||||
+13
-12
@@ -15,18 +15,19 @@ import (
|
||||
// ScanGit scans any git source.
|
||||
func (e *Engine) ScanGit(ctx context.Context, c sources.GitConfig) (sources.JobProgressRef, error) {
|
||||
connection := &sourcespb.Git{
|
||||
Head: c.HeadRef,
|
||||
Base: c.BaseRef,
|
||||
Bare: c.Bare,
|
||||
Uri: c.URI,
|
||||
ExcludeGlobs: c.ExcludeGlobs,
|
||||
IncludePathsFile: c.IncludePathsFile,
|
||||
ExcludePathsFile: c.ExcludePathsFile,
|
||||
MaxDepth: int64(c.MaxDepth),
|
||||
SkipBinaries: c.SkipBinaries,
|
||||
ClonePath: c.ClonePath,
|
||||
NoCleanup: c.NoCleanup,
|
||||
PrintLegacyJson: c.PrintLegacyJSON,
|
||||
Head: c.HeadRef,
|
||||
Base: c.BaseRef,
|
||||
Bare: c.Bare,
|
||||
Uri: c.URI,
|
||||
ExcludeGlobs: c.ExcludeGlobs,
|
||||
IncludePathsFile: c.IncludePathsFile,
|
||||
ExcludePathsFile: c.ExcludePathsFile,
|
||||
MaxDepth: int64(c.MaxDepth),
|
||||
SkipBinaries: c.SkipBinaries,
|
||||
ClonePath: c.ClonePath,
|
||||
NoCleanup: c.NoCleanup,
|
||||
PrintLegacyJson: c.PrintLegacyJSON,
|
||||
TrustLocalGitConfig: c.TrustLocalGitConfig,
|
||||
}
|
||||
|
||||
var conn anypb.Any
|
||||
|
||||
+78
-1
@@ -1,9 +1,13 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
@@ -11,6 +15,7 @@ import (
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/decoders"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/defaults"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/feature"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/sources/git"
|
||||
@@ -32,7 +37,7 @@ func (p *discardPrinter) Print(context.Context, *detectors.ResultWithMetadata) e
|
||||
func TestGitEngine(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repoUrl := "https://github.com/dustin-decker/secretsandstuff.git"
|
||||
path, _, err := git.PrepareRepo(ctx, repoUrl, "")
|
||||
path, _, err := git.PrepareRepo(ctx, repoUrl, "", false, false)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
@@ -118,6 +123,78 @@ func TestGitEngine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitEngineWithMirrorAndBareClones(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
parent, err := os.MkdirTemp("", "trufflehog-test-keys-*")
|
||||
if err != nil {
|
||||
t.Fail()
|
||||
}
|
||||
defer os.RemoveAll(parent)
|
||||
localRepo := filepath.Join(parent, "test_keys.git")
|
||||
cloneCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// clone with --mirror and --bare from https://github.com/trufflesecurity/test_keys.git to local and then pass it in as a local path
|
||||
cloneCmd := exec.CommandContext(cloneCtx, "git", "clone", "--mirror", "--bare", "https://github.com/trufflesecurity/test_keys.git", localRepo)
|
||||
if _, err := cloneCmd.CombinedOutput(); err != nil {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
fileURI := (&url.URL{Scheme: "file", Path: filepath.ToSlash(localRepo)}).String()
|
||||
|
||||
run := func(t *testing.T, mirror bool, cfg sources.GitConfig) (uint64, uint64) {
|
||||
t.Helper()
|
||||
|
||||
const defaultOutputBufferSize = 64
|
||||
opts := []func(*sources.SourceManager){
|
||||
sources.WithSourceUnits(),
|
||||
sources.WithBufferedOutput(defaultOutputBufferSize),
|
||||
}
|
||||
sourceManager := sources.NewManager(opts...)
|
||||
|
||||
conf := Config{
|
||||
Concurrency: 1,
|
||||
Decoders: decoders.DefaultDecoders(),
|
||||
Detectors: defaults.DefaultDetectors(),
|
||||
Verify: false, // avoid network-dependent verification in tests
|
||||
SourceManager: sourceManager,
|
||||
Dispatcher: NewPrinterDispatcher(new(discardPrinter)),
|
||||
}
|
||||
|
||||
feature.UseGitMirror.Store(false)
|
||||
if mirror {
|
||||
feature.UseGitMirror.Store(true)
|
||||
defer feature.UseGitMirror.Store(false)
|
||||
}
|
||||
|
||||
e, err := NewEngine(ctx, &conf)
|
||||
assert.NoError(t, err)
|
||||
|
||||
e.Start(ctx)
|
||||
_, err = e.ScanGit(ctx, cfg)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, e.Finish(ctx))
|
||||
|
||||
m := e.GetMetrics()
|
||||
secrets := m.VerifiedSecretsFound + m.UnverifiedSecretsFound
|
||||
bytes := m.BytesScanned
|
||||
return secrets, bytes
|
||||
}
|
||||
|
||||
s1, b1 := run(t, true, sources.GitConfig{URI: "https://github.com/trufflesecurity/test_keys.git"})
|
||||
s2, b2 := run(t, false, sources.GitConfig{URI: fileURI, Bare: true})
|
||||
s3, b3 := run(t, false, sources.GitConfig{URI: fileURI, Bare: true, TrustLocalGitConfig: true})
|
||||
|
||||
assert.Greater(t, int(s1), 0)
|
||||
assert.Greater(t, int(b1), 0)
|
||||
|
||||
assert.Equal(t, s1, s2)
|
||||
assert.Equal(t, s1, s3)
|
||||
assert.Equal(t, b1, b2)
|
||||
assert.Equal(t, b1, b3)
|
||||
}
|
||||
|
||||
func BenchmarkGitEngine(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
repoUrl := "https://github.com/dustin-decker/secretsandstuff.git"
|
||||
|
||||
+579
-568
File diff suppressed because it is too large
Load Diff
@@ -2089,6 +2089,8 @@ func (m *Git) validate(all bool) error {
|
||||
|
||||
// no validation rules for PrintLegacyJson
|
||||
|
||||
// no validation rules for TrustLocalGitConfig
|
||||
|
||||
switch v := m.Credential.(type) {
|
||||
case *Git_BasicAuth:
|
||||
if v == nil {
|
||||
|
||||
+113
-45
@@ -145,7 +145,7 @@ func (s *Source) withScanOptions(scanOptions *ScanOptions) {
|
||||
s.scanOptions = scanOptions
|
||||
}
|
||||
|
||||
// Init returns an initialized GitHub source.
|
||||
// Init returns an initialized Git source.
|
||||
func (s *Source) Init(aCtx context.Context, name string, jobId sources.JobID, sourceId sources.SourceID, verify bool, connection *anypb.Any, concurrency int) error {
|
||||
s.name = name
|
||||
s.sourceID = sourceId
|
||||
@@ -161,7 +161,7 @@ func (s *Source) Init(aCtx context.Context, name string, jobId sources.JobID, so
|
||||
}
|
||||
|
||||
if uri := conn.GetUri(); uri != "" {
|
||||
repoPath, _, err := prepareRepoSinceCommit(aCtx, uri, conn.GetClonePath(), conn.GetBase())
|
||||
repoPath, _, err := prepareRepoSinceCommit(aCtx, uri, conn.GetClonePath(), conn.GetBase(), conn.GetTrustLocalGitConfig(), conn.GetBare())
|
||||
if err != nil || repoPath == "" {
|
||||
return fmt.Errorf("error preparing repo: %w", err)
|
||||
}
|
||||
@@ -344,13 +344,11 @@ func (s *Source) scanDir(ctx context.Context, gitDir string, reporter sources.Ch
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the directory exists
|
||||
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("directory does not exist: %s", gitDir)
|
||||
}
|
||||
|
||||
// try paths instead of url
|
||||
repo, err := RepoFromPath(gitDir, s.scanOptions.Bare)
|
||||
repo, err := RepoFromPath(gitDir)
|
||||
if err != nil {
|
||||
return reporter.ChunkErr(ctx, err)
|
||||
}
|
||||
@@ -372,7 +370,15 @@ func (s *Source) scanDir(ctx context.Context, gitDir string, reporter sources.Ch
|
||||
return nil
|
||||
}
|
||||
|
||||
func RepoFromPath(path string, isBare bool) (*git.Repository, error) {
|
||||
// RepoFromPath opens a git repository from a given path.
|
||||
// If the repository is bare (--mirror or --bare), the directory referenced by the path variable
|
||||
// will contain the contents of the git directory (ex: path/HEAD, path/config, etc.). In this case,
|
||||
// detectDotGit and enableDotGitCommonDir need to be false.
|
||||
// Otherwise, they need to be true so git can find the git directory (path/.git)
|
||||
//
|
||||
// See: https://git-scm.com/docs/gitrepository-layout#_description
|
||||
func RepoFromPath(path string) (*git.Repository, error) {
|
||||
isBare := isRepoBare(path)
|
||||
options := &git.PlainOpenOptions{}
|
||||
if !isBare {
|
||||
options.DetectDotGit = true
|
||||
@@ -403,6 +409,39 @@ func GitURLParse(gitURL string) (*url.URL, error) {
|
||||
return parsedURL, nil
|
||||
}
|
||||
|
||||
// normalizeFileURI converts relative file URIs to absolute paths.
|
||||
// This ensures that file:// URIs work correctly with git clone operations.
|
||||
func normalizeFileURI(uri *url.URL) (*url.URL, error) {
|
||||
if uri.Scheme != "file" {
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
var rawPath string
|
||||
if uri.Host != "" {
|
||||
// Handle cases like file://. or file://./relative/path
|
||||
if uri.Path == "" {
|
||||
rawPath = uri.Host
|
||||
} else {
|
||||
rawPath = filepath.Join(uri.Host, uri.Path)
|
||||
}
|
||||
} else {
|
||||
// Handle cases like file:///absolute/path
|
||||
rawPath = uri.Path
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(rawPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve absolute path for %q: %w", rawPath, err)
|
||||
}
|
||||
|
||||
normalizedURI := &url.URL{
|
||||
Scheme: "file",
|
||||
Path: absPath,
|
||||
}
|
||||
|
||||
return normalizedURI, nil
|
||||
}
|
||||
|
||||
type cloneParams struct {
|
||||
userInfo *url.Userinfo
|
||||
gitURL string
|
||||
@@ -422,7 +461,7 @@ func CloneRepo(ctx context.Context, userInfo *url.Userinfo, gitURL string, clone
|
||||
|
||||
// If --clone-path is set, create a subdirectory <clonePath>/trufflehog-<repo-name> with permissions 0755.
|
||||
if clonePath != "" {
|
||||
path = filepath.Join(clonePath, "trufflehog-"+strings.TrimSuffix(filepath.Base(gitURL), ".git"))
|
||||
path = filepath.Join(clonePath, "trufflehog-"+strings.TrimSuffix(filepath.Base(gitURL), gitDirName))
|
||||
if err = os.MkdirAll(path, 0755); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create clone path %s: %w", clonePath, err)
|
||||
}
|
||||
@@ -476,30 +515,21 @@ func executeClone(ctx context.Context, params cloneParams) (*git.Repository, err
|
||||
gitArgs = append(gitArgs, "-c", fmt.Sprintf("http.extraHeader=Authorization: Basic %s", authHeader))
|
||||
}
|
||||
}
|
||||
// append clone argument
|
||||
|
||||
gitArgs = append(gitArgs, "clone")
|
||||
var dstGitDir string
|
||||
if feature.UseGitMirror.Load() {
|
||||
gitArgs = append(gitArgs,
|
||||
"--mirror",
|
||||
)
|
||||
dstGitDir = filepath.Join(params.clonePath, gitDirName) // <tmp>/.git
|
||||
if feature.UseGitMirror.Load() && cloneURL.Scheme != "file" {
|
||||
gitArgs = append(gitArgs, "--mirror")
|
||||
} else {
|
||||
if !feature.SkipAdditionalRefs.Load() {
|
||||
gitArgs = append(gitArgs,
|
||||
"-c",
|
||||
"remote.origin.fetch=+refs/*:refs/remotes/origin/*")
|
||||
}
|
||||
dstGitDir = params.clonePath
|
||||
}
|
||||
|
||||
gitArgs = append(gitArgs,
|
||||
cloneURL.String(),
|
||||
dstGitDir,
|
||||
"--quiet", // https://git-scm.com/docs/git-clone#Documentation/git-clone.txt-code--quietcode
|
||||
)
|
||||
|
||||
gitArgs = append(gitArgs, "--quiet")
|
||||
gitArgs = append(gitArgs, params.args...)
|
||||
gitArgs = append(gitArgs, cloneURL.String(), params.clonePath)
|
||||
cloneCmd := exec.Command("git", gitArgs...)
|
||||
|
||||
safeURL, secretForRedaction, err := stripPassword(params.gitURL)
|
||||
@@ -513,7 +543,6 @@ func executeClone(ctx context.Context, params cloneParams) (*git.Repository, err
|
||||
"args", params.args,
|
||||
)
|
||||
|
||||
// Execute command and wait for the stdout / stderr.
|
||||
outputBytes, err := cloneCmd.CombinedOutput()
|
||||
var output string
|
||||
if secretForRedaction != "" {
|
||||
@@ -531,21 +560,18 @@ func executeClone(ctx context.Context, params cloneParams) (*git.Repository, err
|
||||
return nil, fmt.Errorf("clone command exited with no output")
|
||||
} else if cloneCmd.ProcessState.ExitCode() != 0 {
|
||||
logger.V(1).Info("git clone failed", "error", err)
|
||||
// Record the clone failure with the appropriate reason and exit code
|
||||
failureReason := ClassifyCloneError(output)
|
||||
exitCode := cloneCmd.ProcessState.ExitCode()
|
||||
metricsInstance.RecordCloneOperation(statusFailure, failureReason, exitCode)
|
||||
return nil, fmt.Errorf("could not clone repo: %s, %w", safeURL, err)
|
||||
}
|
||||
|
||||
options := &git.PlainOpenOptions{DetectDotGit: true, EnableDotGitCommonDir: true}
|
||||
repo, err := git.PlainOpenWithOptions(params.clonePath, options)
|
||||
repo, err := RepoFromPath(params.clonePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open cloned repo: %w", err)
|
||||
}
|
||||
logger.V(1).Info("successfully cloned repo")
|
||||
|
||||
// Record the successful clone operation
|
||||
metricsInstance.RecordCloneOperation(statusSuccess, cloneSuccess, 0)
|
||||
|
||||
return repo, nil
|
||||
@@ -625,8 +651,9 @@ const gitDirName = ".git"
|
||||
// exists in the ".git" directory at the root of the working tree.
|
||||
//
|
||||
// See: https://git-scm.com/docs/gitrepository-layout#_description
|
||||
func getGitDir(path string, options *ScanOptions) string {
|
||||
if options.Bare {
|
||||
func getGitDir(path string) string {
|
||||
isBare := isRepoBare(path)
|
||||
if isBare {
|
||||
return path
|
||||
} else {
|
||||
return filepath.Join(path, gitDirName)
|
||||
@@ -660,7 +687,7 @@ func (s *Git) ScanCommits(ctx context.Context, repo *git.Repository, path string
|
||||
logValues = append(logValues, "max_depth", scanOptions.MaxDepth)
|
||||
}
|
||||
|
||||
diffChan, err := s.parser.RepoPath(repoCtx, path, scanOptions.HeadHash, scanOptions.BaseHash == "", scanOptions.ExcludeGlobs, scanOptions.Bare)
|
||||
diffChan, err := s.parser.RepoPath(repoCtx, path, scanOptions.HeadHash, scanOptions.BaseHash == "", scanOptions.ExcludeGlobs, isRepoBare(path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -671,7 +698,7 @@ func (s *Git) ScanCommits(ctx context.Context, repo *git.Repository, path string
|
||||
logger.Info("scanning repo", logValues...)
|
||||
|
||||
var (
|
||||
gitDir = getGitDir(path, scanOptions)
|
||||
gitDir = getGitDir(path)
|
||||
depth int64
|
||||
lastCommitHash string
|
||||
)
|
||||
@@ -922,7 +949,7 @@ func (s *Git) ScanStaged(ctx context.Context, repo *git.Repository, path string,
|
||||
|
||||
var (
|
||||
reachedBase = false
|
||||
gitDir = getGitDir(path, scanOptions)
|
||||
gitDir = getGitDir(path)
|
||||
depth int64
|
||||
lastCommitHash string
|
||||
)
|
||||
@@ -1041,7 +1068,8 @@ func (s *Git) ScanRepo(ctx context.Context, repo *git.Repository, repoPath strin
|
||||
s.metrics.RecordRepoScanned(statusFailure)
|
||||
return err
|
||||
}
|
||||
if !scanOptions.Bare {
|
||||
// Skip staged scanning for mirror/bare clones
|
||||
if !isRepoBare(repoPath) {
|
||||
if err := s.ScanStaged(ctx, repo, repoPath, scanOptions, reporter); err != nil {
|
||||
ctx.Logger().V(1).Info("error scanning unstaged changes", "error", err)
|
||||
}
|
||||
@@ -1192,9 +1220,9 @@ func TryAdditionalBaseRefs(repo *git.Repository, base string) (*plumbing.Hash, e
|
||||
}
|
||||
|
||||
// prepareRepoSinceCommit clones a repo starting at the given commitHash and returns the cloned repo path.
|
||||
func prepareRepoSinceCommit(ctx context.Context, uriString, clonePath, commitHash string) (string, bool, error) {
|
||||
func prepareRepoSinceCommit(ctx context.Context, uriString, clonePath, commitHash string, trustLocalGitConfig bool, isBare bool) (string, bool, error) {
|
||||
if commitHash == "" {
|
||||
return PrepareRepo(ctx, uriString, clonePath)
|
||||
return PrepareRepo(ctx, uriString, clonePath, trustLocalGitConfig, isBare)
|
||||
}
|
||||
// TODO: refactor with PrepareRepo to remove duplicated logic
|
||||
|
||||
@@ -1209,13 +1237,13 @@ func prepareRepoSinceCommit(ctx context.Context, uriString, clonePath, commitHas
|
||||
}
|
||||
|
||||
if uri.Scheme == "file" || uri.Host != "github.com" {
|
||||
return PrepareRepo(ctx, uriString, "")
|
||||
return PrepareRepo(ctx, uriString, "", trustLocalGitConfig, isBare)
|
||||
}
|
||||
|
||||
uriPath := strings.TrimPrefix(uri.Path, "/")
|
||||
owner, repoName, found := strings.Cut(uriPath, "/")
|
||||
if !found {
|
||||
return PrepareRepo(ctx, uriString, clonePath)
|
||||
return PrepareRepo(ctx, uriString, clonePath, trustLocalGitConfig, isBare)
|
||||
}
|
||||
|
||||
client := github.NewClient(nil)
|
||||
@@ -1229,13 +1257,13 @@ func prepareRepoSinceCommit(ctx context.Context, uriString, clonePath, commitHas
|
||||
|
||||
commit, _, err := client.Git.GetCommit(context.Background(), owner, repoName, commitHash)
|
||||
if err != nil {
|
||||
return PrepareRepo(ctx, uriString, clonePath)
|
||||
return PrepareRepo(ctx, uriString, clonePath, trustLocalGitConfig, isBare)
|
||||
}
|
||||
var timestamp string
|
||||
{
|
||||
author := commit.GetAuthor()
|
||||
if author == nil {
|
||||
return PrepareRepo(ctx, uriString, clonePath)
|
||||
return PrepareRepo(ctx, uriString, clonePath, trustLocalGitConfig, isBare)
|
||||
}
|
||||
timestamp = author.GetDate().Format(time.RFC3339)
|
||||
}
|
||||
@@ -1252,13 +1280,13 @@ func prepareRepoSinceCommit(ctx context.Context, uriString, clonePath, commitHas
|
||||
|
||||
path, _, err = CloneRepoUsingToken(ctx, password, remotePath, clonePath, uri.User.Username(), true, "--shallow-since", timestamp)
|
||||
if err != nil {
|
||||
return path, true, fmt.Errorf("failed to clone authenticated Git repo (%s): %s", uri.Redacted(), err)
|
||||
return path, true, fmt.Errorf("failed to clone authenticated Git repo (%s): %w", uri.Redacted(), err)
|
||||
}
|
||||
default:
|
||||
ctx.Logger().V(1).Info("cloning repo without authentication", "uri", uri)
|
||||
path, _, err = CloneRepoUsingUnauthenticated(ctx, remotePath, clonePath, "--shallow-since", timestamp)
|
||||
if err != nil {
|
||||
return path, true, fmt.Errorf("failed to clone unauthenticated Git repo (%s): %s", remotePath, err)
|
||||
return path, true, fmt.Errorf("failed to clone unauthenticated Git repo (%s): %w", remotePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1267,7 +1295,8 @@ func prepareRepoSinceCommit(ctx context.Context, uriString, clonePath, commitHas
|
||||
}
|
||||
|
||||
// PrepareRepo clones a repo if possible and returns the cloned repo path.
|
||||
func PrepareRepo(ctx context.Context, uriString, clonePath string) (string, bool, error) {
|
||||
// isBare and trustLocalGitConfig are only used for file:// URIs.
|
||||
func PrepareRepo(ctx context.Context, uriString, clonePath string, trustLocalGitConfig bool, isBare bool) (string, bool, error) {
|
||||
var path string
|
||||
uri, err := GitURLParse(uriString)
|
||||
if err != nil {
|
||||
@@ -1277,7 +1306,39 @@ func PrepareRepo(ctx context.Context, uriString, clonePath string) (string, bool
|
||||
remote := false
|
||||
switch uri.Scheme {
|
||||
case "file":
|
||||
path = fmt.Sprintf("%s%s", uri.Host, uri.Path)
|
||||
switch {
|
||||
case trustLocalGitConfig:
|
||||
path = fmt.Sprintf("%s%s", uri.Host, uri.Path)
|
||||
default:
|
||||
normalizedURI, err := normalizeFileURI(uri)
|
||||
if err != nil {
|
||||
return "", remote, fmt.Errorf("failed to normalize file URI (%s): %w", uriString, err)
|
||||
}
|
||||
|
||||
args := []string{}
|
||||
if isBare {
|
||||
args = append(args, "--bare")
|
||||
}
|
||||
path, _, err = CloneRepo(ctx, uri.User, normalizedURI.String(), clonePath, false, args...)
|
||||
if err != nil {
|
||||
return path, remote, fmt.Errorf("failed to clone file Git repo (%s): %w", normalizedURI.String(), err)
|
||||
}
|
||||
|
||||
if !isRepoBare(path) {
|
||||
// Only copy index file for non-bare clones from working directory repos. This is used to see staged changes.
|
||||
// Note: To scan **un**staged changes in the future, we'd need to set core.worktree to the original path.
|
||||
originalIndexPath := filepath.Join(strings.TrimPrefix(normalizedURI.String(), "file://"), gitDirName, "index")
|
||||
clonedIndexPath := filepath.Join(path, gitDirName, "index")
|
||||
|
||||
indexData, err := os.ReadFile(originalIndexPath)
|
||||
if err != nil {
|
||||
return path, remote, fmt.Errorf("failed to read index file: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(clonedIndexPath, indexData, 0644); err != nil {
|
||||
return path, remote, fmt.Errorf("failed to write index file: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "http", "https":
|
||||
remotePath := uri.String()
|
||||
remote = true
|
||||
@@ -1291,13 +1352,13 @@ func PrepareRepo(ctx context.Context, uriString, clonePath string) (string, bool
|
||||
|
||||
path, _, err = CloneRepoUsingToken(ctx, password, remotePath, clonePath, uri.User.Username(), true)
|
||||
if err != nil {
|
||||
return path, remote, fmt.Errorf("failed to clone authenticated Git repo (%s): %s", uri.Redacted(), err)
|
||||
return path, remote, fmt.Errorf("failed to clone authenticated Git repo (%s): %w", uri.Redacted(), err)
|
||||
}
|
||||
default:
|
||||
ctx.Logger().V(1).Info("cloning repo without authentication", "uri", uri)
|
||||
path, _, err = CloneRepoUsingUnauthenticated(ctx, remotePath, clonePath)
|
||||
if err != nil {
|
||||
return path, remote, fmt.Errorf("failed to clone unauthenticated Git repo (%s): %s", remotePath, err)
|
||||
return path, remote, fmt.Errorf("failed to clone unauthenticated Git repo (%s): %w", remotePath, err)
|
||||
}
|
||||
}
|
||||
case "ssh":
|
||||
@@ -1305,7 +1366,7 @@ func PrepareRepo(ctx context.Context, uriString, clonePath string) (string, bool
|
||||
remote = true
|
||||
path, _, err = CloneRepoUsingSSH(ctx, remotePath)
|
||||
if err != nil {
|
||||
return path, remote, fmt.Errorf("failed to clone unauthenticated Git repo (%s): %s", remotePath, err)
|
||||
return path, remote, fmt.Errorf("failed to clone unauthenticated Git repo (%s): %w", remotePath, err)
|
||||
}
|
||||
default:
|
||||
return "", remote, fmt.Errorf("unsupported Git URI: %s", uriString)
|
||||
@@ -1457,3 +1518,10 @@ func (s *Source) ChunkUnit(ctx context.Context, unit sources.SourceUnit, reporte
|
||||
func (s *Source) UnmarshalSourceUnit(data []byte) (sources.SourceUnit, error) {
|
||||
return UnmarshalUnit(data)
|
||||
}
|
||||
|
||||
// isRepoBare returns true if the repo path does NOT contain a .git directory.
|
||||
// This is a helper function used outside of the source struct.
|
||||
func isRepoBare(repoPath string) bool {
|
||||
_, err := os.Stat(filepath.Join(repoPath, gitDirName))
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
|
||||
+516
-2
@@ -3,6 +3,9 @@ package git
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -435,6 +438,8 @@ func TestPrepareRepo(t *testing.T) {
|
||||
path: true,
|
||||
remote: false,
|
||||
err: nil,
|
||||
// Note: If we set trustLocalGitConfig to false (below), we will get an error for this test
|
||||
// b/c it's not a valid git repo and we will try to clone it.
|
||||
},
|
||||
{
|
||||
uri: "no bueno",
|
||||
@@ -446,7 +451,7 @@ func TestPrepareRepo(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
ctx := context.Background()
|
||||
repo, b, err := PrepareRepo(ctx, tt.uri, "")
|
||||
repo, b, err := PrepareRepo(ctx, tt.uri, "", true, false)
|
||||
var repoLen bool
|
||||
if len(repo) > 0 {
|
||||
repoLen = true
|
||||
@@ -463,7 +468,7 @@ func BenchmarkPrepareRepo(b *testing.B) {
|
||||
uri := "https://github.com/dustin-decker/secretsandstuff.git"
|
||||
ctx := context.Background()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _ = PrepareRepo(ctx, uri, "")
|
||||
_, _, _ = PrepareRepo(ctx, uri, "", false, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,3 +603,512 @@ func TestChunkUnit(t *testing.T) {
|
||||
assert.Equal(t, 22, len(reporter.Chunks))
|
||||
assert.Equal(t, 0, len(reporter.ChunkErrs))
|
||||
}
|
||||
|
||||
func setupTestRepo(t *testing.T, repoName string) string {
|
||||
tempDir := t.TempDir()
|
||||
repoPath := filepath.Join(tempDir, repoName)
|
||||
|
||||
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())
|
||||
|
||||
return repoPath
|
||||
}
|
||||
|
||||
func addTestFileAndCommit(t *testing.T, repoPath, filename, content string) {
|
||||
testFile := filepath.Join(repoPath, filename)
|
||||
assert.NoError(t, os.WriteFile(testFile, []byte(content), 0644))
|
||||
assert.NoError(t, exec.Command("git", "-C", repoPath, "add", filename).Run())
|
||||
assert.NoError(t, exec.Command("git", "-C", repoPath, "commit", "-m", "Test commit").Run())
|
||||
}
|
||||
|
||||
func addMaliciousGitConfig(t *testing.T, repoPath string) {
|
||||
assert.NoError(t, exec.Command("git", "-C", repoPath, "config", "alias.test", "!touch malicious_alias.txt").Run())
|
||||
}
|
||||
|
||||
func testPrepareRepoSanitization(t *testing.T, repoPath string, trustLocalGitConfig, isBare bool) {
|
||||
ctx := context.Background()
|
||||
fileURI := "file://" + repoPath
|
||||
|
||||
preparedPath, isRemote, err := PrepareRepo(ctx, fileURI, "", trustLocalGitConfig, isBare)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, isRemote, "Local file URI should not be considered remote")
|
||||
|
||||
if !trustLocalGitConfig {
|
||||
assert.NotEqual(t, repoPath, preparedPath, "Sanitized repo path should be different from original")
|
||||
|
||||
_, err := os.Stat(preparedPath)
|
||||
assert.NoError(t, err, "Cloned repository should exist")
|
||||
|
||||
if isBare {
|
||||
_, err = os.Stat(filepath.Join(preparedPath, "refs"))
|
||||
assert.NoError(t, err, "Bare repo should have refs directory")
|
||||
_, err = os.Stat(filepath.Join(preparedPath, "HEAD"))
|
||||
assert.NoError(t, err, "Bare repo should have HEAD file")
|
||||
_, err = os.Stat(filepath.Join(preparedPath, ".git"))
|
||||
assert.True(t, os.IsNotExist(err), "Bare repo should not have .git subdirectory")
|
||||
} else {
|
||||
_, err = exec.Command("git", "-C", preparedPath, "status").Output()
|
||||
assert.NoError(t, err, "Cloned repo should be a valid git repository")
|
||||
}
|
||||
} else {
|
||||
assert.Equal(t, repoPath, preparedPath, "Trusted repo should use original path")
|
||||
output, err := exec.Command("git", "-C", preparedPath, "config", "--get", "alias.test").Output()
|
||||
assert.NoError(t, err, "Malicious git config should be present")
|
||||
assert.Equal(t, "!touch malicious_alias.txt", strings.TrimSpace(string(output)), "Malicious git config should be present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitConfigSanitization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repoPath := setupTestRepo(t, "test-repo")
|
||||
addMaliciousGitConfig(t, repoPath)
|
||||
addTestFileAndCommit(t, repoPath, "test.txt", "test content\nsecret: AKIA1234567890123456")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
trustLocalGitConfig bool
|
||||
}{
|
||||
{"sanitization enabled - should clone", false},
|
||||
{"sanitization disabled - direct access", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testPrepareRepoSanitization(t, repoPath, tt.trustLocalGitConfig, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitConfigSanitizationWithBareRepo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repoPath := setupTestRepo(t, "working-repo-original")
|
||||
addTestFileAndCommit(t, repoPath, "test.txt", "test content\nsecret: AKIA1234567890123456")
|
||||
|
||||
tempDir := t.TempDir()
|
||||
bareRepoPath := filepath.Join(tempDir, "working-repo")
|
||||
assert.NoError(t, exec.Command("git", "clone", repoPath, bareRepoPath, "--bare").Run())
|
||||
addMaliciousGitConfig(t, bareRepoPath)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
trustLocalGitConfig bool
|
||||
isBare bool
|
||||
}{
|
||||
{"sanitization enabled - should clone bare repo", false, true},
|
||||
{"sanitization disabled - direct access of bare repo", true, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testPrepareRepoSanitization(t, bareRepoPath, tt.trustLocalGitConfig, tt.isBare)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitConfigSecurityIsolation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
maliciousRepoPath := setupTestRepo(t, "malicious-repo")
|
||||
addTestFileAndCommit(t, maliciousRepoPath, "test.txt", "test content")
|
||||
addMaliciousGitConfig(t, maliciousRepoPath)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
trustLocalGitConfig bool
|
||||
}{
|
||||
{"sanitized repo should be isolated from malicious config", false},
|
||||
{"trusted repo should use original path with all configs", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fileURI := "file://" + maliciousRepoPath
|
||||
preparedPath, isRemote, err := PrepareRepo(ctx, fileURI, "", tt.trustLocalGitConfig, false)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, isRemote)
|
||||
|
||||
maliciousFilePath := filepath.Join(preparedPath, "malicious_alias.txt")
|
||||
|
||||
if tt.trustLocalGitConfig {
|
||||
assert.Equal(t, maliciousRepoPath, preparedPath, "Trusted repo should use original path")
|
||||
|
||||
output, err := exec.Command("git", "-C", preparedPath, "config", "--get", "alias.test").Output()
|
||||
assert.NoError(t, err, "Config alias.test should exist in trusted (original) repo")
|
||||
assert.Equal(t, "!touch malicious_alias.txt", strings.TrimSpace(string(output)),
|
||||
"Config alias.test should have original dangerous value")
|
||||
|
||||
err = exec.Command("git", "-C", preparedPath, "test").Run()
|
||||
assert.NoError(t, err)
|
||||
_, err = os.Stat(maliciousFilePath)
|
||||
assert.False(t, os.IsNotExist(err), "Malicious file malicious_alias.txt should exist in trusted environment")
|
||||
|
||||
} else {
|
||||
assert.NotEqual(t, maliciousRepoPath, preparedPath, "Sanitized repo should be different from original")
|
||||
|
||||
_, err = os.Stat(preparedPath)
|
||||
assert.NoError(t, err, "Sanitized repository should exist")
|
||||
|
||||
_, err = exec.Command("git", "-C", preparedPath, "status").Output()
|
||||
assert.NoError(t, err, "Sanitized repo should be a valid git repository")
|
||||
|
||||
err = exec.Command("git", "-C", preparedPath, "test").Run()
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = os.Stat(maliciousFilePath)
|
||||
assert.True(t, os.IsNotExist(err), "Malicious file malicious_alias.txt should not exist in sanitized environment")
|
||||
}
|
||||
// if the file exists, remove it
|
||||
if _, err := os.Stat(maliciousFilePath); err == nil {
|
||||
assert.NoError(t, os.Remove(maliciousFilePath))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitConfigSanitizationWithStagedChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
repoPath := setupTestRepo(t, "staged-changes-repo")
|
||||
addMaliciousGitConfig(t, repoPath)
|
||||
addTestFileAndCommit(t, repoPath, "test.txt", "test content\nsecret: AKIA1234567890123456")
|
||||
|
||||
testFile := filepath.Join(repoPath, "test.txt")
|
||||
assert.NoError(t, os.WriteFile(testFile, []byte("modified content with secret: AKIA1234567890123456"), 0644))
|
||||
assert.NoError(t, exec.Command("git", "-C", repoPath, "add", "test.txt").Run())
|
||||
|
||||
output, err := exec.Command("git", "-C", repoPath, "diff", "--cached").Output()
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(output), "modified content", "Staged changes should exist in original repo")
|
||||
|
||||
t.Run("staged changes preserved during sanitization", func(t *testing.T) {
|
||||
fileURI := "file://" + repoPath
|
||||
preparedPath, isRemote, err := PrepareRepo(ctx, fileURI, "", false, false) // Enable sanitization
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, isRemote)
|
||||
assert.NotEqual(t, repoPath, preparedPath, "Sanitized repo should be cloned")
|
||||
|
||||
stagedOutput, err := exec.Command("git", "-C", preparedPath, "diff", "--cached").Output()
|
||||
if err == nil && len(stagedOutput) > 0 {
|
||||
assert.Contains(t, string(stagedOutput), "modified content", "Staged changes should be preserved in cloned repo")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrepareRepoErrorPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("clone failure should return error", func(t *testing.T) {
|
||||
invalidFileURI := "file:///nonexistent/invalid/repo/path"
|
||||
preparedPath, isRemote, err := PrepareRepo(ctx, invalidFileURI, "", false, false)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, isRemote)
|
||||
assert.Equal(t, "", preparedPath)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeFileURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "absolute file URI unchanged",
|
||||
input: "file:///absolute/path",
|
||||
expected: "file:///absolute/path",
|
||||
},
|
||||
{
|
||||
name: "relative file URI with current directory",
|
||||
input: "file://.",
|
||||
expected: "", // Will be set to current working directory
|
||||
},
|
||||
{
|
||||
name: "relative file URI with subdirectory",
|
||||
input: "file://./subdir",
|
||||
expected: "", // Will be set to current working directory + subdir
|
||||
},
|
||||
{
|
||||
name: "relative file URI with parent directory",
|
||||
input: "file://..",
|
||||
expected: "", // Will be set to parent of current working directory
|
||||
},
|
||||
{
|
||||
name: "non-file URI unchanged",
|
||||
input: "https://github.com/user/repo.git",
|
||||
expected: "https://github.com/user/repo.git",
|
||||
},
|
||||
{
|
||||
name: "file URI with host and path",
|
||||
input: "file://hostname/path",
|
||||
expected: "", // Will be set to absolute path of hostname/path
|
||||
},
|
||||
}
|
||||
|
||||
// Get current working directory for expected results
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get current working directory: %v", err)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inputURI, err := GitURLParse(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse input URI: %v", err)
|
||||
}
|
||||
|
||||
result, err := normalizeFileURI(inputURI)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var expected string
|
||||
switch tt.name {
|
||||
case "relative file URI with current directory":
|
||||
expected = "file://" + cwd
|
||||
case "relative file URI with subdirectory":
|
||||
expected = "file://" + filepath.Join(cwd, "subdir")
|
||||
case "relative file URI with parent directory":
|
||||
expected = "file://" + filepath.Dir(cwd)
|
||||
case "file URI with host and path":
|
||||
expectedPath, _ := filepath.Abs(filepath.Join("hostname", "path"))
|
||||
expected = "file://" + expectedPath
|
||||
default:
|
||||
expected = tt.expected
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, result.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRepoWithNormalization(t *testing.T) {
|
||||
repoPath := setupTestRepo(t, "test-repo")
|
||||
addTestFileAndCommit(t, repoPath, "test.txt", "test content")
|
||||
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get current directory: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Chdir(originalDir); err != nil {
|
||||
t.Fatalf("failed to restore original directory: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Test absolute paths first (without changing directory)
|
||||
absoluteTests := []struct {
|
||||
name string
|
||||
uri string
|
||||
trustLocalConfig bool
|
||||
}{
|
||||
{
|
||||
name: "absolute file URI - no trust",
|
||||
uri: "file://" + repoPath,
|
||||
trustLocalConfig: false,
|
||||
},
|
||||
{
|
||||
name: "absolute file URI - with trust",
|
||||
uri: "file://" + repoPath,
|
||||
trustLocalConfig: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range absoluteTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path, _, _ := PrepareRepo(ctx, tt.uri, "", tt.trustLocalConfig, false)
|
||||
|
||||
if !tt.trustLocalConfig {
|
||||
assert.NotEqual(t, repoPath, path, "Cloned repo should not use original path")
|
||||
cmd := exec.Command("git", "-C", path, "status")
|
||||
err := cmd.Run()
|
||||
assert.NoError(t, err, "Cloned repo should be a valid git repository")
|
||||
} else {
|
||||
assert.Equal(t, repoPath, path, "Trusted repo should use original path")
|
||||
}
|
||||
|
||||
if path != repoPath && path != "" {
|
||||
os.RemoveAll(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Now change to temp directory for relative path tests
|
||||
if err := os.Chdir(repoPath); err != nil {
|
||||
t.Fatalf("failed to change to temp directory: %v", err)
|
||||
}
|
||||
|
||||
relativeTests := []struct {
|
||||
name string
|
||||
uri string
|
||||
trustLocalConfig bool
|
||||
}{
|
||||
{
|
||||
name: "relative file URI with current directory - no trust",
|
||||
uri: "file://.",
|
||||
trustLocalConfig: false,
|
||||
},
|
||||
{
|
||||
name: "relative file URI with current directory - with trust",
|
||||
uri: "file://.",
|
||||
trustLocalConfig: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range relativeTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path, _, _ := PrepareRepo(ctx, tt.uri, "", tt.trustLocalConfig, false)
|
||||
|
||||
if !tt.trustLocalConfig {
|
||||
assert.NotEqual(t, tt.uri, "file://"+path, "Cloned repo should not use original path")
|
||||
cmd := exec.Command("git", "-C", path, "status")
|
||||
err := cmd.Run()
|
||||
assert.NoError(t, err, "Cloned repo should be a valid git repository")
|
||||
} else {
|
||||
assert.Equal(t, tt.uri, "file://"+path, "Trusted repo should use original path")
|
||||
}
|
||||
|
||||
if path != repoPath && path != "" {
|
||||
os.RemoveAll(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRepoWithNormalizationBare(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workingRepoPath := setupTestRepo(t, "working-repo-original")
|
||||
addTestFileAndCommit(t, workingRepoPath, "test.txt", "test content")
|
||||
|
||||
tempDir := t.TempDir()
|
||||
bareRepoPath := filepath.Join(tempDir, "bare-repo")
|
||||
assert.NoError(t, exec.Command("git", "clone", workingRepoPath, bareRepoPath, "--bare").Run())
|
||||
|
||||
_, err := os.Stat(filepath.Join(bareRepoPath, "refs"))
|
||||
assert.NoError(t, err, "Bare repository should have refs directory")
|
||||
_, err = os.Stat(filepath.Join(bareRepoPath, "HEAD"))
|
||||
assert.NoError(t, err, "Bare repository should have HEAD file")
|
||||
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get current directory: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Chdir(originalDir); err != nil {
|
||||
t.Fatalf("failed to restore original directory: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Test absolute paths first (without changing directory)
|
||||
absoluteTests := []struct {
|
||||
name string
|
||||
uri string
|
||||
trustLocalConfig bool
|
||||
}{
|
||||
{
|
||||
name: "absolute file URI with bare repo - no trust",
|
||||
uri: "file://" + bareRepoPath,
|
||||
trustLocalConfig: false,
|
||||
},
|
||||
{
|
||||
name: "absolute file URI with bare repo - with trust",
|
||||
uri: "file://" + bareRepoPath,
|
||||
trustLocalConfig: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range absoluteTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path, isRemote, err := PrepareRepo(ctx, tt.uri, "", tt.trustLocalConfig, true)
|
||||
|
||||
assert.NoError(t, err, "PrepareRepo should succeed")
|
||||
assert.False(t, isRemote, "File URI should not be considered remote")
|
||||
|
||||
if tt.trustLocalConfig {
|
||||
assert.Equal(t, bareRepoPath, path, "Trusted bare repo should use original path")
|
||||
|
||||
_, err = os.Stat(filepath.Join(path, "refs"))
|
||||
assert.NoError(t, err, "Original bare repo should have refs directory")
|
||||
_, err = os.Stat(filepath.Join(path, "HEAD"))
|
||||
assert.NoError(t, err, "Original bare repo should have HEAD file")
|
||||
} else {
|
||||
assert.NotEqual(t, bareRepoPath, path, "Sanitized bare repo path should be different from original")
|
||||
|
||||
_, err = os.Stat(filepath.Join(path, "refs"))
|
||||
assert.NoError(t, err, "Cloned bare repo should have refs directory")
|
||||
_, err = os.Stat(filepath.Join(path, "HEAD"))
|
||||
assert.NoError(t, err, "Cloned bare repo should have HEAD file")
|
||||
_, err = os.Stat(filepath.Join(path, ".git"))
|
||||
assert.True(t, os.IsNotExist(err), "Bare repo should not have .git subdirectory")
|
||||
}
|
||||
|
||||
if path != bareRepoPath && path != "" {
|
||||
os.RemoveAll(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Now change to temp directory for relative path tests
|
||||
if err := os.Chdir(tempDir); err != nil {
|
||||
t.Fatalf("failed to change to temp directory: %v", err)
|
||||
}
|
||||
|
||||
relativeTests := []struct {
|
||||
name string
|
||||
uri string
|
||||
trustLocalConfig bool
|
||||
}{
|
||||
{
|
||||
name: "relative file URI with bare repo - no trust",
|
||||
uri: "file://./bare-repo",
|
||||
trustLocalConfig: false,
|
||||
},
|
||||
{
|
||||
name: "relative file URI with bare repo - with trust",
|
||||
uri: "file://./bare-repo",
|
||||
trustLocalConfig: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range relativeTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path, isRemote, err := PrepareRepo(ctx, tt.uri, "", tt.trustLocalConfig, true)
|
||||
|
||||
assert.NoError(t, err, "PrepareRepo should succeed")
|
||||
assert.False(t, isRemote, "File URI should not be considered remote")
|
||||
|
||||
if tt.trustLocalConfig {
|
||||
assert.Equal(t, tt.uri, "file://"+path, "Trusted bare repo should use relative path")
|
||||
|
||||
_, err = os.Stat(filepath.Join(path, "refs"))
|
||||
assert.NoError(t, err, "Original bare repo should have refs directory")
|
||||
_, err = os.Stat(filepath.Join(path, "HEAD"))
|
||||
assert.NoError(t, err, "Original bare repo should have HEAD file")
|
||||
} else {
|
||||
assert.NotEqual(t, tt.uri, "file://"+path, "Sanitized bare repo path should be different from original")
|
||||
|
||||
_, err = os.Stat(filepath.Join(path, "refs"))
|
||||
assert.NoError(t, err, "Cloned bare repo should have refs directory")
|
||||
_, err = os.Stat(filepath.Join(path, "HEAD"))
|
||||
assert.NoError(t, err, "Cloned bare repo should have HEAD file")
|
||||
_, err = os.Stat(filepath.Join(path, ".git"))
|
||||
assert.True(t, os.IsNotExist(err), "Bare repo should not have .git subdirectory")
|
||||
}
|
||||
|
||||
if path != bareRepoPath && path != "" {
|
||||
os.RemoveAll(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,8 @@ type GitConfig struct {
|
||||
NoCleanup bool
|
||||
// PrintLegacyJSON indicates whether to print legacy JSON output format for this source.
|
||||
PrintLegacyJSON bool
|
||||
// TrustLocalGitConfig allows to trust the local git config.
|
||||
TrustLocalGitConfig bool
|
||||
}
|
||||
|
||||
// GithubConfig defines the optional configuration for a github source.
|
||||
|
||||
@@ -226,6 +226,7 @@ message Git {
|
||||
string clone_path = 16;
|
||||
bool no_cleanup = 17;
|
||||
bool print_legacy_json = 18;
|
||||
bool trust_local_git_config = 19; // Trust local git configurations
|
||||
}
|
||||
|
||||
message GitLab {
|
||||
|
||||
Reference in New Issue
Block a user