Files
wehub-resource-sync 48b3ccf279
Test / test (windows-latest) (push) Waiting to run
gitleaks / gitleaks (push) Has been skipped
Test / test (ubuntu-latest) (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:12:29 +08:00

531 lines
14 KiB
Go

package sources
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/url"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/fatih/semgroup"
"github.com/gitleaks/go-gitdiff/gitdiff"
"github.com/zricethezav/gitleaks/v8/cmd/scm"
"github.com/zricethezav/gitleaks/v8/config"
"github.com/zricethezav/gitleaks/v8/logging"
)
var quotedOptPattern = regexp.MustCompile(`^(?:"[^"]+"|'[^']+')$`)
// GitCmd helps to work with Git's output.
type GitCmd struct {
cmd *exec.Cmd
diffFilesCh <-chan *gitdiff.File
errCh <-chan error
repoPath string
}
// blobReader provides a ReadCloser interface git cat-file blob to fetch
// a blob from a repo
type blobReader struct {
io.ReadCloser
cmd *exec.Cmd
}
// Close closes the underlying reader and then waits for the command to complete,
// releasing its resources.
func (br *blobReader) Close() error {
// Discard the remaining data from the pipe to avoid blocking
_, drainErr := io.Copy(io.Discard, br)
// Close the pipe (should signal the command to stop if it hasn't already)
closeErr := br.ReadCloser.Close()
// Wait to prevent zombie processes.
waitErr := br.cmd.Wait()
// Return the first error encountered
if drainErr != nil {
return drainErr
}
if closeErr != nil {
return closeErr
}
return waitErr
}
// NewGitLogCmd returns `*DiffFilesCmd` with two channels: `<-chan *gitdiff.File` and `<-chan error`.
// Caller should read everything from channels until receiving a signal about their closure and call
// the `func (*DiffFilesCmd) Wait()` error in order to release resources.
func NewGitLogCmd(source string, logOpts string) (*GitCmd, error) {
return NewGitLogCmdContext(context.Background(), source, logOpts)
}
// NewGitLogCmdContext is the same as NewGitLogCmd but supports passing in a
// context to use for timeouts
func NewGitLogCmdContext(ctx context.Context, source string, logOpts string) (*GitCmd, error) {
sourceClean := filepath.Clean(source)
var cmd *exec.Cmd
if logOpts != "" {
args := []string{"-C", sourceClean, "log", "-p", "-U0"}
// Ensure that the user-provided |logOpts| aren't wrapped in quotes.
// https://github.com/gitleaks/gitleaks/issues/1153
userArgs := strings.Split(logOpts, " ")
var quotedOpts []string
for _, element := range userArgs {
if quotedOptPattern.MatchString(element) {
quotedOpts = append(quotedOpts, element)
}
}
if len(quotedOpts) > 0 {
logging.Warn().Msgf("the following `--log-opts` values may not work as expected: %v\n\tsee https://github.com/gitleaks/gitleaks/issues/1153 for more information", quotedOpts)
}
args = append(args, userArgs...)
cmd = exec.CommandContext(ctx, "git", args...)
} else {
cmd = exec.CommandContext(ctx, "git", "-C", sourceClean, "log", "-p", "-U0",
"--full-history", "--all", "--diff-filter=tuxdb")
}
logging.Debug().Msgf("executing: %s", cmd.String())
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
errCh := make(chan error)
go listenForStdErr(stderr, errCh)
gitdiffFiles, err := gitdiff.Parse(stdout)
if err != nil {
return nil, err
}
return &GitCmd{
cmd: cmd,
diffFilesCh: gitdiffFiles,
errCh: errCh,
repoPath: sourceClean,
}, nil
}
// NewGitDiffCmd returns `*DiffFilesCmd` with two channels: `<-chan *gitdiff.File` and `<-chan error`.
// Caller should read everything from channels until receiving a signal about their closure and call
// the `func (*DiffFilesCmd) Wait()` error in order to release resources.
func NewGitDiffCmd(source string, staged bool) (*GitCmd, error) {
return NewGitDiffCmdContext(context.Background(), source, staged)
}
// NewGitDiffCmdContext is the same as NewGitDiffCmd but supports passing in a
// context to use for timeouts
func NewGitDiffCmdContext(ctx context.Context, source string, staged bool) (*GitCmd, error) {
sourceClean := filepath.Clean(source)
var cmd *exec.Cmd
cmd = exec.CommandContext(ctx, "git", "-C", sourceClean, "diff", "-U0", "--no-ext-diff", ".")
if staged {
cmd = exec.CommandContext(ctx, "git", "-C", sourceClean, "diff", "-U0", "--no-ext-diff",
"--staged", ".")
}
logging.Debug().Msgf("executing: %s", cmd.String())
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
errCh := make(chan error)
go listenForStdErr(stderr, errCh)
gitdiffFiles, err := gitdiff.Parse(stdout)
if err != nil {
return nil, err
}
return &GitCmd{
cmd: cmd,
diffFilesCh: gitdiffFiles,
errCh: errCh,
repoPath: sourceClean,
}, nil
}
// DiffFilesCh returns a channel with *gitdiff.File.
func (c *GitCmd) DiffFilesCh() <-chan *gitdiff.File {
return c.diffFilesCh
}
// ErrCh returns a channel that could produce an error if there is something in stderr.
func (c *GitCmd) ErrCh() <-chan error {
return c.errCh
}
// Wait waits for the command to exit and waits for any copying to
// stdin or copying from stdout or stderr to complete.
//
// Wait also closes underlying stdout and stderr.
func (c *GitCmd) Wait() error {
return c.cmd.Wait()
}
// String displays the command used for GitCmd
func (c *GitCmd) String() string {
return c.cmd.String()
}
// NewBlobReader returns an io.ReadCloser that can be used to read a blob
// within the git repo used to create the GitCmd.
//
// The caller is responsible for closing the reader.
func (c *GitCmd) NewBlobReader(commit, path string) (io.ReadCloser, error) {
return c.NewBlobReaderContext(context.Background(), commit, path)
}
// NewBlobReaderContext is the same as NewBlobReader but supports passing in a
// context to use for timeouts
func (c *GitCmd) NewBlobReaderContext(ctx context.Context, commit, path string) (io.ReadCloser, error) {
gitArgs := []string{"-C", c.repoPath, "cat-file", "blob", commit + ":" + path}
cmd := exec.CommandContext(ctx, "git", gitArgs...)
cmd.Stderr = io.Discard
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start git command: %w", err)
}
return &blobReader{
ReadCloser: stdout,
cmd: cmd,
}, nil
}
// listenForStdErr listens for stderr output from git, prints it to stdout,
// sends to errCh and closes it.
func listenForStdErr(stderr io.ReadCloser, errCh chan<- error) {
defer close(errCh)
var errEncountered bool
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
// if git throws one of the following errors:
//
// exhaustive rename detection was skipped due to too many files.
// you may want to set your diff.renameLimit variable to at least
// (some large number) and retry the command.
//
// inexact rename detection was skipped due to too many files.
// you may want to set your diff.renameLimit variable to at least
// (some large number) and retry the command.
//
// Auto packing the repository in background for optimum performance.
// See "git help gc" for manual housekeeping.
//
// we skip exiting the program as git log -p/git diff will continue
// to send data to stdout and finish executing. This next bit of
// code prevents gitleaks from stopping mid scan if this error is
// encountered
if strings.Contains(scanner.Text(),
"exhaustive rename detection was skipped") ||
strings.Contains(scanner.Text(),
"inexact rename detection was skipped") ||
strings.Contains(scanner.Text(),
"you may want to set your diff.renameLimit") ||
strings.Contains(scanner.Text(),
"See \"git help gc\" for manual housekeeping") ||
strings.Contains(scanner.Text(),
"Auto packing the repository in background for optimum performance") {
logging.Warn().Msg(scanner.Text())
} else {
logging.Error().Msgf("[git] %s", scanner.Text())
errEncountered = true
}
}
if errEncountered {
errCh <- errors.New("stderr is not empty")
return
}
}
// RemoteInfo provides the info needed for reconstructing links from findings
type RemoteInfo struct {
Platform scm.Platform
Url string
}
// Git is a source for yielding fragments from a git repo
type Git struct {
Cmd *GitCmd
Config *config.Config
Remote *RemoteInfo
Sema *semgroup.Group
MaxArchiveDepth int
}
// CommitInfo captures metadata about the commit
type CommitInfo struct {
AuthorEmail string
AuthorName string
Date string
Message string
Remote *RemoteInfo
SHA string
}
// Fragments yields fragments from a git repo
func (s *Git) Fragments(ctx context.Context, yield FragmentsFunc) error {
defer func() {
if err := s.Cmd.Wait(); err != nil {
logging.Debug().Err(err).Str("cmd", s.Cmd.String()).Msg("command aborted")
}
}()
var (
diffFilesCh = s.Cmd.DiffFilesCh()
errCh = s.Cmd.ErrCh()
wg sync.WaitGroup
)
// loop to range over both DiffFiles (stdout) and ErrCh (stderr)
for diffFilesCh != nil || errCh != nil {
select {
case <-ctx.Done():
return ctx.Err()
case gitdiffFile, open := <-diffFilesCh:
if !open {
diffFilesCh = nil
break
}
if gitdiffFile.IsDelete {
continue
}
// skip non-archive binary files
yieldAsArchive := false
if gitdiffFile.IsBinary {
if !isArchive(ctx, gitdiffFile.NewName) {
continue
}
yieldAsArchive = true
}
// Check if commit is allowed
commitSHA := ""
var commitInfo *CommitInfo
if gitdiffFile.PatchHeader != nil {
commitSHA = gitdiffFile.PatchHeader.SHA
for _, a := range s.Config.Allowlists {
if ok, c := a.CommitAllowed(gitdiffFile.PatchHeader.SHA); ok {
logging.Trace().Str("allowed-commit", c).Msg("skipping commit: global allowlist")
continue
}
}
commitInfo = &CommitInfo{
Date: gitdiffFile.PatchHeader.AuthorDate.UTC().Format(time.RFC3339),
Message: gitdiffFile.PatchHeader.Message(),
Remote: s.Remote,
SHA: commitSHA,
}
if gitdiffFile.PatchHeader.Author != nil {
commitInfo.AuthorName = gitdiffFile.PatchHeader.Author.Name
commitInfo.AuthorEmail = gitdiffFile.PatchHeader.Author.Email
}
}
wg.Add(1)
s.Sema.Go(func() error {
defer wg.Done()
if yieldAsArchive {
blob, err := s.Cmd.NewBlobReaderContext(ctx, commitSHA, gitdiffFile.NewName)
if err != nil {
logging.Error().Err(err).Msg("could not read archive blob")
return nil
}
file := File{
Content: blob,
Path: gitdiffFile.NewName,
MaxArchiveDepth: s.MaxArchiveDepth,
Config: s.Config,
}
// enrich and yield fragments
err = file.Fragments(ctx, func(fragment Fragment, err error) error {
fragment.CommitSHA = commitSHA
fragment.CommitInfo = commitInfo
return yield(fragment, err)
})
// Close the blob reader and log any issues
if err := blob.Close(); err != nil {
logging.Debug().Err(err).Msg("blobReader.Close() returned an error")
}
return err
}
for _, textFragment := range gitdiffFile.TextFragments {
if textFragment == nil {
return nil
}
fragment := Fragment{
CommitSHA: commitSHA,
FilePath: gitdiffFile.NewName,
Raw: textFragment.Raw(gitdiff.OpAdd),
StartLine: int(textFragment.NewPosition),
CommitInfo: commitInfo,
}
if err := yield(fragment, nil); err != nil {
return err
}
}
return nil
})
case err, open := <-errCh:
if !open {
errCh = nil
break
}
return yield(Fragment{}, err)
}
}
select {
case <-ctx.Done():
return ctx.Err()
default:
wg.Wait()
return nil
}
}
// NewRemoteInfo builds a new RemoteInfo for generating finding links
func NewRemoteInfo(platform scm.Platform, source string) *RemoteInfo {
return NewRemoteInfoContext(context.Background(), platform, source)
}
// NewRemoteInfoContext is the same as NewRemoteInfo but supports passing in a
// context to use for timeouts
func NewRemoteInfoContext(ctx context.Context, platform scm.Platform, source string) *RemoteInfo {
if platform == scm.NoPlatform {
return &RemoteInfo{Platform: platform}
}
remoteUrl, err := getRemoteUrl(ctx, source)
if err != nil {
if strings.Contains(err.Error(), "No remote configured") {
logging.Debug().Msg("skipping finding links: repository has no configured remote.")
platform = scm.NoPlatform
} else {
logging.Error().Err(err).Msg("skipping finding links: unable to parse remote URL")
}
goto End
}
if platform == scm.UnknownPlatform {
platform = platformFromHost(remoteUrl)
if platform == scm.UnknownPlatform {
logging.Info().
Str("host", remoteUrl.Hostname()).
Msg("Unknown SCM platform. Use --platform to include links in findings.")
} else {
logging.Debug().
Str("host", remoteUrl.Hostname()).
Str("platform", platform.String()).
Msg("SCM platform parsed from host")
}
}
End:
var rUrl string
if remoteUrl != nil {
rUrl = remoteUrl.String()
}
return &RemoteInfo{
Platform: platform,
Url: rUrl,
}
}
var sshUrlpat = regexp.MustCompile(`^git@([a-zA-Z0-9.-]+):(?:\d{1,5}/)?([\w/.-]+?)(?:\.git)?$`)
func getRemoteUrl(ctx context.Context, source string) (*url.URL, error) {
// This will return the first remote — typically, "origin".
cmd := exec.CommandContext(ctx, "git", "ls-remote", "--quiet", "--get-url")
if source != "." {
cmd.Dir = source
}
stdout, err := cmd.Output()
if err != nil {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return nil, fmt.Errorf("command failed (%d): %w, stderr: %s", exitError.ExitCode(), err, string(bytes.TrimSpace(exitError.Stderr)))
}
return nil, err
}
remoteUrl := string(bytes.TrimSpace(stdout))
if matches := sshUrlpat.FindStringSubmatch(remoteUrl); matches != nil {
remoteUrl = fmt.Sprintf("https://%s/%s", matches[1], matches[2])
}
remoteUrl = strings.TrimSuffix(remoteUrl, ".git")
parsedUrl, err := url.Parse(remoteUrl)
if err != nil {
return nil, fmt.Errorf("unable to parse remote URL: %w", err)
}
// Remove any user info.
parsedUrl.User = nil
return parsedUrl, nil
}
func platformFromHost(u *url.URL) scm.Platform {
switch strings.ToLower(u.Hostname()) {
case "github.com":
return scm.GitHubPlatform
case "gitlab.com":
return scm.GitLabPlatform
case "dev.azure.com", "visualstudio.com":
return scm.AzureDevOpsPlatform
case "gitea.com", "code.forgejo.org", "codeberg.org":
return scm.GiteaPlatform
case "bitbucket.org":
return scm.BitbucketPlatform
default:
return scm.UnknownPlatform
}
}