Files
trufflehog/pkg/sources/git/git_test.go
joeleonjr bc2cd3e45e
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
Local Git Config Sanitization (#4502)
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.
2025-10-15 14:50:44 -04:00

1115 lines
35 KiB
Go

package git
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/kylelemons/godebug/pretty"
"github.com/stretchr/testify/assert"
"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 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",
Verify: 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",
Verify: 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",
Verify: 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",
Verify: 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 <[email protected]>\nGitHub <[email protected]>\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 <[email protected]>\nGitHub <[email protected]>\nUpdate bump\n")},
"a6f8aa55736d4a85be31a0048a4607396898647a-bump": {B: []byte("\n\nf\n")},
"73ab4713057944753f1bdeb80e757380e64c6b5b-": {B: []byte("Dustin <[email protected]>\nDustin <[email protected]>\nbump\n")},
"73ab4713057944753f1bdeb80e757380e64c6b5b-bump": {B: []byte(" s \n\n")},
"2f251b8c1e72135a375b659951097ec7749d4af9-": {B: []byte("Dustin <[email protected]>\nDustin <[email protected]>\nbump\n")},
"2f251b8c1e72135a375b659951097ec7749d4af9-bump": {B: []byte(" \n\n")},
"e6c8bbabd8796ea3cd85bfc2e55b27e0a491747f-": {B: []byte("Dustin Decker <[email protected]>\nGitHub <[email protected]>\nUpdate bump\n")},
"e6c8bbabd8796ea3cd85bfc2e55b27e0a491747f-bump": {B: []byte("\noops \n")},
"735b52b0eb40610002bb1088e902bd61824eb305-": {B: []byte("Dustin Decker <[email protected]>\nGitHub <[email protected]>\nUpdate bump\n")},
"735b52b0eb40610002bb1088e902bd61824eb305-bump": {B: []byte("\noops\n")},
"ce62d79908803153ef6e145e042d3e80488ef747-": {B: []byte("Dustin Decker <[email protected]>\nGitHub <[email protected]>\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 <[email protected]>\nDustin <[email protected]>\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 <[email protected]>\nDustin <[email protected]>\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 <[email protected]>\nDustin <[email protected]>\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 <[email protected]>\nGitHub <[email protected]>\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 <[email protected]>\nGitHub <[email protected]>\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 <[email protected]>\nbill-rich <[email protected]>\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://[email protected]/org/repo",
"github.com",
"user",
"",
"",
"/org/repo",
"https",
},
{
"https://user:[email protected]/org/repo",
"github.com",
"user",
"pass",
"",
"/org/repo",
"https",
},
{
"ssh://[email protected]/org/repo",
"github.com",
"user",
"",
"",
"/org/repo",
"ssh",
},
{
"[email protected]: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.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", "[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)
}
})
}
}