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

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:
joeleonjr
2025-10-15 14:50:44 -04:00
committed by GitHub
parent e88e7d019e
commit bc2cd3e45e
10 changed files with 1333 additions and 677 deletions
+2
View File
@@ -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
+27 -49
View File
@@ -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
View File
@@ -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
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
})
}
}
+2
View File
@@ -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.
+1
View File
@@ -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 {