package git import ( "bytes" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" "github.com/kylelemons/godebug/pretty" "github.com/stretchr/testify/assert" "github.com/trufflesecurity/trufflehog/v3/pkg/feature" "google.golang.org/protobuf/types/known/anypb" "github.com/trufflesecurity/trufflehog/v3/pkg/common" "github.com/trufflesecurity/trufflehog/v3/pkg/context" "github.com/trufflesecurity/trufflehog/v3/pkg/pb/credentialspb" "github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb" "github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb" "github.com/trufflesecurity/trufflehog/v3/pkg/process" "github.com/trufflesecurity/trufflehog/v3/pkg/sources" "github.com/trufflesecurity/trufflehog/v3/pkg/sourcestest" ) func TestClone_Timeout(t *testing.T) { ctx := context.Background() t.Run("an unset timeout should not cause a timeout", func(t *testing.T) { _, _, err := CloneRepo( ctx, nil, "https://github.com/dustin-decker/secretsandstuff.git", "", false) // There shouldn't be an error - but if there is because of some other problem, we don't want this test to fail if err != nil { assert.NotContains(t, err.Error(), "timed out") } }) t.Run("a clone that times out should time out", func(t *testing.T) { feature.GitCloneTimeoutDuration.Store(int64(1 * time.Nanosecond)) t.Cleanup(func() { feature.GitCloneTimeoutDuration.Store(0) }) _, _, err := CloneRepo( ctx, nil, "https://github.com/dustin-decker/secretsandstuff.git", "", false) if assert.Error(t, err) { assert.Contains(t, err.Error(), "timed out") } }) } func TestSource_Scan(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() secret, err := common.GetTestSecret(ctx) if err != nil { t.Fatal(fmt.Errorf("failed to access secret: %v", err)) } basicUser := secret.MustGetField("GITLAB_USER") basicPass := secret.MustGetField("GITLAB_PASS") type init struct { name string verify bool connection *sourcespb.Git concurrency int } tests := []struct { name string init init wantChunk *sources.Chunk wantErr bool }{ { name: "local repo", init: init{ name: "this repo", connection: &sourcespb.Git{ Directories: []string{"../../../"}, Credential: &sourcespb.Git_Unauthenticated{ Unauthenticated: &credentialspb.Unauthenticated{}, }, }, concurrency: 4, }, wantChunk: &sources.Chunk{ SourceType: sourcespb.SourceType_SOURCE_TYPE_GIT, SourceName: "this repo", SourceVerify: false, }, wantErr: false, }, { name: "remote repo, unauthenticated", init: init{ name: "test source", connection: &sourcespb.Git{ Repositories: []string{"https://github.com/dustin-decker/secretsandstuff.git"}, Credential: &sourcespb.Git_Unauthenticated{ Unauthenticated: &credentialspb.Unauthenticated{}, }, }, concurrency: 4, }, wantChunk: &sources.Chunk{ SourceType: sourcespb.SourceType_SOURCE_TYPE_GIT, SourceName: "test source", SourceVerify: false, }, wantErr: false, }, { name: "remote repo, unauthenticated, concurrency 0", init: init{ name: "test source", connection: &sourcespb.Git{ Repositories: []string{"https://github.com/dustin-decker/secretsandstuff.git"}, Credential: &sourcespb.Git_Unauthenticated{ Unauthenticated: &credentialspb.Unauthenticated{}, }, }, concurrency: 0, }, wantChunk: &sources.Chunk{ SourceType: sourcespb.SourceType_SOURCE_TYPE_GIT, SourceName: "test source", SourceVerify: false, }, wantErr: false, }, { name: "remote repo, basic auth", init: init{ name: "test source", connection: &sourcespb.Git{ Repositories: []string{"https://github.com/dustin-decker/secretsandstuff.git"}, Credential: &sourcespb.Git_BasicAuth{ BasicAuth: &credentialspb.BasicAuth{ Username: basicUser, Password: basicPass, }, }, }, concurrency: 4, }, wantChunk: &sources.Chunk{ SourceType: sourcespb.SourceType_SOURCE_TYPE_GIT, SourceName: "test source", SourceVerify: false, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := Source{} conn, err := anypb.New(tt.init.connection) if err != nil { t.Fatal(err) } err = s.Init(ctx, tt.init.name, 0, 0, tt.init.verify, conn, tt.init.concurrency) if (err != nil) != tt.wantErr { t.Errorf("Source.Init() error = %v, wantErr %v", err, tt.wantErr) return } chunksCh := make(chan *sources.Chunk, 1) go func() { assert.NoError(t, s.Chunks(ctx, chunksCh)) }() gotChunk := <-chunksCh gotChunk.Data = nil // Commits don't come in a deterministic order, so remove metadata comparison gotChunk.SourceMetadata = nil if diff := pretty.Compare(gotChunk, tt.wantChunk); diff != "" { t.Errorf("Source.Chunks() %s diff: (-got +want)\n%s", tt.name, diff) t.Errorf("Data: %s", string(gotChunk.Data)) } }) } } // We ran into an issue where upgrading a dependency caused the git patch chunking to break // So this test exists to make sure that when something changes, we know about it. func TestSource_Chunks_Integration(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() type init struct { name string verify bool connection *sourcespb.Git } type byteCompare struct { B []byte Found bool Multi bool } tests := []struct { name string init init // verified repoURL string expectedChunkData map[string]*byteCompare scanOptions ScanOptions }{ { name: "remote repo, unauthenticated", repoURL: "https://github.com/dustin-decker/secretsandstuff.git", expectedChunkData: map[string]*byteCompare{ "70001020fab32b1fcf2f1f0e5c66424eae649826-": {B: []byte("Dustin Decker \nGitHub \nUpdate aws\n")}, "70001020fab32b1fcf2f1f0e5c66424eae649826-aws": {B: []byte("[default]\naws_access_key_id = AKIAXYZDQCEN4B6JSJQI\naws_secret_access_key = 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\noutput = json\nregion = us-east-2\n")}, "a6f8aa55736d4a85be31a0048a4607396898647a-": {B: []byte("Dustin Decker \nGitHub \nUpdate bump\n")}, "a6f8aa55736d4a85be31a0048a4607396898647a-bump": {B: []byte("\n\nf\n")}, "73ab4713057944753f1bdeb80e757380e64c6b5b-": {B: []byte("Dustin \nDustin \nbump\n")}, "73ab4713057944753f1bdeb80e757380e64c6b5b-bump": {B: []byte(" s \n\n")}, "2f251b8c1e72135a375b659951097ec7749d4af9-": {B: []byte("Dustin \nDustin \nbump\n")}, "2f251b8c1e72135a375b659951097ec7749d4af9-bump": {B: []byte(" \n\n")}, "e6c8bbabd8796ea3cd85bfc2e55b27e0a491747f-": {B: []byte("Dustin Decker \nGitHub \nUpdate bump\n")}, "e6c8bbabd8796ea3cd85bfc2e55b27e0a491747f-bump": {B: []byte("\noops \n")}, "735b52b0eb40610002bb1088e902bd61824eb305-": {B: []byte("Dustin Decker \nGitHub \nUpdate bump\n")}, "735b52b0eb40610002bb1088e902bd61824eb305-bump": {B: []byte("\noops\n")}, "ce62d79908803153ef6e145e042d3e80488ef747-": {B: []byte("Dustin Decker \nGitHub \nCreate bump\n")}, "ce62d79908803153ef6e145e042d3e80488ef747-bump": {B: []byte("\n")}, // Normally we might expect to see this commit, and we may in the future. // But at the moment we're ignoring any commit unless it contains at least one non-space character. "27fbead3bf883cdb7de9d7825ed401f28f9398f1-": {B: []byte("Dustin \nDustin \noops\n")}, "27fbead3bf883cdb7de9d7825ed401f28f9398f1-slack": {B: []byte("\n\n\nyup, just did that\n\ngithub_lol: \"ffc7e0f9400fb6300167009e42d2f842cd7956e2\"\n\noh, goodness. there's another one!\n")}, "8afb0ecd4998b1179e428db5ebbcdc8221214432-": {B: []byte("Dustin \nDustin \nadd slack token\n")}, "8afb0ecd4998b1179e428db5ebbcdc8221214432-slack": {B: []byte("oops might drop a slack token here\n\ngithub_secret=\"369963c1434c377428ca8531fbc46c0c43d037a0\"\n\nyup, just did that\n"), Multi: true}, "8fe6f04ef1839e3fc54b5147e3d0e0b7ab971bd5-": {B: []byte("Dustin \nDustin \noops, accidently commited AWS token...\n")}, //nolint:misspell "8fe6f04ef1839e3fc54b5147e3d0e0b7ab971bd5-aws": {B: []byte("blah blaj\n\nthis is the secret: AKIA2E0A8F3B244C9986\n\nokay thank you bye\n"), Multi: true}, "84e9c75e388ae3e866e121087ea2dd45a71068f2-": {B: []byte("Dylan Ayrey \nGitHub \nUpdate aws\n")}, "84e9c75e388ae3e866e121087ea2dd45a71068f2-aws": {B: []byte("\n\nthis is the secret: [Default]\nAccess key Id: AKIAILE3JG6KMS3HZGCA\nSecret Access Key: 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\n\nokay thank you bye\n"), Multi: false}, }, }, { name: "remote repo, limited", repoURL: "https://github.com/dustin-decker/secretsandstuff.git", expectedChunkData: map[string]*byteCompare{ "70001020fab32b1fcf2f1f0e5c66424eae649826-": {B: []byte("Dustin Decker \nGitHub \nUpdate aws\n")}, "70001020fab32b1fcf2f1f0e5c66424eae649826-aws": {B: []byte("[default]\naws_access_key_id = AKIAXYZDQCEN4B6JSJQI\naws_secret_access_key = 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\noutput = json\nregion = us-east-2\n")}, }, scanOptions: ScanOptions{ HeadHash: "70001020fab32b1fcf2f1f0e5c66424eae649826", BaseHash: "a6f8aa55736d4a85be31a0048a4607396898647a", }, }, { name: "remote repo, main ahead of branch", repoURL: "https://github.com/bill-rich/bad-secrets.git", expectedChunkData: map[string]*byteCompare{ "547865c6cc0da46622306902b1b66f7e25dd0412-": {B: []byte("bill-rich \nbill-rich \nAdd some_branch_file\n")}, "547865c6cc0da46622306902b1b66f7e25dd0412-some_branch_file": {B: []byte("[default]\naws_access_key=AKIAYVP4CIPPH5TNP3SW\naws_secret_access_key=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\nregion=us-east-2\noutput=json\n\n#addibng a comment\n")}, }, scanOptions: ScanOptions{ HeadHash: "some_branch", BaseHash: "master", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := Source{} beforeProcesses := process.GetGitProcessList() conn, err := anypb.New(tt.init.connection) if err != nil { t.Fatal(err) } err = s.Init(ctx, tt.init.name, 0, 0, tt.init.verify, conn, 4) if err != nil { t.Fatal(err) } chunksCh := make(chan *sources.Chunk, 1) go func() { defer close(chunksCh) repoPath, repo, err := CloneRepoUsingUnauthenticated(ctx, tt.repoURL, "") if err != nil { panic(err) } err = s.git.ScanRepo(ctx, repo, repoPath, &tt.scanOptions, sources.ChanReporter{Ch: chunksCh}) if err != nil { panic(err) } }() for chunk := range chunksCh { key := "" switch meta := chunk.SourceMetadata.GetData().(type) { case *source_metadatapb.MetaData_Git: key = strings.TrimRight(meta.Git.Commit+"-"+meta.Git.File, "\n") } if expectedData, exists := tt.expectedChunkData[key]; !exists { t.Errorf("A chunk exists that was not expected with key %q", key) } else { if bytes.Equal(chunk.Data, expectedData.B) { (*tt.expectedChunkData[key]).Found = true } else if !expectedData.Multi { t.Errorf("Got %q: %q, which was not expected", key, string(chunk.Data)) } } } for key, expected := range tt.expectedChunkData { if !expected.Found { t.Errorf("Expected data with key %q not found", key) } } afterProcesses := process.GetGitProcessList() zombies := process.DetectGitZombies(beforeProcesses, afterProcesses) if len(zombies) > 0 { t.Errorf("Git zombies detected: %v", zombies) } }) } } func TestSource_Chunks_Edge_Cases(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() secret, err := common.GetTestSecret(ctx) if err != nil { t.Fatal(fmt.Errorf("failed to access secret: %v", err)) } basicUser := secret.MustGetField("GITLAB_USER") basicPass := secret.MustGetField("GITLAB_PASS") type init struct { name string verify bool connection *sourcespb.Git } tests := []struct { name string init init wantErr string }{ { name: "empty repo", init: init{ name: "test source", connection: &sourcespb.Git{ Repositories: []string{"https://github.com/git-fixtures/empty.git"}, Credential: &sourcespb.Git_Unauthenticated{ Unauthenticated: &credentialspb.Unauthenticated{}, }, }, }, wantErr: "remote", }, { name: "no repo", init: init{ name: "test source", connection: &sourcespb.Git{ Repositories: []string{""}, Credential: &sourcespb.Git_Unauthenticated{ Unauthenticated: &credentialspb.Unauthenticated{}, }, }, }, wantErr: "remote", }, { name: "no repo, basic auth", init: init{ name: "test source", connection: &sourcespb.Git{ Repositories: []string{""}, Credential: &sourcespb.Git_BasicAuth{ BasicAuth: &credentialspb.BasicAuth{ Username: basicUser, Password: basicPass, }, }, }, }, wantErr: "remote", }, { name: "symlinks repo", init: init{ name: "test source", connection: &sourcespb.Git{ Repositories: []string{"https://github.com/git-fixtures/symlinks.git"}, Credential: &sourcespb.Git_Unauthenticated{ Unauthenticated: &credentialspb.Unauthenticated{}, }, }, }, }, { name: "submodule repo", init: init{ name: "test source", connection: &sourcespb.Git{ Repositories: []string{"https://github.com/git-fixtures/submodule.git"}, Credential: &sourcespb.Git_Unauthenticated{ Unauthenticated: &credentialspb.Unauthenticated{}, }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := Source{} conn, err := anypb.New(tt.init.connection) if err != nil { t.Fatal(err) } err = s.Init(ctx, tt.init.name, 0, 0, tt.init.verify, conn, 4) if err != nil { t.Errorf("Source.Init() error = %v", err) return } chunksCh := make(chan *sources.Chunk, 1) go func() { for chunk := range chunksCh { chunk.Data = nil } }() if err := s.Chunks(ctx, chunksCh); err != nil && !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("Source.Chunks() error = %v, wantErr %v", err, tt.wantErr) } }) } } func TestPrepareRepo(t *testing.T) { tests := []struct { uri string path bool remote bool err error }{ { uri: "https://github.com/dustin-decker/secretsandstuff.git", path: true, remote: true, err: nil, }, { uri: "http://github.com/dustin-decker/secretsandstuff.git", path: true, remote: true, err: nil, }, { uri: "file:///path/to/file.json", 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", path: false, remote: false, err: fmt.Errorf("unsupported Git URI: no bueno"), }, } for _, tt := range tests { ctx := context.Background() repo, b, err := PrepareRepo(ctx, tt.uri, "", true, false) var repoLen bool if len(repo) > 0 { repoLen = true } else { repoLen = false } if repoLen != tt.path || b != tt.remote { t.Errorf("PrepareRepo(%v) got: %v, %v, %v want: %v, %v, %v", tt.uri, repo, b, err, tt.path, tt.remote, tt.err) } } } 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, "", false, false) } } func TestGitURLParse(t *testing.T) { for _, tt := range []struct { url string host string user string password string port string path string scheme string }{ { "https://user@github.com/org/repo", "github.com", "user", "", "", "/org/repo", "https", }, { "https://user:pass@github.com/org/repo", "github.com", "user", "pass", "", "/org/repo", "https", }, { "ssh://user@github.com/org/repo", "github.com", "user", "", "", "/org/repo", "ssh", }, { "user@github.com:org/repo", "github.com", "user", "", "", "/org/repo", "ssh", }, } { u, err := GitURLParse(tt.url) if err != nil { t.Fatal(err) } assert.Equal(t, tt.host, u.Host) assert.Equal(t, tt.user, u.User.Username()) password, _ := u.User.Password() assert.Equal(t, tt.password, password) assert.Equal(t, tt.port, u.Port()) assert.Equal(t, tt.path, u.Path) assert.Equal(t, tt.scheme, u.Scheme) } } func TestEnumerate(t *testing.T) { t.Parallel() ctx := context.Background() // Setup the connection to test enumeration. units := []string{ "foo", "bar", "baz", "/path/to/dir/", "/path/to/another/dir/", } conn, err := anypb.New(&sourcespb.Git{ Repositories: units[0:3], Directories: units[3:], }) assert.NoError(t, err) // Initialize the source. s := Source{} err = s.Init(ctx, "test enumerate", 0, 0, true, conn, 1) assert.NoError(t, err) reporter := sourcestest.TestReporter{} err = s.Enumerate(ctx, &reporter) assert.NoError(t, err) assert.Equal(t, len(units), len(reporter.Units)) assert.Equal(t, 0, len(reporter.UnitErrs)) for _, unit := range reporter.Units { id, _ := unit.SourceUnitID() assert.Contains(t, units, id) } for _, unit := range units[:3] { assert.Contains(t, reporter.Units, SourceUnit{ID: unit, Kind: UnitRepo}) } for _, unit := range units[3:] { assert.Contains(t, reporter.Units, SourceUnit{ID: unit, Kind: UnitDir}) } } func TestChunkUnit(t *testing.T) { t.Skip("flaky - INS-212") t.Parallel() ctx := context.Background() // Initialize the source. s := Source{} conn, err := anypb.New(&sourcespb.Git{ Credential: &sourcespb.Git_Unauthenticated{}, }) assert.NoError(t, err) err = s.Init(ctx, "test chunk", 0, 0, true, conn, 1) assert.NoError(t, err) reporter := sourcestest.TestReporter{} // Happy path single repository. err = s.ChunkUnit(ctx, SourceUnit{ ID: "https://github.com/dustin-decker/secretsandstuff.git", Kind: UnitRepo, }, &reporter) assert.NoError(t, err) // Error path - should return fatal error for missing directory. err = s.ChunkUnit(ctx, SourceUnit{ ID: "/file/not/found", Kind: UnitDir, }, &reporter) assert.Error(t, err) assert.Contains(t, err.Error(), "directory does not exist") 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", "test@example.com").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: "", }, { 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 "absolute file URI unchanged": // On Windows, absolute paths get drive letter prepended // On Unix, they remain as-is if runtime.GOOS == "windows" { expectedPath, _ := filepath.Abs("/absolute/path") expected = "file://" + expectedPath } else { expected = "file:///absolute/path" } 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 } // Normalize slashes for Windows comparison if runtime.GOOS == "windows" { expected = filepath.ToSlash(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) } }) } }