Files
Jonathan Miller f7c1904625
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m44s
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Generic: Repo Health / Report Issues (push) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
feat(security): built-in security scanning platform (#508)
Add a pluggable security scanning framework with secret detection
as the first scanner module. Scans run on push to default branch
and on-demand via the Security settings page.

Includes:
- Scanner interface for pluggable scanner types
- Secret scanner with 15 built-in patterns (AWS, GitHub, Stripe, etc.)
- SecurityAlert model with fingerprint-based dedup
- SecurityScannerConfig per-repo settings
- Migration v349 for security tables
- Repo settings Security page with alerts table
- Scan Now button for on-demand scanning
- Alert resolve/dismiss actions
- Push-time scanning in post-receive hook
2026-06-06 16:23:08 -05:00

204 lines
7.4 KiB
Go

// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package security
import (
"bufio"
"crypto/sha256"
"fmt"
"io"
"regexp"
"strings"
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
)
// SecretRule defines a pattern to match against file contents.
type SecretRule struct {
ID string
Title string
Pattern *regexp.Regexp
Severity security_model.AlertSeverity
Description string
}
// DefaultSecretRules contains the built-in secret detection patterns.
var DefaultSecretRules = []SecretRule{
// AWS
{ID: "aws-access-key", Title: "AWS Access Key ID", Severity: security_model.SeverityCritical,
Pattern: regexp.MustCompile(`AKIA[0-9A-Z]{16}`), Description: "AWS access key ID detected"},
{ID: "aws-secret-key", Title: "AWS Secret Access Key", Severity: security_model.SeverityCritical,
Pattern: regexp.MustCompile(`(?i)aws_secret_access_key\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}`), Description: "AWS secret access key detected"},
// Generic tokens/keys
{ID: "private-key", Title: "Private Key", Severity: security_model.SeverityCritical,
Pattern: regexp.MustCompile(`-----BEGIN (RSA|EC|OPENSSH|DSA|PGP) PRIVATE KEY-----`), Description: "Private key file detected"},
{ID: "generic-api-key", Title: "Generic API Key", Severity: security_model.SeverityHigh,
Pattern: regexp.MustCompile(`(?i)(api[_-]?key|apikey)\s*[=:]\s*['"]?[A-Za-z0-9_\-]{20,}`), Description: "API key assignment detected"},
{ID: "generic-secret", Title: "Generic Secret", Severity: security_model.SeverityHigh,
Pattern: regexp.MustCompile(`(?i)(secret|password|passwd|pwd)\s*[=:]\s*['"][^'"]{8,}['"]`), Description: "Hardcoded secret or password detected"},
{ID: "generic-token", Title: "Generic Token", Severity: security_model.SeverityHigh,
Pattern: regexp.MustCompile(`(?i)(token|auth_token|access_token)\s*[=:]\s*['"]?[A-Za-z0-9_\-.]{20,}`), Description: "Token assignment detected"},
// GitHub/Gitea
{ID: "github-pat", Title: "GitHub Personal Access Token", Severity: security_model.SeverityCritical,
Pattern: regexp.MustCompile(`ghp_[A-Za-z0-9]{36}`), Description: "GitHub personal access token detected"},
{ID: "github-oauth", Title: "GitHub OAuth Token", Severity: security_model.SeverityCritical,
Pattern: regexp.MustCompile(`gho_[A-Za-z0-9]{36}`), Description: "GitHub OAuth token detected"},
// Stripe
{ID: "stripe-secret", Title: "Stripe Secret Key", Severity: security_model.SeverityCritical,
Pattern: regexp.MustCompile(`sk_live_[A-Za-z0-9]{24,}`), Description: "Stripe live secret key detected"},
{ID: "stripe-publishable", Title: "Stripe Publishable Key", Severity: security_model.SeverityLow,
Pattern: regexp.MustCompile(`pk_live_[A-Za-z0-9]{24,}`), Description: "Stripe live publishable key detected (usually safe but flagged)"},
// JWT
{ID: "jwt-token", Title: "JWT Token", Severity: security_model.SeverityMedium,
Pattern: regexp.MustCompile(`eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`), Description: "JWT token detected"},
// Connection strings
{ID: "connection-string", Title: "Connection String with Password", Severity: security_model.SeverityCritical,
Pattern: regexp.MustCompile(`(?i)(mysql|postgres|postgresql|mongodb|redis|amqp|smtp)://[^:]+:[^@]+@[^\s]+`), Description: "Database/service connection string with embedded password"},
// Google
{ID: "google-api-key", Title: "Google API Key", Severity: security_model.SeverityHigh,
Pattern: regexp.MustCompile(`AIza[0-9A-Za-z_-]{35}`), Description: "Google API key detected"},
// Slack
{ID: "slack-webhook", Title: "Slack Webhook URL", Severity: security_model.SeverityMedium,
Pattern: regexp.MustCompile(`https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[A-Za-z0-9]+`), Description: "Slack webhook URL detected"},
// SendGrid
{ID: "sendgrid-api-key", Title: "SendGrid API Key", Severity: security_model.SeverityHigh,
Pattern: regexp.MustCompile(`SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}`), Description: "SendGrid API key detected"},
// PayPal
{ID: "paypal-client-secret", Title: "PayPal Client Secret", Severity: security_model.SeverityCritical,
Pattern: regexp.MustCompile(`(?i)paypal.*secret\s*[=:]\s*['"]?[A-Za-z0-9_-]{20,}`), Description: "PayPal client secret detected"},
}
// Files to skip during scanning.
var skipExtensions = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".ico": true,
".svg": true, ".woff": true, ".woff2": true, ".ttf": true, ".eot": true,
".zip": true, ".tar": true, ".gz": true, ".bz2": true, ".7z": true,
".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true,
".exe": true, ".dll": true, ".so": true, ".dylib": true, ".o": true,
".min.js": true, ".min.css": true,
}
var skipPaths = []string{
"vendor/", "node_modules/", ".git/", "dist/", "build/",
"go.sum", "package-lock.json", "composer.lock", "yarn.lock",
}
// SecretScanner implements the Scanner interface for secret detection.
type SecretScanner struct {
Rules []SecretRule
}
// NewSecretScanner creates a scanner with default rules.
func NewSecretScanner() *SecretScanner {
return &SecretScanner{Rules: DefaultSecretRules}
}
func (s *SecretScanner) Type() security_model.ScannerType {
return security_model.ScannerSecret
}
func (s *SecretScanner) ScanCommit(commit *git.Commit) ([]Finding, error) {
// For push-time scanning, we scan the diff of the commit
return s.ScanTree(commit)
}
func (s *SecretScanner) ScanTree(commit *git.Commit) ([]Finding, error) {
if commit == nil {
return nil, nil
}
entries, err := commit.ListEntriesRecursiveFast()
if err != nil {
return nil, fmt.Errorf("ListEntriesRecursiveFast: %w", err)
}
var findings []Finding
for _, entry := range entries {
if !entry.IsRegular() {
continue
}
path := entry.Name()
if shouldSkipFile(path) {
continue
}
// Skip large files (> 1MB)
if entry.Blob().Size() > 1024*1024 {
continue
}
reader, err := entry.Blob().DataAsync()
if err != nil {
log.Trace("SecretScanner: skip %s: %v", path, err)
continue
}
fileFindings := s.scanReader(reader, path, commit.ID.String())
reader.Close()
findings = append(findings, fileFindings...)
}
return findings, nil
}
func (s *SecretScanner) scanReader(r io.Reader, filePath, commitSHA string) []Finding {
var findings []Finding
scanner := bufio.NewScanner(r)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
for _, rule := range s.Rules {
if rule.Pattern.MatchString(line) {
fingerprint := fmt.Sprintf("%x", sha256.Sum256([]byte(rule.ID+":"+filePath+":"+line)))
findings = append(findings, Finding{
Scanner: security_model.ScannerSecret,
Severity: rule.Severity,
RuleID: rule.ID,
Title: rule.Title,
Description: rule.Description,
FilePath: filePath,
LineNumber: lineNum,
CommitSHA: commitSHA,
Fingerprint: fingerprint[:32],
})
break // one finding per line per file
}
}
}
return findings
}
func shouldSkipFile(path string) bool {
lower := strings.ToLower(path)
for _, skip := range skipPaths {
if strings.HasPrefix(lower, skip) || strings.Contains(lower, "/"+skip) {
return true
}
}
for ext := range skipExtensions {
if strings.HasSuffix(lower, ext) {
return true
}
}
return false
}