feat: security scanning API + pre-receive hook blocking (#692) #713
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user