diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 47bf3d8b9c..ffde9bbcf7 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 } diff --git a/models/migrations/v1_27/v350.go b/models/migrations/v1_27/v350.go new file mode 100644 index 0000000000..894c2687ed --- /dev/null +++ b/models/migrations/v1_27/v350.go @@ -0,0 +1,49 @@ +// Copyright 2026 Moko Consulting +// 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)) +} diff --git a/models/security/security_alert.go b/models/security/security_alert.go new file mode 100644 index 0000000000..456ea733f8 --- /dev/null +++ b/models/security/security_alert.go @@ -0,0 +1,219 @@ +// Copyright 2026 Moko Consulting +// 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 +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index b027f8eb89..d8fd9e8ec4 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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.", diff --git a/routers/web/repo/setting/security.go b/routers/web/repo/setting/security.go new file mode 100644 index 0000000000..b41448dc2d --- /dev/null +++ b/routers/web/repo/setting/security.go @@ -0,0 +1,111 @@ +// Copyright 2026 Moko Consulting +// 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") +} diff --git a/routers/web/web.go b/routers/web/web.go index bbc0371140..c160cb892b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/repository/push.go b/services/repository/push.go index 5519da1409..e893319cdd 100644 --- a/services/repository/push.go +++ b/services/repository/push.go @@ -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) diff --git a/services/security/orchestrator.go b/services/security/orchestrator.go new file mode 100644 index 0000000000..a44c9c1b73 --- /dev/null +++ b/services/security/orchestrator.go @@ -0,0 +1,75 @@ +// Copyright 2026 Moko Consulting +// 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()) + } +} diff --git a/services/security/scanner.go b/services/security/scanner.go new file mode 100644 index 0000000000..7bfa5450db --- /dev/null +++ b/services/security/scanner.go @@ -0,0 +1,35 @@ +// Copyright 2026 Moko Consulting +// 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) +} diff --git a/services/security/secret_scanner.go b/services/security/secret_scanner.go new file mode 100644 index 0000000000..a6ee4969ae --- /dev/null +++ b/services/security/secret_scanner.go @@ -0,0 +1,203 @@ +// Copyright 2026 Moko Consulting +// 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 +} diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index 071fb3b5fb..d5d4405026 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -18,6 +18,9 @@ {{svg "octicon-list-unordered"}} {{ctx.Locale.Tr "repo.settings.metadata"}} + + {{svg "octicon-shield"}} {{ctx.Locale.Tr "repo.settings.security"}} + {{if or .Repository.IsPrivate .Permission.HasAnyUnitPublicAccess}} {{svg "octicon-eye"}} {{ctx.Locale.Tr "repo.settings.public_access"}} diff --git a/templates/repo/settings/security.tmpl b/templates/repo/settings/security.tmpl new file mode 100644 index 0000000000..05f28cf8ac --- /dev/null +++ b/templates/repo/settings/security.tmpl @@ -0,0 +1,140 @@ +{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings security")}} +

+ {{svg "octicon-shield" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.settings.security"}} +

+
+

{{ctx.Locale.Tr "repo.settings.security_desc"}}

+ + {{if .AlertCounts}} +
+ {{range $sev, $count := .AlertCounts}} +
+ {{$sev}}: {{$count}} +
+ {{end}} +
+ {{end}} + +
+ {{.CsrfTokenHtml}} +
{{ctx.Locale.Tr "repo.settings.security_scanners"}}
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +
+

{{ctx.Locale.Tr "repo.settings.security_block_on_push_help"}}

+
+ +
+
+ +

+ {{ctx.Locale.Tr "repo.settings.security_alerts"}} +
+ {{.CsrfTokenHtml}} + +
+

+
+ {{if .SecurityAlerts}} + + + + + + + + + + + + + {{range .SecurityAlerts}} + + + + + + + + + {{end}} + +
{{ctx.Locale.Tr "repo.settings.security_severity"}}{{ctx.Locale.Tr "repo.settings.security_scanner_type"}}{{ctx.Locale.Tr "repo.settings.security_finding"}}{{ctx.Locale.Tr "repo.settings.security_file"}}{{ctx.Locale.Tr "repo.settings.security_status"}}
+ + {{.Severity}} + + {{.Scanner}} + {{.Title}} + {{if .Description}}
{{.Description}}{{end}} +
+ {{if .FilePath}} + {{.FilePath}}{{if .LineNumber}}:{{.LineNumber}}{{end}} + {{end}} + + {{if eq .Status "active"}} + Active + {{else if eq .Status "resolved"}} + Resolved + {{else}} + Dismissed + {{end}} + + {{if eq .Status "active"}} +
+ {{$.CsrfTokenHtml}} + + +
+
+ {{$.CsrfTokenHtml}} + + +
+ {{end}} +
+ {{else}} +
+

{{ctx.Locale.Tr "repo.settings.security_no_alerts"}}

+
+ {{end}} +
+{{template "repo/settings/layout_footer" .}}