diff --git a/modules/secretscan/scanner.go b/modules/secretscan/scanner.go index a4e6c9559c..78b69bcf5c 100644 --- a/modules/secretscan/scanner.go +++ b/modules/secretscan/scanner.go @@ -6,6 +6,9 @@ package secretscan import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" "io" "path/filepath" @@ -46,6 +49,60 @@ type Scanner struct { 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{ @@ -292,6 +349,21 @@ func (s *Scanner) ScanCommitRange(ctx context.Context, repo *git.Repository, old 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 diff --git a/services/secretscan/secretscan.go b/services/secretscan/secretscan.go index b5577360b4..1a34d15e69 100644 --- a/services/secretscan/secretscan.go +++ b/services/secretscan/secretscan.go @@ -143,7 +143,10 @@ func formatBlockMessage(secrets []secretscan.DetectedSecret, repoName string) st sb.WriteString(" 2. Use environment variables or a secrets manager\n") sb.WriteString(" 3. Consider using GitCaddy Vault to securely store secrets\n") sb.WriteString("\n") - sb.WriteString("If this is a false positive, contact your administrator.\n") + sb.WriteString("FALSE POSITIVE?\n") + sb.WriteString(" Use the GitSecrets addon to mark detections as false\n") + sb.WriteString(" positives. This creates a .gitsecrets-ignore file in\n") + sb.WriteString(" your repo that the server will respect on future pushes.\n") sb.WriteString("═══════════════════════════════════════════════════════════\n") return sb.String()