feat(security): add Security tab to repo navigation #541

Merged
jmiller merged 1 commits from feat/508-security-scanner into dev 2026-06-06 21:36:42 +00:00
5 changed files with 204 additions and 0 deletions
+1
View File
@@ -1969,6 +1969,7 @@
"repo.signing.wont_sign.approved": "The merge will not be signed as the PR is not approved.",
"repo.ext_wiki": "Access to External Wiki",
"repo.ext_wiki.desc": "Link to an external wiki.",
"repo.security": "Security",
"repo.wiki": "Wiki",
"repo.wiki.welcome": "Welcome to the Wiki.",
"repo.wiki.welcome_desc": "The wiki lets you write and share documentation with collaborators.",
+88
View File
@@ -0,0 +1,88 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package repo
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 tplRepoSecurity templates.TplName = "repo/security"
// Security renders the repo-level security tab showing alerts and scan controls.
func Security(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.security")
ctx.Data["PageIsSecurity"] = 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, tplRepoSecurity)
}
// SecurityScanNow triggers an immediate scan from the security tab.
func SecurityScanNow(ctx *context.Context) {
commit := ctx.Repo.Commit
if commit == nil {
ctx.Flash.Error("No commits found")
ctx.Redirect(ctx.Repo.RepoLink + "/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 + "/security")
}
// SecurityAlertUpdate changes alert status from the security tab.
func SecurityAlertUpdateTab(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 + "/security")
}
+7
View File
@@ -1677,6 +1677,13 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
})
// end "/{username}/{reponame}/wiki"
m.Group("/{username}/{reponame}/security", func() {
m.Get("", repo.Security)
m.Post("/scan", reqRepoAdmin, repo.SecurityScanNow)
m.Post("/alert/{id}", reqRepoAdmin, repo.SecurityAlertUpdateTab)
}, reqSignIn, context.RepoAssignment, reqRepoAdmin)
// end "/{username}/{reponame}/security"
m.Group("/{username}/{reponame}/activity", func() {
// activity has its own permission checks
m.Get("", repo.Activity)
+9
View File
@@ -122,6 +122,15 @@
</a>
{{end}}
{{if and .Permission.IsAdmin .IsSigned}}
<a class="{{if .PageIsSecurity}}active {{end}}item" href="{{.RepoLink}}/security">
{{svg "octicon-shield"}} {{ctx.Locale.Tr "repo.security"}}
{{if .SecurityAlertCount}}
<span class="ui small label red">{{CountFmt .SecurityAlertCount}}</span>
{{end}}
</a>
{{end}}
{{if .Permission.CanRead ctx.Consts.RepoUnitTypePackages}}
<a href="{{.RepoLink}}/packages" class="{{if .IsPackagesPage}}active {{end}}item">
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
+99
View File
@@ -0,0 +1,99 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository security">
{{template "repo/header" .}}
<div class="ui container">
<div class="tw-flex tw-justify-between tw-items-center tw-mb-4">
<h2>{{svg "octicon-shield" 20 "tw-mr-2"}}{{ctx.Locale.Tr "repo.security"}}</h2>
{{if .Permission.IsAdmin}}
<div class="tw-flex tw-gap-2">
<form method="post" action="{{.RepoLink}}/security/scan" class="tw-inline">
{{.CsrfTokenHtml}}
<button class="ui small primary button" type="submit">{{svg "octicon-sync" 14}} {{ctx.Locale.Tr "repo.settings.security_scan_now"}}</button>
</form>
<a class="ui small button" href="{{.RepoLink}}/settings/security">{{svg "octicon-gear" 14}} Settings</a>
</div>
{{end}}
</div>
{{if .AlertCounts}}
<div class="tw-flex tw-gap-3 tw-mb-4">
{{range $sev, $count := .AlertCounts}}
<div class="ui {{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}}
{{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>
{{if .Permission.IsAdmin}}<th></th>{{end}}
</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}}
<a href="{{$.RepoLink}}/src/branch/{{$.BranchName}}/{{.FilePath}}{{if .LineNumber}}#L{{.LineNumber}}{{end}}">
<code class="tw-text-xs">{{.FilePath}}{{if .LineNumber}}:{{.LineNumber}}{{end}}</code>
</a>
{{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>
{{if $.Permission.IsAdmin}}
<td class="tw-text-right">
{{if eq .Status "active"}}
<form method="post" action="{{$.RepoLink}}/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}}/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>
{{end}}
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="ui segment">
<div class="empty-placeholder">
<p>{{svg "octicon-shield-check" 48}}</p>
<p>{{ctx.Locale.Tr "repo.settings.security_no_alerts"}}</p>
</div>
</div>
{{end}}
</div>
</div>
{{template "base/footer" .}}