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
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
204 lines
7.4 KiB
Go
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
|
|
}
|