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

265 lines
7.1 KiB
Go

package sources
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/h2non/filetype"
"github.com/mholt/archives"
"github.com/rs/zerolog"
"github.com/zricethezav/gitleaks/v8/config"
"github.com/zricethezav/gitleaks/v8/logging"
)
const defaultBufferSize = 100 * 1_000 // 100kb
const InnerPathSeparator = "!"
type seekReaderAt interface {
io.ReaderAt
io.Seeker
}
// File is a source for yielding fragments from a file or other reader
type File struct {
// Content provides a reader to the file's content
Content io.Reader
// Path is the resolved real path of the file
Path string
// Symlink represents a symlink to the file if that's how it was discovered
Symlink string
// Buffer is used for reading the content in chunks
Buffer []byte
// Config is the gitleaks config used for shouldSkipPath. If not set, then
// shouldSkipPath is ignored
Config *config.Config
// outerPaths is the list of container paths (e.g. archives) that lead to
// this file
outerPaths []string
// MaxArchiveDepth limits how deep the sources will explore nested archives
MaxArchiveDepth int
// archiveDepth is the current archive nesting depth
archiveDepth int
}
// Fragments yields fragments for the this source
func (s *File) Fragments(ctx context.Context, yield FragmentsFunc) error {
format, _, err := archives.Identify(ctx, s.Path, nil)
// Process the file as an archive if there's no error && Identify returns
// a format; but if there's an error or no format, just swallow the error
// and fall back on treating it like a normal file and let fileFragments
// decide what to do with it.
if err == nil && format != nil {
if s.archiveDepth+1 > s.MaxArchiveDepth {
var event *zerolog.Event
// Warn if the feature is enabled; else emit a trace log.
if s.MaxArchiveDepth != 0 {
event = logging.Warn()
} else {
event = logging.Trace()
}
event.Str(
"path", s.FullPath(),
).Int(
"max_archive_depth", s.MaxArchiveDepth,
).Msg("skipping archive: exceeds max archive depth")
return nil
}
if extractor, ok := format.(archives.Extractor); ok {
return s.extractorFragments(ctx, extractor, s.Content, yield)
}
if decompressor, ok := format.(archives.Decompressor); ok {
return s.decompressorFragments(ctx, decompressor, s.Content, yield)
}
logging.Warn().Str("path", s.FullPath()).Msg("skipping unknown archive type")
}
return s.fileFragments(ctx, bufio.NewReader(s.Content), yield)
}
// extractorFragments recursively crawls archives and yields fragments
func (s *File) extractorFragments(ctx context.Context, extractor archives.Extractor, reader io.Reader, yield FragmentsFunc) error {
if _, isSeekReaderAt := reader.(seekReaderAt); !isSeekReaderAt {
switch extractor.(type) {
case archives.SevenZip, archives.Zip:
tmpfile, err := os.CreateTemp("", "gitleaks-archive-")
if err != nil {
logging.Error().Str("path", s.FullPath()).Msg("could not create tmp file")
return nil
}
defer func() {
_ = tmpfile.Close()
_ = os.Remove(tmpfile.Name())
}()
_, err = io.Copy(tmpfile, reader)
if err != nil {
logging.Error().Str("path", s.FullPath()).Msg("could not copy archive file")
return nil
}
reader = tmpfile
}
}
return extractor.Extract(ctx, reader, func(_ context.Context, d archives.FileInfo) error {
if d.IsDir() {
return nil
}
innerReader, err := d.Open()
if err != nil {
logging.Error().Err(err).Str("path", s.FullPath()).Msg("could not open archive inner file")
return nil
}
defer innerReader.Close()
path := filepath.Clean(d.NameInArchive)
if s.Config != nil && shouldSkipPath(s.Config, path) {
logging.Debug().Str("path", s.FullPath()).Msg("skipping file: global allowlist")
return nil
}
file := &File{
Content: innerReader,
Path: path,
Symlink: s.Symlink,
outerPaths: append(s.outerPaths, filepath.ToSlash(s.Path)),
MaxArchiveDepth: s.MaxArchiveDepth,
archiveDepth: s.archiveDepth + 1,
}
if err := file.Fragments(ctx, yield); err != nil {
return err
}
return nil
})
}
// decompressorFragments recursively crawls archives and yields fragments
func (s *File) decompressorFragments(ctx context.Context, decompressor archives.Decompressor, reader io.Reader, yield FragmentsFunc) error {
innerReader, err := decompressor.OpenReader(reader)
if err != nil {
logging.Error().Str("path", s.FullPath()).Msg("could read compressed file")
return nil
}
if err := s.fileFragments(ctx, bufio.NewReader(innerReader), yield); err != nil {
_ = innerReader.Close()
return err
}
_ = innerReader.Close()
return nil
}
// fileFragments reads the file into fragments to yield
func (s *File) fileFragments(ctx context.Context, reader *bufio.Reader, yield FragmentsFunc) error {
// Create a buffer if the caller hasn't provided one
if s.Buffer == nil {
s.Buffer = make([]byte, defaultBufferSize)
}
totalLines := 0
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
fragment := Fragment{
FilePath: s.FullPath(),
}
n, err := reader.Read(s.Buffer)
if n == 0 {
if err != nil && err != io.EOF {
return yield(fragment, fmt.Errorf("could not read file: %w", err))
}
return nil
}
// Only check the filetype at the start of file.
if totalLines == 0 {
// TODO: could other optimizations be introduced here?
if mimetype, err := filetype.Match(s.Buffer[:n]); err != nil {
return yield(
fragment,
fmt.Errorf("could not read file: could not determine type: %w", err),
)
} else if mimetype.MIME.Type == "application" {
logging.Debug().
Str("mime_type", mimetype.MIME.Value).
Str("path", s.FullPath()).
Msgf("skipping binary file")
return nil
}
}
// Try to split chunks across large areas of whitespace, if possible.
peekBuf := bytes.NewBuffer(s.Buffer[:n])
if err := readUntilSafeBoundary(reader, n, maxPeekSize, peekBuf); err != nil {
return yield(
fragment,
fmt.Errorf("could not read file: could not read until safe boundary: %w", err),
)
}
fragment.Raw = peekBuf.String()
fragment.Bytes = peekBuf.Bytes()
fragment.StartLine = totalLines + 1
// Count the number of newlines in this chunk
totalLines += strings.Count(fragment.Raw, "\n")
if len(s.Symlink) > 0 {
fragment.SymlinkFile = s.Symlink
}
if isWindows {
fragment.FilePath = filepath.ToSlash(fragment.FilePath)
fragment.SymlinkFile = filepath.ToSlash(s.Symlink)
fragment.WindowsFilePath = s.FullPath()
}
// log errors but continue since there's content
if err != nil && err != io.EOF {
logging.Warn().Err(err).Msgf("issue reading file")
}
// Done with the file!
if err == io.EOF {
return yield(fragment, nil)
}
if err := yield(fragment, err); err != nil {
return err
}
}
}
}
// FullPath returns the File.Path with any preceding outer paths
func (s *File) FullPath() string {
if len(s.outerPaths) > 0 {
return strings.Join(
// outerPaths have already been normalized to slash
append(s.outerPaths, s.Path),
InnerPathSeparator,
)
}
return s.Path
}