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
220 lines
8.2 KiB
Go
220 lines
8.2 KiB
Go
// 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
|
|
}
|