From 7b334f94c0855f6fb8331d4eb58eef836b4b1383 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:14:46 -0500 Subject: [PATCH 1/3] feat: security scanning API endpoints + pre-receive hook blocking (#692) Add REST API for security alerts (list, get, update status, trigger scan) and scanner config (get, update). Wire block_on_push into the pre-receive hook so pushes containing detected secrets are rejected with details. Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- CHANGELOG.md | 2 + routers/api/v1/repo/security.go | 214 ++++++++++++++++++++++++++++ routers/private/hook_pre_receive.go | 18 +++ services/security/orchestrator.go | 21 +++ 4 files changed, 255 insertions(+) create mode 100644 routers/api/v1/repo/security.go 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/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..e7ccff06e8 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,23 @@ 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 { + 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) { -- 2.52.0 From 84df5d79324388524e780e876f393cf3900957a9 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:33:29 -0500 Subject: [PATCH 2/3] feat: register security scanning API routes in router Adds /repos/{owner}/{repo}/security/* route group for security alert management, scanning, and configuration endpoints. Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- routers/api/v1/api.go | 8 ++++++++ 1 file changed, 8 insertions(+) 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) -- 2.52.0 From 9a4aa0fafb18a109ea2e30f46d77bc6bfddc5021 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 02:35:50 -0500 Subject: [PATCH 3/3] fix: log error when pre-receive secret scan cannot read commit Previously, GetCommit failures were silently swallowed, allowing pushes to proceed without scanning. Now logs the error so admins can diagnose issues while still allowing the push. Claude-Session: https://claude.ai/code/session_011AAFzotGMf3ayvXhEmStCd --- routers/private/hook_pre_receive.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index e7ccff06e8..0da5d3a65b 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -154,7 +154,9 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r if newCommitID != objectFormat.EmptyObjectID().String() { newCommit, err := gitRepo.GetCommit(newCommitID) - if err == nil { + 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 { -- 2.52.0