diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db6fd594a..485a0f6283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - Wiki full-text search: case-insensitive search across all wiki page titles and content (#550) - Wiki search API: GET /wiki/search?q=term with paginated JSON results (#550) - Metadata deploy fields: deploy_host, deploy_port, deploy_user, deploy_path, docker_image, docker_registry, container_name, health_url (#692) +- Security scanning API: REST endpoints for alerts, config, and on-demand scans (GET/PATCH /security/alerts, /security/config, POST /security/scan) (#692) +- Pre-receive hook secret blocking: push rejection when block_on_push enabled and secrets detected in commits (#692) - Metadata API partial updates: PUT /metadata now merges only sent fields instead of replacing all - Wiki revision diff: line-by-line diff view per commit in wiki page history (#667) - Wiki categories: YAML frontmatter `categories:` with category index page (#668) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5433f11f25..20139b15c7 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1262,6 +1262,14 @@ func Routes() *web.Router { }) m.Post("/priority", bind(api.UpdateBranchProtectionPriories{}), mustNotBeArchived, repo.UpdateBranchProtectionPriories) }, reqToken(), reqAdmin()) + m.Group("/security", func() { + m.Get("/alerts", repo.ListSecurityAlerts) + m.Get("/alerts/{id}", repo.GetSecurityAlert) + m.Patch("/alerts/{id}", reqToken(), reqAdmin(), repo.UpdateSecurityAlert) + m.Post("/scan", reqToken(), reqAdmin(), repo.TriggerSecurityScan) + m.Get("/config", repo.GetSecurityConfig) + m.Patch("/config", reqToken(), reqAdmin(), repo.UpdateSecurityConfig) + }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true)) m.Group("/tags", func() { m.Get("", repo.ListTags) m.Get("/*", repo.GetTag) diff --git a/routers/api/v1/repo/security.go b/routers/api/v1/repo/security.go new file mode 100644 index 0000000000..cba4ef0fef --- /dev/null +++ b/routers/api/v1/repo/security.go @@ -0,0 +1,214 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package repo + +import ( + "encoding/json" + "net/http" + "time" + + security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" + security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security" +) + +type apiSecurityAlert struct { + ID int64 `json:"id"` + Scanner string `json:"scanner"` + Severity string `json:"severity"` + Status string `json:"status"` + RuleID string `json:"rule_id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + FilePath string `json:"file_path,omitempty"` + LineNumber int `json:"line_number,omitempty"` + CommitSHA string `json:"commit_sha,omitempty"` + Fingerprint string `json:"fingerprint"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type apiSecurityConfig struct { + Enabled bool `json:"enabled"` + BlockOnPush bool `json:"block_on_push"` + SecretScanner bool `json:"secret_scanner"` + DependScanner bool `json:"depend_scanner"` +} + +// ListSecurityAlerts returns all security alerts for a repo. +func ListSecurityAlerts(ctx *context.APIContext) { + status := ctx.FormString("status") + repoID := ctx.Repo.Repository.ID + + var alerts []*security_model.SecurityAlert + var err error + + switch status { + case "", "active": + alerts, err = security_model.GetActiveAlerts(ctx, repoID) + default: + alerts, err = security_model.GetAllAlerts(ctx, repoID) + } + if err != nil { + ctx.APIErrorInternal(err) + return + } + + result := make([]*apiSecurityAlert, len(alerts)) + for i, a := range alerts { + result[i] = toAPISecurityAlert(a) + } + ctx.JSON(http.StatusOK, result) +} + +// GetSecurityAlert returns a single security alert. +func GetSecurityAlert(ctx *context.APIContext) { + id := ctx.PathParamInt64("id") + alert, err := security_model.GetAlertByID(ctx, id) + if err != nil { + ctx.APIErrorNotFound() + return + } + if alert.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + ctx.JSON(http.StatusOK, toAPISecurityAlert(alert)) +} + +// UpdateSecurityAlert changes the status of a security alert. +func UpdateSecurityAlert(ctx *context.APIContext) { + id := ctx.PathParamInt64("id") + alert, err := security_model.GetAlertByID(ctx, id) + if err != nil { + ctx.APIErrorNotFound() + return + } + if alert.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + + var req struct { + Status string `json:"status"` + } + if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + + status := security_model.AlertStatus(req.Status) + if status != security_model.AlertStatusResolved && status != security_model.AlertStatusDismissed { + ctx.APIError(http.StatusUnprocessableEntity, "status must be 'resolved' or 'dismissed'") + return + } + + if err := security_model.UpdateAlertStatus(ctx, id, status, ctx.Doer.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + alert, _ = security_model.GetAlertByID(ctx, id) + ctx.JSON(http.StatusOK, toAPISecurityAlert(alert)) +} + +// TriggerSecurityScan runs all enabled scanners against HEAD. +func TriggerSecurityScan(ctx *context.APIContext) { + commit := ctx.Repo.Commit + if commit == nil { + ctx.APIError(http.StatusBadRequest, "no commits in repository") + return + } + + security_service.ScanOnPush(ctx, ctx.Repo.Repository, commit) + + alerts, err := security_model.GetActiveAlerts(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + result := make([]*apiSecurityAlert, len(alerts)) + for i, a := range alerts { + result[i] = toAPISecurityAlert(a) + } + ctx.JSON(http.StatusOK, result) +} + +// GetSecurityConfig returns the scanner config for a repo. +func GetSecurityConfig(ctx *context.APIContext) { + cfg, err := security_model.GetScannerConfig(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, toAPISecurityConfig(cfg)) +} + +// UpdateSecurityConfig updates the scanner config for a repo. +func UpdateSecurityConfig(ctx *context.APIContext) { + var req struct { + Enabled *bool `json:"enabled"` + BlockOnPush *bool `json:"block_on_push"` + SecretScanner *bool `json:"secret_scanner"` + DependScanner *bool `json:"depend_scanner"` + } + if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + + cfg, err := security_model.GetScannerConfig(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + if req.Enabled != nil { + cfg.Enabled = *req.Enabled + } + if req.BlockOnPush != nil { + cfg.BlockOnPush = *req.BlockOnPush + } + if req.SecretScanner != nil { + cfg.SecretScanner = *req.SecretScanner + } + if req.DependScanner != nil { + cfg.DependScanner = *req.DependScanner + } + + if err := security_model.SaveScannerConfig(ctx, cfg); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, toAPISecurityConfig(cfg)) +} + +func toAPISecurityAlert(a *security_model.SecurityAlert) *apiSecurityAlert { + return &apiSecurityAlert{ + ID: a.ID, + Scanner: string(a.Scanner), + Severity: string(a.Severity), + Status: string(a.Status), + RuleID: a.RuleID, + Title: a.Title, + Description: a.Description, + FilePath: a.FilePath, + LineNumber: a.LineNumber, + CommitSHA: a.CommitSHA, + Fingerprint: a.Fingerprint, + CreatedAt: a.CreatedUnix.AsTime(), + UpdatedAt: a.UpdatedUnix.AsTime(), + } +} + +func toAPISecurityConfig(cfg *security_model.SecurityScannerConfig) *apiSecurityConfig { + return &apiSecurityConfig{ + Enabled: cfg.Enabled, + BlockOnPush: cfg.BlockOnPush, + SecretScanner: cfg.SecretScanner, + DependScanner: cfg.DependScanner, + } +} diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index 0dc1c9a9a0..0da5d3a65b 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -26,6 +26,7 @@ import ( "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/agit" gitea_context "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull" + security_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/security" ) type preReceiveContext struct { @@ -151,6 +152,25 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r gitRepo := ctx.Repo.GitRepo objectFormat := ctx.Repo.GetObjectFormat() + if newCommitID != objectFormat.EmptyObjectID().String() { + newCommit, err := gitRepo.GetCommit(newCommitID) + if err != nil { + log.Error("Secret scan: failed to get commit %s in %-v: %v", newCommitID[:12], repo, err) + } else { + if findings := security_service.ScanPushForSecrets(ctx, repo.ID, newCommit); len(findings) > 0 { + msg := fmt.Sprintf("Push rejected: %d secret(s) detected in commit %s", len(findings), newCommitID[:12]) + for _, f := range findings { + msg += fmt.Sprintf("\n - %s in %s:%d", f.Title, f.FilePath, f.LineNumber) + } + log.Warn("Secret scan blocked push to %s in %-v: %d findings", branchName, repo, len(findings)) + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: msg, + }) + return + } + } + } + defaultBranch := repo.DefaultBranch if ctx.opts.IsWiki && repo.DefaultWikiBranch != "" { defaultBranch = repo.DefaultWikiBranch diff --git a/services/security/orchestrator.go b/services/security/orchestrator.go index f4108d9a89..df68ec5a60 100644 --- a/services/security/orchestrator.go +++ b/services/security/orchestrator.go @@ -12,6 +12,27 @@ import ( "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" ) +// ScanPushForSecrets checks a commit for secrets and returns findings. +// Used by the pre-receive hook to block pushes containing secrets. +func ScanPushForSecrets(ctx context.Context, repoID int64, commit *git.Commit) []Finding { + cfg, err := security_model.GetScannerConfig(ctx, repoID) + if err != nil { + log.Error("ScanPushForSecrets: GetScannerConfig: %v", err) + return nil + } + if !cfg.Enabled || !cfg.BlockOnPush || !cfg.SecretScanner { + return nil + } + + scanner := NewSecretScanner() + findings, err := scanner.ScanCommit(commit) + if err != nil { + log.Error("ScanPushForSecrets: ScanCommit: %v", err) + return nil + } + return findings +} + // 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) {