Some checks failed
Build and Release / Create Release (push) Has been skipped
Build and Release / Unit Tests (push) Failing after 31s
Build and Release / Lint (push) Failing after 55s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Integration Tests (PostgreSQL) (push) Failing after 56s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped
498 lines
13 KiB
Go
498 lines
13 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package secretscan
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.gitcaddy.com/server/v3/modules/git"
|
|
"code.gitcaddy.com/server/v3/modules/git/gitcmd"
|
|
"code.gitcaddy.com/server/v3/modules/json"
|
|
"code.gitcaddy.com/server/v3/modules/log"
|
|
"code.gitcaddy.com/server/v3/modules/setting"
|
|
)
|
|
|
|
// DetectedSecret represents a secret found during scanning
|
|
type DetectedSecret struct {
|
|
PatternID string `json:"pattern_id"`
|
|
PatternName string `json:"pattern_name"`
|
|
Category Category `json:"category"`
|
|
Severity Severity `json:"severity"`
|
|
FilePath string `json:"file_path"`
|
|
LineNumber int `json:"line_number"`
|
|
MatchedText string `json:"matched_text"`
|
|
MaskedText string `json:"masked_text"`
|
|
CommitSHA string `json:"commit_sha,omitempty"`
|
|
}
|
|
|
|
// ScanResult contains the results of a secret scan
|
|
type ScanResult struct {
|
|
Secrets []DetectedSecret `json:"secrets"`
|
|
ScannedFiles int `json:"scanned_files"`
|
|
ScanDuration time.Duration `json:"scan_duration"`
|
|
Blocked bool `json:"blocked"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// Scanner scans content for secrets
|
|
type Scanner struct {
|
|
patterns []Pattern
|
|
ignoredFiles []string
|
|
}
|
|
|
|
// IgnoreEntry represents an entry in a .gitsecrets-ignore file
|
|
type IgnoreEntry struct {
|
|
ContentHash string `json:"contentHash"`
|
|
PatternID string `json:"patternId"`
|
|
FilePath string `json:"filePath"`
|
|
Reason string `json:"reason"`
|
|
Confidence float64 `json:"confidence,omitempty"`
|
|
AddedAt int64 `json:"addedAt"`
|
|
}
|
|
|
|
// contentHash computes the same hash the GitSecrets addon uses: SHA-256 truncated to 16 hex chars
|
|
func contentHash(text string) string {
|
|
h := sha256.Sum256([]byte(text))
|
|
return hex.EncodeToString(h[:])[:16]
|
|
}
|
|
|
|
// parseIgnoreFile parses a .gitsecrets-ignore file and returns a map keyed by contentHash
|
|
func parseIgnoreFile(data string) map[string]IgnoreEntry {
|
|
entries := make(map[string]IgnoreEntry)
|
|
for line := range strings.SplitSeq(data, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
var entry IgnoreEntry
|
|
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
|
continue
|
|
}
|
|
if entry.ContentHash != "" {
|
|
entries[entry.ContentHash] = entry
|
|
}
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// filterIgnored removes detected secrets that match entries in the .gitsecrets-ignore file.
|
|
// Matching is done on contentHash + patternId.
|
|
func filterIgnored(secrets []DetectedSecret, ignoreEntries map[string]IgnoreEntry) []DetectedSecret {
|
|
if len(ignoreEntries) == 0 {
|
|
return secrets
|
|
}
|
|
|
|
var filtered []DetectedSecret
|
|
for _, s := range secrets {
|
|
hash := contentHash(s.MatchedText)
|
|
if entry, ok := ignoreEntries[hash]; ok && entry.PatternID == s.PatternID {
|
|
log.Debug("Secret scan: Skipping ignored secret (hash=%s, pattern=%s, reason=%s)", hash, s.PatternID, entry.Reason)
|
|
continue
|
|
}
|
|
filtered = append(filtered, s)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// NewScanner creates a new secret scanner
|
|
func NewScanner() *Scanner {
|
|
return &Scanner{
|
|
patterns: GetBuiltinPatterns(),
|
|
ignoredFiles: getDefaultIgnoredFiles(),
|
|
}
|
|
}
|
|
|
|
// getDefaultIgnoredFiles returns the default list of ignored file patterns
|
|
func getDefaultIgnoredFiles() []string {
|
|
return []string{
|
|
// Lock files
|
|
"package-lock.json",
|
|
"yarn.lock",
|
|
"pnpm-lock.yaml",
|
|
"go.sum",
|
|
"Cargo.lock",
|
|
"Gemfile.lock",
|
|
"poetry.lock",
|
|
"composer.lock",
|
|
// Documentation
|
|
"*.md",
|
|
"*.txt",
|
|
"LICENSE*",
|
|
"CHANGELOG*",
|
|
// Binary files (won't be in diff anyway but good to have)
|
|
"*.exe", "*.dll", "*.so", "*.dylib",
|
|
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.ico",
|
|
"*.zip", "*.tar", "*.gz", "*.rar",
|
|
"*.pdf", "*.doc", "*.docx",
|
|
// Test fixtures
|
|
"**/testdata/*",
|
|
"**/fixtures/*",
|
|
"**/__mocks__/*",
|
|
}
|
|
}
|
|
|
|
// shouldIgnoreFile checks if a file should be ignored during scanning
|
|
func (s *Scanner) shouldIgnoreFile(filePath string) bool {
|
|
fileName := filepath.Base(filePath)
|
|
|
|
for _, pattern := range s.ignoredFiles {
|
|
// Check exact match
|
|
if pattern == fileName {
|
|
return true
|
|
}
|
|
|
|
// Check glob pattern
|
|
if matched, _ := filepath.Match(pattern, fileName); matched {
|
|
return true
|
|
}
|
|
|
|
// Check path pattern
|
|
if matched, _ := filepath.Match(pattern, filePath); matched {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ScanContent scans text content for secrets
|
|
func (s *Scanner) ScanContent(content, filePath string) []DetectedSecret {
|
|
var secrets []DetectedSecret
|
|
|
|
if s.shouldIgnoreFile(filePath) {
|
|
return secrets
|
|
}
|
|
|
|
lines := strings.Split(content, "\n")
|
|
|
|
for _, pattern := range s.patterns {
|
|
matches := pattern.Regex.FindAllStringIndex(content, -1)
|
|
for _, match := range matches {
|
|
matchedText := content[match[0]:match[1]]
|
|
|
|
// Skip false positives
|
|
if IsFalsePositive(matchedText, &pattern) {
|
|
continue
|
|
}
|
|
|
|
// Calculate line number
|
|
lineNumber := 1
|
|
for _, c := range content[:match[0]] {
|
|
if c == '\n' {
|
|
lineNumber++
|
|
}
|
|
}
|
|
|
|
// Get context (the line)
|
|
contextLine := ""
|
|
if lineNumber > 0 && lineNumber <= len(lines) {
|
|
contextLine = lines[lineNumber-1]
|
|
}
|
|
_ = contextLine
|
|
|
|
secrets = append(secrets, DetectedSecret{
|
|
PatternID: pattern.ID,
|
|
PatternName: pattern.Name,
|
|
Category: pattern.Category,
|
|
Severity: pattern.Severity,
|
|
FilePath: filePath,
|
|
LineNumber: lineNumber,
|
|
MatchedText: matchedText,
|
|
MaskedText: maskSecret(matchedText),
|
|
})
|
|
}
|
|
}
|
|
|
|
return secrets
|
|
}
|
|
|
|
// ScanDiff scans a git diff for secrets (only added lines)
|
|
func (s *Scanner) ScanDiff(diff string) []DetectedSecret {
|
|
var secrets []DetectedSecret
|
|
|
|
currentFile := ""
|
|
lineNumber := 0
|
|
|
|
for line := range strings.SplitSeq(diff, "\n") {
|
|
// Track current file from diff header
|
|
if file, found := strings.CutPrefix(line, "+++ b/"); found {
|
|
currentFile = file
|
|
lineNumber = 0
|
|
continue
|
|
}
|
|
|
|
// Track line numbers from hunk header
|
|
if strings.HasPrefix(line, "@@") {
|
|
// Parse @@ -x,y +a,b @@
|
|
for part := range strings.SplitSeq(line, " ") {
|
|
if strings.HasPrefix(part, "+") && part != "+++" {
|
|
_, _ = fmt.Sscanf(part, "+%d", &lineNumber)
|
|
lineNumber-- // Will be incremented below
|
|
break
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Only scan added lines (starting with +)
|
|
if content, found := strings.CutPrefix(line, "+"); found && !strings.HasPrefix(line, "+++") {
|
|
lineNumber++
|
|
|
|
if s.shouldIgnoreFile(currentFile) {
|
|
continue
|
|
}
|
|
|
|
lineSecrets := s.ScanContent(content, currentFile)
|
|
for i := range lineSecrets {
|
|
lineSecrets[i].LineNumber = lineNumber
|
|
}
|
|
secrets = append(secrets, lineSecrets...)
|
|
} else if !strings.HasPrefix(line, "-") {
|
|
// Context line (no prefix) - increment line number
|
|
lineNumber++
|
|
}
|
|
}
|
|
|
|
return secrets
|
|
}
|
|
|
|
// ScanCommitRange scans all commits in a range for secrets
|
|
func (s *Scanner) ScanCommitRange(ctx context.Context, repo *git.Repository, oldCommitID, newCommitID string) (*ScanResult, error) {
|
|
startTime := time.Now()
|
|
result := &ScanResult{
|
|
Secrets: []DetectedSecret{},
|
|
}
|
|
|
|
// Get object format to check for empty commit ID
|
|
objectFormat, err := repo.GetObjectFormat()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get object format: %w", err)
|
|
}
|
|
|
|
// Handle new branch (oldCommitID is all zeros)
|
|
emptyOID := objectFormat.EmptyObjectID().String()
|
|
if oldCommitID == emptyOID || strings.Trim(oldCommitID, "0") == "" {
|
|
// For new branches, scan all files in the new commit
|
|
commit, err := repo.GetCommit(newCommitID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get commit %s: %w", newCommitID, err)
|
|
}
|
|
|
|
// Get all entries recursively using the embedded Tree
|
|
entries, err := commit.ListEntriesRecursiveFast()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list tree entries: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
if s.shouldIgnoreFile(entry.Name()) {
|
|
continue
|
|
}
|
|
|
|
result.ScannedFiles++
|
|
|
|
// Get file content
|
|
content, err := commit.GetFileContent(entry.Name(), 1024*1024) // 1MB limit
|
|
if err != nil {
|
|
log.Warn("Failed to read file %s: %v", entry.Name(), err)
|
|
continue
|
|
}
|
|
|
|
secrets := s.ScanContent(content, entry.Name())
|
|
for i := range secrets {
|
|
secrets[i].CommitSHA = newCommitID
|
|
}
|
|
result.Secrets = append(result.Secrets, secrets...)
|
|
}
|
|
} else {
|
|
// Get diff between commits using git diff command
|
|
var diffBuf bytes.Buffer
|
|
compareArg := oldCommitID + ".." + newCommitID
|
|
err := gitcmd.NewCommand("diff", "-p", "-U0").
|
|
AddDynamicArguments(compareArg).
|
|
WithDir(repo.Path).
|
|
WithStdout(&diffBuf).
|
|
Run(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get diff: %w", err)
|
|
}
|
|
|
|
// Count files changed
|
|
filesBuf := bytes.Buffer{}
|
|
err = gitcmd.NewCommand("diff", "--name-only").
|
|
AddDynamicArguments(compareArg).
|
|
WithDir(repo.Path).
|
|
WithStdout(&filesBuf).
|
|
Run(ctx)
|
|
if err == nil {
|
|
result.ScannedFiles = len(strings.Split(strings.TrimSpace(filesBuf.String()), "\n"))
|
|
}
|
|
|
|
// Scan the diff
|
|
secrets := s.ScanDiff(diffBuf.String())
|
|
for i := range secrets {
|
|
secrets[i].CommitSHA = newCommitID
|
|
}
|
|
result.Secrets = append(result.Secrets, secrets...)
|
|
}
|
|
|
|
// Load .gitsecrets-ignore from the commit being pushed
|
|
commit, err := repo.GetCommit(newCommitID)
|
|
if err == nil {
|
|
if ignoreContent, err := commit.GetFileContent(".gitsecrets-ignore", 256*1024); err == nil {
|
|
ignoreEntries := parseIgnoreFile(ignoreContent)
|
|
if len(ignoreEntries) > 0 {
|
|
before := len(result.Secrets)
|
|
result.Secrets = filterIgnored(result.Secrets, ignoreEntries)
|
|
if skipped := before - len(result.Secrets); skipped > 0 {
|
|
log.Info("Secret scan: Skipped %d secret(s) via .gitsecrets-ignore in %s", skipped, repo.Path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
result.ScanDuration = time.Since(startTime)
|
|
|
|
// Determine if push should be blocked
|
|
if setting.SecretScan.BlockOnDetection && len(result.Secrets) > 0 {
|
|
result.Blocked = true
|
|
result.Message = formatBlockMessage(result.Secrets)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// maskSecret masks a secret for display
|
|
func maskSecret(secret string) string {
|
|
if len(secret) <= 8 {
|
|
return strings.Repeat("*", len(secret))
|
|
}
|
|
|
|
// For private keys, show type only
|
|
if strings.Contains(secret, "-----BEGIN") {
|
|
if idx := strings.Index(secret, "-----"); idx >= 0 {
|
|
end := strings.Index(secret[idx+5:], "-----")
|
|
if end > 0 {
|
|
return fmt.Sprintf("[%s]", secret[idx+5:idx+5+end])
|
|
}
|
|
}
|
|
return "[PRIVATE KEY]"
|
|
}
|
|
|
|
// Show first 4 and last 4 characters
|
|
prefix := secret[:4]
|
|
suffix := secret[len(secret)-4:]
|
|
masked := strings.Repeat("*", minInt(len(secret)-8, 20))
|
|
|
|
return prefix + masked + suffix
|
|
}
|
|
|
|
// formatBlockMessage creates a user-friendly message about detected secrets
|
|
func formatBlockMessage(secrets []DetectedSecret) string {
|
|
var sb strings.Builder
|
|
sb.WriteString("Push rejected: Secrets detected in your commits\n\n")
|
|
|
|
// Group by severity
|
|
critical := []DetectedSecret{}
|
|
high := []DetectedSecret{}
|
|
other := []DetectedSecret{}
|
|
|
|
for _, s := range secrets {
|
|
switch s.Severity {
|
|
case SeverityCritical:
|
|
critical = append(critical, s)
|
|
case SeverityHigh:
|
|
high = append(high, s)
|
|
default:
|
|
other = append(other, s)
|
|
}
|
|
}
|
|
|
|
if len(critical) > 0 {
|
|
sb.WriteString("CRITICAL:\n")
|
|
for _, s := range critical {
|
|
sb.WriteString(fmt.Sprintf(" - %s in %s (line %d)\n", s.PatternName, s.FilePath, s.LineNumber))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
if len(high) > 0 {
|
|
sb.WriteString("HIGH:\n")
|
|
for _, s := range high {
|
|
sb.WriteString(fmt.Sprintf(" - %s in %s (line %d)\n", s.PatternName, s.FilePath, s.LineNumber))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
if len(other) > 0 {
|
|
sb.WriteString("OTHER:\n")
|
|
for _, s := range other {
|
|
sb.WriteString(fmt.Sprintf(" - %s in %s (line %d)\n", s.PatternName, s.FilePath, s.LineNumber))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
sb.WriteString("Please remove these secrets and use environment variables or a secrets manager.\n")
|
|
sb.WriteString("If you believe this is a false positive, contact your administrator.\n")
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// ScanFile scans a single file for secrets
|
|
func (s *Scanner) ScanFile(ctx context.Context, repo *git.Repository, commitID, filePath string) ([]DetectedSecret, error) {
|
|
if s.shouldIgnoreFile(filePath) {
|
|
return nil, nil
|
|
}
|
|
|
|
commit, err := repo.GetCommit(commitID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get commit: %w", err)
|
|
}
|
|
|
|
content, err := commit.GetFileContent(filePath, 1024*1024) // 1MB limit
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get file content: %w", err)
|
|
}
|
|
|
|
secrets := s.ScanContent(content, filePath)
|
|
for i := range secrets {
|
|
secrets[i].CommitSHA = commitID
|
|
}
|
|
|
|
return secrets, nil
|
|
}
|
|
|
|
// ScanReader scans content from an io.Reader for secrets
|
|
func (s *Scanner) ScanReader(r io.Reader, filePath string) ([]DetectedSecret, error) {
|
|
if s.shouldIgnoreFile(filePath) {
|
|
return nil, nil
|
|
}
|
|
|
|
content, err := io.ReadAll(io.LimitReader(r, 1024*1024)) // 1MB limit
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read content: %w", err)
|
|
}
|
|
|
|
return s.ScanContent(string(content), filePath), nil
|
|
}
|