2
0
Files
logikonline 5bda6de937
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
refactor(secretscan): use internal json module instead of stdlib
2026-03-04 01:03:31 -05:00

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
}