feat: security scanning API + pre-receive hook blocking (#692) #713

Merged
jmiller merged 3 commits from feature/secret-scanning-clean into dev 2026-06-28 08:46:04 +00:00
5 changed files with 265 additions and 0 deletions
+2
View File
@@ -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)
+8
View File
@@ -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)
+214
View File
@@ -0,0 +1,214 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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,
}
}
+20
View File
@@ -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
+21
View File
@@ -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) {