Cybersecurity-Projects/PROJECTS/intermediate/secrets-scanner/internal/source/git.go

332 lines
6.1 KiB
Go

/*
©AngelaMos | 2026
git.go
Git history and staged-index scanner
Opens a git repository with go-git and streams 50-line chunks from either the
full commit history or the staging area. History mode walks every commit on the
target branch (or HEAD), respecting optional Since date and Depth limits. Staged
mode reads blobs directly from the index, skipping unmodified files.
Key exports:
Git - scanner with RepoPath, Branch, Since, Depth, StagedOnly fields
NewGit - constructs a Git source with defaults applied
Connects to:
source/source.go - implements the Source interface
engine/pipeline.go - receives Chunk values with CommitSHA and Author populated
cli/git.go - creates a Git source and passes it to the pipeline
*/
package source
import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/storer"
"github.com/CarterPerez-dev/portia/pkg/types"
)
type Git struct {
RepoPath string
Branch string
Since string
Depth int
StagedOnly bool
MaxSize int64
Excludes []string
}
func NewGit(
repoPath string,
branch string,
since string,
depth int,
stagedOnly bool,
maxSize int64,
excludes []string,
) *Git {
if maxSize <= 0 {
maxSize = defaultMaxFileSize
}
return &Git{
RepoPath: repoPath,
Branch: branch,
Since: since,
Depth: depth,
StagedOnly: stagedOnly,
MaxSize: maxSize,
Excludes: excludes,
}
}
func (g *Git) String() string {
return "git:" + g.RepoPath
}
func (g *Git) Chunks(
ctx context.Context, out chan<- types.Chunk,
) error {
repo, err := git.PlainOpen(g.RepoPath)
if err != nil {
return fmt.Errorf("open repository: %w", err)
}
if g.StagedOnly {
return g.scanStaged(ctx, repo, out)
}
return g.scanHistory(ctx, repo, out)
}
func (g *Git) scanStaged(
ctx context.Context,
repo *git.Repository,
out chan<- types.Chunk,
) error {
wt, err := repo.Worktree()
if err != nil {
return fmt.Errorf("worktree: %w", err)
}
status, err := wt.Status()
if err != nil {
return fmt.Errorf("status: %w", err)
}
idx, err := repo.Storer.Index()
if err != nil {
return fmt.Errorf("index: %w", err)
}
for _, entry := range idx.Entries {
if ctx.Err() != nil {
return ctx.Err()
}
fileStatus := status.File(entry.Name)
if fileStatus.Staging == git.Unmodified &&
fileStatus.Worktree == git.Unmodified {
continue
}
if g.isExcluded(entry.Name) || isBinaryExt(entry.Name) {
continue
}
blob, blobErr := repo.BlobObject(entry.Hash)
if blobErr != nil {
continue
}
if blob.Size > g.MaxSize {
continue
}
content, readErr := readBlob(blob)
if readErr != nil {
continue
}
chunks := splitIntoChunks(
content, entry.Name, "", "", time.Time{},
)
for _, chunk := range chunks {
select {
case <-ctx.Done():
return ctx.Err()
case out <- chunk:
}
}
}
return nil
}
func (g *Git) scanHistory( //nolint:gocognit
ctx context.Context,
repo *git.Repository,
out chan<- types.Chunk,
) error {
ref, err := g.resolveRef(repo)
if err != nil {
return err
}
logOpts := &git.LogOptions{
From: ref.Hash(),
Order: git.LogOrderCommitterTime,
}
if g.Since != "" {
sinceTime, parseErr := time.Parse("2006-01-02", g.Since)
if parseErr == nil {
logOpts.Since = &sinceTime
}
}
iter, err := repo.Log(logOpts)
if err != nil {
return fmt.Errorf("git log: %w", err)
}
defer iter.Close()
count := 0
return iter.ForEach(func(c *object.Commit) error {
if ctx.Err() != nil {
return ctx.Err()
}
if g.Depth > 0 && count >= g.Depth {
return storer.ErrStop
}
count++
tree, treeErr := c.Tree()
if treeErr != nil {
return nil //nolint:nilerr
}
return tree.Files().ForEach(func(f *object.File) error {
if ctx.Err() != nil {
return ctx.Err()
}
if g.isExcluded(f.Name) || isBinaryExt(f.Name) {
return nil
}
if f.Size > g.MaxSize {
return nil
}
isBin, binErr := f.IsBinary()
if binErr != nil || isBin {
return nil //nolint:nilerr
}
content, readErr := f.Contents()
if readErr != nil {
return nil //nolint:nilerr
}
author := ""
if c.Author.Email != "" {
author = c.Author.Email
}
chunks := splitIntoChunks(
content,
f.Name,
c.Hash.String(),
author,
c.Author.When,
)
for _, chunk := range chunks {
select {
case <-ctx.Done():
return ctx.Err()
case out <- chunk:
}
}
return nil
})
})
}
func (g *Git) resolveRef(
repo *git.Repository,
) (*plumbing.Reference, error) {
if g.Branch != "" {
ref, err := repo.Reference(
plumbing.NewBranchReferenceName(g.Branch),
true,
)
if err != nil {
return nil, fmt.Errorf(
"resolve branch %s: %w", g.Branch, err,
)
}
return ref, nil
}
ref, err := repo.Head()
if err != nil {
return nil, fmt.Errorf("resolve HEAD: %w", err)
}
return ref, nil
}
func (g *Git) isExcluded(path string) bool {
for _, pattern := range g.Excludes {
if matched, _ := filepath.Match( //nolint:errcheck
pattern, filepath.Base(path),
); matched {
return true
}
if strings.Contains(path, pattern) {
return true
}
}
return false
}
func readBlob(blob *object.Blob) (string, error) {
reader, err := blob.Reader()
if err != nil {
return "", err
}
defer reader.Close() //nolint:errcheck
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
return string(data), nil
}
func splitIntoChunks(
content string,
filePath string,
commitSHA string,
author string,
commitDate time.Time,
) []types.Chunk {
lines := strings.Split(content, "\n")
var chunks []types.Chunk
for i := 0; i < len(lines); i += 50 {
end := i + 50
if end > len(lines) {
end = len(lines)
}
chunkContent := strings.Join(lines[i:end], "\n")
if strings.TrimSpace(chunkContent) == "" {
continue
}
chunks = append(chunks, types.Chunk{
Content: chunkContent,
FilePath: filePath,
LineStart: i + 1,
CommitSHA: commitSHA,
Author: author,
CommitDate: commitDate,
})
}
return chunks
}