feat(security): built-in security scanning platform (#508) #540
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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" .}}
|
||||
Reference in New Issue
Block a user