Files
trufflehog/pkg/engine/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

241 lines
6.9 KiB
Go

package engine
import (
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
"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"
)
type expResult struct {
B string
LineNumber int64
Verified bool
}
type discardPrinter struct{}
func (p *discardPrinter) Print(context.Context, *detectors.ResultWithMetadata) error {
// This method intentionally does nothing.
return nil
}
func TestGitEngine(t *testing.T) {
ctx := context.Background()
repoUrl := "https://github.com/dustin-decker/secretsandstuff.git"
path, _, err := git.PrepareRepo(ctx, repoUrl, "", false, false)
if err != nil {
t.Error(err)
}
defer os.RemoveAll(path)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
type testProfile struct {
expected map[string]expResult
branch string
base string
maxDepth int
}
for tName, tTest := range map[string]testProfile{
"all_secrets": {
expected: map[string]expResult{
"70001020fab32b1fcf2f1f0e5c66424eae649826": {"AKIAXYZDQCEN4B6JSJQI", 2, true},
"84e9c75e388ae3e866e121087ea2dd45a71068f2": {"AKIAILE3JG6KMS3HZGCA", 4, true},
"8afb0ecd4998b1179e428db5ebbcdc8221214432": {"369963c1434c377428ca8531fbc46c0c43d037a0", 3, false},
"27fbead3bf883cdb7de9d7825ed401f28f9398f1": {"ffc7e0f9400fb6300167009e42d2f842cd7956e2", 7, false},
},
},
"base_commit": {
expected: map[string]expResult{
"70001020fab32b1fcf2f1f0e5c66424eae649826": {"AKIAXYZDQCEN4B6JSJQI", 2, true},
},
base: "2f251b8c1e72135a375b659951097ec7749d4af9",
},
} {
t.Run(tName, func(t *testing.T) {
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: true,
SourceManager: sourceManager,
Dispatcher: NewPrinterDispatcher(new(discardPrinter)),
}
e, err := NewEngine(ctx, &conf)
assert.NoError(t, err)
e.Start(ctx)
cfg := sources.GitConfig{
URI: path,
HeadRef: tTest.branch,
BaseRef: tTest.base,
MaxDepth: tTest.maxDepth,
}
if _, err := e.ScanGit(ctx, cfg); err != nil {
return
}
// Wait for all the chunks to be processed.
assert.Nil(t, e.Finish(ctx))
for result := range e.ResultsChan() {
switch meta := result.SourceMetadata.GetData().(type) {
case *source_metadatapb.MetaData_Git:
if tTest.expected[meta.Git.Commit].B != string(result.Raw) {
t.Errorf("%s: unexpected result. Got: %s, Expected: %s", tName, string(result.Raw), tTest.expected[meta.Git.Commit].B)
}
if tTest.expected[meta.Git.Commit].LineNumber != result.SourceMetadata.GetGit().Line {
t.Errorf("%s: unexpected line number. Got: %d, Expected: %d", tName, result.SourceMetadata.GetGit().Line, tTest.expected[meta.Git.Commit].LineNumber)
}
if tTest.expected[meta.Git.Commit].Verified != result.Verified {
t.Errorf("%s: unexpected verification. Got: %v, Expected: %v", tName, result.Verified, tTest.expected[meta.Git.Commit].Verified)
}
}
}
metrics := e.GetMetrics()
assert.Equal(t, len(tTest.expected), int(metrics.VerifiedSecretsFound)+int(metrics.UnverifiedSecretsFound))
})
}
}
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"
ctx, cancel := context.WithCancel(ctx)
defer cancel()
const defaultOutputBufferSize = 64
opts := []func(*sources.SourceManager){
sources.WithSourceUnits(),
sources.WithBufferedOutput(defaultOutputBufferSize),
}
sourceManager := sources.NewManager(opts...)
conf := Config{
Concurrency: runtime.NumCPU(),
Decoders: decoders.DefaultDecoders(),
Detectors: defaults.DefaultDetectors(),
Verify: false,
SourceManager: sourceManager,
Dispatcher: NewPrinterDispatcher(new(discardPrinter)),
}
e, err := NewEngine(ctx, &conf)
assert.NoError(b, err)
go func() {
resultCount := 0
for range e.ResultsChan() {
resultCount++
}
}()
cfg := sources.GitConfig{URI: repoUrl}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := e.ScanGit(ctx, cfg); err != nil {
return
}
}
assert.Nil(b, e.Finish(ctx))
}