feat(security): built-in security scanning platform (#508) #540

Merged
jmiller merged 1 commits from feat/508-security-scanner into dev 2026-06-06 21:24:18 +00:00
12 changed files with 866 additions and 0 deletions
+1
View File
@@ -426,6 +426,7 @@ func prepareMigrationTasks() []*migration {
newMigration(346, "Add issue status definitions table", v1_27.AddIssueStatusDefTable),
newMigration(347, "Add repo manifest table", v1_27.AddRepoManifestTable),
newMigration(348, "Add issue priority definitions table", v1_27.AddIssuePriorityDefTable),
newMigration(349, "Add security scanning tables", v1_27.AddSecurityScanningTables),
}
return preparedMigrations
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import (
"xorm.io/xorm"
)
// AddSecurityScanningTables creates security_alert and security_scanner_config tables.
func AddSecurityScanningTables(x *xorm.Engine) error {
type SecurityAlert struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL 'repo_id'"`
Scanner string `xorm:"VARCHAR(20) NOT NULL 'scanner'"`
Severity string `xorm:"VARCHAR(10) NOT NULL 'severity'"`
Status string `xorm:"VARCHAR(10) NOT NULL DEFAULT 'active' 'status'"`
RuleID string `xorm:"VARCHAR(100) NOT NULL 'rule_id'"`
Title string `xorm:"TEXT NOT NULL 'title'"`
Description string `xorm:"TEXT 'description'"`
FilePath string `xorm:"TEXT 'file_path'"`
LineNumber int `xorm:"'line_number'"`
CommitSHA string `xorm:"VARCHAR(64) 'commit_sha'"`
Fingerprint string `xorm:"VARCHAR(64) INDEX 'fingerprint'"`
Metadata string `xorm:"TEXT 'metadata'"`
ResolvedBy int64 `xorm:"'resolved_by'"`
CreatedUnix int64 `xorm:"INDEX CREATED 'created_unix'"`
UpdatedUnix int64 `xorm:"UPDATED 'updated_unix'"`
}
if err := x.Sync(new(SecurityAlert)); err != nil {
return err
}
type SecurityScannerConfig struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE INDEX NOT NULL 'repo_id'"`
Enabled bool `xorm:"NOT NULL DEFAULT true 'enabled'"`
BlockOnPush bool `xorm:"NOT NULL DEFAULT false 'block_on_push'"`
SecretScanner bool `xorm:"NOT NULL DEFAULT true 'secret_scanner'"`
DependScanner bool `xorm:"NOT NULL DEFAULT true 'depend_scanner'"`
CodeScanner bool `xorm:"NOT NULL DEFAULT false 'code_scanner'"`
ConfigScanner bool `xorm:"NOT NULL DEFAULT false 'config_scanner'"`
LicenseScanner bool `xorm:"NOT NULL DEFAULT false 'license_scanner'"`
CustomPatterns string `xorm:"TEXT 'custom_patterns'"`
CreatedUnix int64 `xorm:"INDEX CREATED 'created_unix'"`
UpdatedUnix int64 `xorm:"UPDATED 'updated_unix'"`
}
return x.Sync(new(SecurityScannerConfig))
}
+219
View File
@@ -0,0 +1,219 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package security
import (
"context"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)
func init() {
db.RegisterModel(new(SecurityAlert))
db.RegisterModel(new(SecurityScannerConfig))
}
// AlertSeverity represents the severity level of a security finding.
type AlertSeverity string
const (
SeverityCritical AlertSeverity = "critical"
SeverityHigh AlertSeverity = "high"
SeverityMedium AlertSeverity = "medium"
SeverityLow AlertSeverity = "low"
SeverityInfo AlertSeverity = "info"
)
// AlertStatus represents the lifecycle state of an alert.
type AlertStatus string
const (
AlertStatusActive AlertStatus = "active"
AlertStatusResolved AlertStatus = "resolved"
AlertStatusDismissed AlertStatus = "dismissed"
)
// ScannerType identifies which scanner produced a finding.
type ScannerType string
const (
ScannerSecret ScannerType = "secret"
ScannerDependency ScannerType = "dependency"
ScannerCode ScannerType = "code"
ScannerConfig ScannerType = "config"
ScannerLicense ScannerType = "license"
)
// SecurityAlert stores a single security finding for a repository.
type SecurityAlert struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL 'repo_id'"`
Scanner ScannerType `xorm:"VARCHAR(20) NOT NULL 'scanner'"`
Severity AlertSeverity `xorm:"VARCHAR(10) NOT NULL 'severity'"`
Status AlertStatus `xorm:"VARCHAR(10) NOT NULL DEFAULT 'active' 'status'"`
RuleID string `xorm:"VARCHAR(100) NOT NULL 'rule_id'"` // e.g. "aws-access-key", "cve-2024-1234"
Title string `xorm:"TEXT NOT NULL 'title'"`
Description string `xorm:"TEXT 'description'"`
FilePath string `xorm:"TEXT 'file_path'"`
LineNumber int `xorm:"'line_number'"`
CommitSHA string `xorm:"VARCHAR(64) 'commit_sha'"`
Fingerprint string `xorm:"VARCHAR(64) INDEX 'fingerprint'"` // dedup key: hash of rule+file+content
Metadata string `xorm:"TEXT 'metadata'"` // JSON extra data
ResolvedBy int64 `xorm:"'resolved_by'"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
}
func (SecurityAlert) TableName() string {
return "security_alert"
}
// SecurityScannerConfig stores per-repo scanner settings.
type SecurityScannerConfig struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE INDEX NOT NULL 'repo_id'"`
Enabled bool `xorm:"NOT NULL DEFAULT true 'enabled'"`
BlockOnPush bool `xorm:"NOT NULL DEFAULT false 'block_on_push'"` // reject push if secrets found
SecretScanner bool `xorm:"NOT NULL DEFAULT true 'secret_scanner'"`
DependScanner bool `xorm:"NOT NULL DEFAULT true 'depend_scanner'"`
CodeScanner bool `xorm:"NOT NULL DEFAULT false 'code_scanner'"`
ConfigScanner bool `xorm:"NOT NULL DEFAULT false 'config_scanner'"`
LicenseScanner bool `xorm:"NOT NULL DEFAULT false 'license_scanner'"`
CustomPatterns string `xorm:"TEXT 'custom_patterns'"` // JSON array of custom regex patterns
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED 'updated_unix'"`
}
func (SecurityScannerConfig) TableName() string {
return "security_scanner_config"
}
// ──────────────────────────────────────────────────────────────────────
// Alert queries
// ──────────────────────────────────────────────────────────────────────
// GetActiveAlerts returns all active alerts for a repo.
func GetActiveAlerts(ctx context.Context, repoID int64) ([]*SecurityAlert, error) {
alerts := make([]*SecurityAlert, 0, 20)
return alerts, db.GetEngine(ctx).
Where("repo_id = ? AND status = ?", repoID, AlertStatusActive).
OrderBy("severity ASC, created_unix DESC").
Find(&alerts)
}
// GetAllAlerts returns all alerts for a repo (including resolved/dismissed).
func GetAllAlerts(ctx context.Context, repoID int64) ([]*SecurityAlert, error) {
alerts := make([]*SecurityAlert, 0, 50)
return alerts, db.GetEngine(ctx).
Where("repo_id = ?", repoID).
OrderBy("status ASC, severity ASC, created_unix DESC").
Find(&alerts)
}
// GetAlertByID returns a single alert.
func GetAlertByID(ctx context.Context, id int64) (*SecurityAlert, error) {
alert := new(SecurityAlert)
has, err := db.GetEngine(ctx).ID(id).Get(alert)
if err != nil {
return nil, err
}
if !has {
return nil, db.ErrNotExist{Resource: "SecurityAlert", ID: id}
}
return alert, nil
}
// GetAlertCountsByRepo returns count of active alerts grouped by severity.
func GetAlertCountsByRepo(ctx context.Context, repoID int64) (map[AlertSeverity]int64, error) {
type result struct {
Severity AlertSeverity `xorm:"severity"`
Count int64 `xorm:"count"`
}
var results []result
err := db.GetEngine(ctx).
Table("security_alert").
Select("severity, COUNT(*) as count").
Where("repo_id = ? AND status = ?", repoID, AlertStatusActive).
GroupBy("severity").
Find(&results)
if err != nil {
return nil, err
}
counts := make(map[AlertSeverity]int64)
for _, r := range results {
counts[r.Severity] = r.Count
}
return counts, nil
}
// CreateOrUpdateAlert creates a new alert or updates if fingerprint exists.
func CreateOrUpdateAlert(ctx context.Context, alert *SecurityAlert) error {
if alert.Fingerprint != "" {
existing := new(SecurityAlert)
has, err := db.GetEngine(ctx).
Where("repo_id = ? AND fingerprint = ?", alert.RepoID, alert.Fingerprint).
Get(existing)
if err != nil {
return err
}
if has {
// Update existing - refresh commit SHA and keep active
existing.CommitSHA = alert.CommitSHA
existing.LineNumber = alert.LineNumber
existing.Status = AlertStatusActive
_, err = db.GetEngine(ctx).ID(existing.ID).
Cols("commit_sha", "line_number", "status").Update(existing)
return err
}
}
_, err := db.GetEngine(ctx).Insert(alert)
return err
}
// UpdateAlertStatus changes the status of an alert.
func UpdateAlertStatus(ctx context.Context, id int64, status AlertStatus, resolvedBy int64) error {
_, err := db.GetEngine(ctx).ID(id).
Cols("status", "resolved_by").
Update(&SecurityAlert{Status: status, ResolvedBy: resolvedBy})
return err
}
// ──────────────────────────────────────────────────────────────────────
// Scanner config queries
// ──────────────────────────────────────────────────────────────────────
// GetScannerConfig returns the scanner config for a repo, or defaults.
func GetScannerConfig(ctx context.Context, repoID int64) (*SecurityScannerConfig, error) {
cfg := new(SecurityScannerConfig)
has, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Get(cfg)
if err != nil {
return nil, err
}
if !has {
return &SecurityScannerConfig{
RepoID: repoID,
Enabled: true,
SecretScanner: true,
DependScanner: true,
}, nil
}
return cfg, nil
}
// SaveScannerConfig creates or updates scanner config.
func SaveScannerConfig(ctx context.Context, cfg *SecurityScannerConfig) error {
existing := new(SecurityScannerConfig)
has, err := db.GetEngine(ctx).Where("repo_id = ?", cfg.RepoID).Get(existing)
if err != nil {
return err
}
if has {
cfg.ID = existing.ID
_, err = db.GetEngine(ctx).ID(cfg.ID).AllCols().Update(cfg)
return err
}
_, err = db.GetEngine(ctx).Insert(cfg)
return err
}
+22
View File
@@ -2750,6 +2750,28 @@
"repo.settings.manifest_entry_point": "Entry Point",
"repo.settings.manifest_save": "Save Manifest",
"repo.settings.manifest_saved": "Manifest settings saved.",
"repo.settings.security": "Security",
"repo.settings.security_desc": "Security scanning detects secrets, vulnerabilities, and code issues across the repository.",
"repo.settings.security_scanners": "Scanners",
"repo.settings.security_enabled": "Enable security scanning",
"repo.settings.security_secret_scanner": "Secret Scanner - API keys, tokens, passwords, private keys",
"repo.settings.security_depend_scanner": "Dependency Scanner - CVEs in dependencies (coming soon)",
"repo.settings.security_code_scanner": "Code Scanner - SQL injection, XSS, command injection (coming soon)",
"repo.settings.security_config_scanner": "Config Scanner - Insecure settings, debug modes (coming soon)",
"repo.settings.security_license_scanner": "License Scanner - License compliance (coming soon)",
"repo.settings.security_block_on_push": "Block pushes with critical findings",
"repo.settings.security_block_on_push_help": "Reject pushes to the default branch if critical secrets are detected.",
"repo.settings.security_save": "Save Settings",
"repo.settings.security_saved": "Security settings saved.",
"repo.settings.security_alerts": "Security Alerts",
"repo.settings.security_scan_now": "Scan Now",
"repo.settings.security_scan_complete": "Security scan complete.",
"repo.settings.security_severity": "Severity",
"repo.settings.security_scanner_type": "Scanner",
"repo.settings.security_finding": "Finding",
"repo.settings.security_file": "File",
"repo.settings.security_status": "Status",
"repo.settings.security_no_alerts": "No security alerts found. Run a scan or push to the default branch to check.",
"repo.settings.metadata": "Metadata",
"repo.settings.metadata_saved": "Repository metadata saved.",
"repo.settings.metadata_empty": "No metadata fields defined. Org admins can add fields in Organization Settings > Custom Fields.",
+111
View File
@@ -0,0 +1,111 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package setting
import (
"net/http"
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security"
)
const tplSettingsSecurity templates.TplName = "repo/settings/security"
// SecuritySettings displays the repo security scanning settings and alerts.
func SecuritySettings(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.security")
ctx.Data["PageIsSettingsSecurity"] = true
repoID := ctx.Repo.Repository.ID
cfg, err := security_model.GetScannerConfig(ctx, repoID)
if err != nil {
ctx.ServerError("GetScannerConfig", err)
return
}
ctx.Data["ScannerConfig"] = cfg
alerts, err := security_model.GetAllAlerts(ctx, repoID)
if err != nil {
ctx.ServerError("GetAllAlerts", err)
return
}
ctx.Data["SecurityAlerts"] = alerts
counts, err := security_model.GetAlertCountsByRepo(ctx, repoID)
if err != nil {
ctx.ServerError("GetAlertCountsByRepo", err)
return
}
ctx.Data["AlertCounts"] = counts
ctx.HTML(http.StatusOK, tplSettingsSecurity)
}
// SecuritySettingsPost saves security scanner configuration.
func SecuritySettingsPost(ctx *context.Context) {
cfg := &security_model.SecurityScannerConfig{
RepoID: ctx.Repo.Repository.ID,
Enabled: ctx.FormString("enabled") == "on",
BlockOnPush: ctx.FormString("block_on_push") == "on",
SecretScanner: ctx.FormString("secret_scanner") == "on",
DependScanner: ctx.FormString("depend_scanner") == "on",
CodeScanner: ctx.FormString("code_scanner") == "on",
ConfigScanner: ctx.FormString("config_scanner") == "on",
LicenseScanner: ctx.FormString("license_scanner") == "on",
}
if err := security_model.SaveScannerConfig(ctx, cfg); err != nil {
ctx.ServerError("SaveScannerConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.security_saved"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings/security")
}
// SecurityScanNow triggers an immediate scan of the repository.
func SecurityScanNow(ctx *context.Context) {
commit := ctx.Repo.Commit
if commit == nil {
ctx.Flash.Error("No commits found in repository")
ctx.Redirect(ctx.Repo.RepoLink + "/settings/security")
return
}
security_service.ScanOnPush(ctx, ctx.Repo.Repository, commit)
ctx.Flash.Success(ctx.Tr("repo.settings.security_scan_complete"))
ctx.Redirect(ctx.Repo.RepoLink + "/settings/security")
}
// SecurityAlertUpdate changes the status of a security alert.
func SecurityAlertUpdate(ctx *context.Context) {
id := ctx.PathParamInt64("id")
status := security_model.AlertStatus(ctx.FormString("status"))
if status != security_model.AlertStatusResolved && status != security_model.AlertStatusDismissed {
status = security_model.AlertStatusDismissed
}
alert, err := security_model.GetAlertByID(ctx, id)
if err != nil {
ctx.ServerError("GetAlertByID", err)
return
}
if alert.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
if err := security_model.UpdateAlertStatus(ctx, id, status, ctx.Doer.ID); err != nil {
ctx.ServerError("UpdateAlertStatus", err)
return
}
ctx.Flash.Success("Alert updated")
ctx.Redirect(ctx.Repo.RepoLink + "/settings/security")
}
+5
View File
@@ -1207,6 +1207,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Combo("/licensing").Get(repo_setting.LicensingSettings).Post(repo_setting.LicensingSettingsPost)
m.Combo("/manifest").Get(repo_setting.ManifestSettings).Post(repo_setting.ManifestSettingsPost)
m.Combo("/metadata").Get(repo_setting.Metadata).Post(repo_setting.MetadataPost)
m.Group("/security", func() {
m.Combo("").Get(repo_setting.SecuritySettings).Post(repo_setting.SecuritySettingsPost)
m.Post("/scan", repo_setting.SecurityScanNow)
m.Post("/alert/{id}", repo_setting.SecurityAlertUpdate)
})
m.Group("/collaboration", func() {
m.Combo("").Get(repo_setting.Collaboration).Post(repo_setting.CollaborationPost)
+3
View File
@@ -27,6 +27,7 @@ import (
issue_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/issue"
notify_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/notify"
pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull"
security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security"
)
// pushQueue represents a queue to handle update pull request tests
@@ -195,6 +196,8 @@ func pushUpdates(optsList []*repo_module.PushUpdateOptions) error {
}
// Auto-sync .mokogitea/manifest.xml to database on default branch push
SyncManifestFromCommit(ctx, repo, newCommit)
// Run security scanners on default branch push
security_service.ScanOnPush(ctx, repo, newCommit)
} else {
if err := DelDivergenceFromCache(repo.ID, branch); err != nil {
log.Error("DelDivergenceFromCache: %v", err)
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package security
import (
"context"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
)
// ScanOnPush runs enabled scanners against a commit pushed to the default branch.
// Called from services/repository/push.go on default branch pushes.
func ScanOnPush(ctx context.Context, repo *repo_model.Repository, commit *git.Commit) {
if commit == nil {
return
}
cfg, err := security_model.GetScannerConfig(ctx, repo.ID)
if err != nil {
log.Error("SecurityScan: GetScannerConfig for %s: %v", repo.FullName(), err)
return
}
if !cfg.Enabled {
return
}
var scanners []Scanner
if cfg.SecretScanner {
scanners = append(scanners, NewSecretScanner())
}
// Future scanners added here:
// if cfg.DependScanner { scanners = append(scanners, NewDependencyScanner()) }
// if cfg.CodeScanner { scanners = append(scanners, NewCodeScanner()) }
if len(scanners) == 0 {
return
}
totalFindings := 0
for _, s := range scanners {
findings, err := s.ScanTree(commit)
if err != nil {
log.Error("SecurityScan: %s scanner for %s: %v", s.Type(), repo.FullName(), err)
continue
}
for _, f := range findings {
alert := &security_model.SecurityAlert{
RepoID: repo.ID,
Scanner: f.Scanner,
Severity: f.Severity,
RuleID: f.RuleID,
Title: f.Title,
Description: f.Description,
FilePath: f.FilePath,
LineNumber: f.LineNumber,
CommitSHA: f.CommitSHA,
Fingerprint: f.Fingerprint,
Metadata: f.Metadata,
}
if err := security_model.CreateOrUpdateAlert(ctx, alert); err != nil {
log.Error("SecurityScan: CreateOrUpdateAlert: %v", err)
}
totalFindings++
}
}
if totalFindings > 0 {
log.Warn("SecurityScan: %d findings in %s", totalFindings, repo.FullName())
}
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package security
import (
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
)
// Finding represents a single security issue found by a scanner.
type Finding struct {
Scanner security_model.ScannerType
Severity security_model.AlertSeverity
RuleID string
Title string
Description string
FilePath string
LineNumber int
CommitSHA string
Fingerprint string // unique identifier for dedup
Metadata string // JSON extra data
}
// Scanner is the interface all security scanner modules implement.
type Scanner interface {
// Type returns the scanner type identifier.
Type() security_model.ScannerType
// ScanCommit scans a single commit and returns findings.
ScanCommit(commit *git.Commit) ([]Finding, error)
// ScanTree scans the full repository tree and returns findings.
ScanTree(commit *git.Commit) ([]Finding, error)
}
+203
View File
@@ -0,0 +1,203 @@
// 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
}
+3
View File
@@ -18,6 +18,9 @@
<a class="{{if .PageIsSettingsMetadata}}active {{end}}item" href="{{.RepoLink}}/settings/metadata">
{{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "repo.settings.metadata"}}
</a>
<a class="{{if .PageIsSettingsSecurity}}active {{end}}item" href="{{.RepoLink}}/settings/security">
{{svg "octicon-shield"}} {{ctx.Locale.Tr "repo.settings.security"}}
</a>
{{if or .Repository.IsPrivate .Permission.HasAnyUnitPublicAccess}}
<a class="{{if .PageIsSettingsPublicAccess}}active {{end}}item" href="{{.RepoLink}}/settings/public_access">
{{svg "octicon-eye"}} {{ctx.Locale.Tr "repo.settings.public_access"}}
+140
View File
@@ -0,0 +1,140 @@
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings security")}}
<h4 class="ui top attached header">
{{svg "octicon-shield" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.settings.security"}}
</h4>
<div class="ui attached segment">
<p class="text grey">{{ctx.Locale.Tr "repo.settings.security_desc"}}</p>
{{if .AlertCounts}}
<div class="tw-flex tw-gap-3 tw-mb-4">
{{range $sev, $count := .AlertCounts}}
<div class="ui mini {{if eq $sev "critical"}}red{{else if eq $sev "high"}}orange{{else if eq $sev "medium"}}yellow{{else if eq $sev "low"}}blue{{else}}grey{{end}} label">
{{$sev}}: {{$count}}
</div>
{{end}}
</div>
{{end}}
<form class="ui form" method="post" action="{{.RepoLink}}/settings/security">
{{.CsrfTokenHtml}}
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.security_scanners"}}</h5>
<div class="inline field">
<div class="ui checkbox">
<input name="enabled" type="checkbox" {{if .ScannerConfig.Enabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.security_enabled"}}</label>
</div>
</div>
<div class="inline field">
<div class="ui checkbox">
<input name="secret_scanner" type="checkbox" {{if .ScannerConfig.SecretScanner}}checked{{end}}>
<label>{{svg "octicon-key" 14}} {{ctx.Locale.Tr "repo.settings.security_secret_scanner"}}</label>
</div>
</div>
<div class="inline field">
<div class="ui checkbox">
<input name="depend_scanner" type="checkbox" {{if .ScannerConfig.DependScanner}}checked{{end}}>
<label>{{svg "octicon-package" 14}} {{ctx.Locale.Tr "repo.settings.security_depend_scanner"}}</label>
</div>
</div>
<div class="inline field">
<div class="ui checkbox">
<input name="code_scanner" type="checkbox" {{if .ScannerConfig.CodeScanner}}checked{{end}}>
<label>{{svg "octicon-code" 14}} {{ctx.Locale.Tr "repo.settings.security_code_scanner"}}</label>
</div>
</div>
<div class="inline field">
<div class="ui checkbox">
<input name="config_scanner" type="checkbox" {{if .ScannerConfig.ConfigScanner}}checked{{end}}>
<label>{{svg "octicon-gear" 14}} {{ctx.Locale.Tr "repo.settings.security_config_scanner"}}</label>
</div>
</div>
<div class="inline field">
<div class="ui checkbox">
<input name="license_scanner" type="checkbox" {{if .ScannerConfig.LicenseScanner}}checked{{end}}>
<label>{{svg "octicon-law" 14}} {{ctx.Locale.Tr "repo.settings.security_license_scanner"}}</label>
</div>
</div>
<div class="divider"></div>
<div class="inline field">
<div class="ui checkbox">
<input name="block_on_push" type="checkbox" {{if .ScannerConfig.BlockOnPush}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.security_block_on_push"}}</label>
</div>
<p class="help">{{ctx.Locale.Tr "repo.settings.security_block_on_push_help"}}</p>
</div>
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.settings.security_save"}}</button>
</form>
</div>
<h4 class="ui top attached header tw-mt-4">
{{ctx.Locale.Tr "repo.settings.security_alerts"}}
<form method="post" action="{{.RepoLink}}/settings/security/scan" class="tw-float-right tw-inline">
{{.CsrfTokenHtml}}
<button class="ui mini primary button" type="submit">{{svg "octicon-sync" 14}} {{ctx.Locale.Tr "repo.settings.security_scan_now"}}</button>
</form>
</h4>
<div class="ui attached segment">
{{if .SecurityAlerts}}
<table class="ui compact table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.settings.security_severity"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.security_scanner_type"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.security_finding"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.security_file"}}</th>
<th>{{ctx.Locale.Tr "repo.settings.security_status"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .SecurityAlerts}}
<tr {{if ne .Status "active"}}class="tw-opacity-50"{{end}}>
<td>
<span class="ui mini {{if eq .Severity "critical"}}red{{else if eq .Severity "high"}}orange{{else if eq .Severity "medium"}}yellow{{else if eq .Severity "low"}}blue{{else}}grey{{end}} label">
{{.Severity}}
</span>
</td>
<td>{{.Scanner}}</td>
<td>
<strong>{{.Title}}</strong>
{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}
</td>
<td>
{{if .FilePath}}
<code class="tw-text-xs">{{.FilePath}}{{if .LineNumber}}:{{.LineNumber}}{{end}}</code>
{{end}}
</td>
<td>
{{if eq .Status "active"}}
<span class="ui mini red label">Active</span>
{{else if eq .Status "resolved"}}
<span class="ui mini green label">Resolved</span>
{{else}}
<span class="ui mini grey label">Dismissed</span>
{{end}}
</td>
<td class="tw-text-right">
{{if eq .Status "active"}}
<form method="post" action="{{$.RepoLink}}/settings/security/alert/{{.ID}}" class="tw-inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="status" value="resolved">
<button class="ui tiny green icon button" type="submit" title="Resolve">{{svg "octicon-check" 14}}</button>
</form>
<form method="post" action="{{$.RepoLink}}/settings/security/alert/{{.ID}}" class="tw-inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="status" value="dismissed">
<button class="ui tiny grey icon button" type="submit" title="Dismiss">{{svg "octicon-x" 14}}</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-placeholder">
<p>{{ctx.Locale.Tr "repo.settings.security_no_alerts"}}</p>
</div>
{{end}}
</div>
{{template "repo/settings/layout_footer" .}}