feat: code security scanner with OWASP pattern detection (#552) #716

Merged
jmiller merged 7 commits from feature/code-scanner into dev 2026-06-28 16:08:31 +00:00
6 changed files with 371 additions and 13 deletions
+19 -9
View File
@@ -47,15 +47,15 @@ jobs:
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
if [ "$BASE" != "main" ] && [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
REASON="Fix branches must target 'main' or 'dev', not '${BASE}'"
fi
;;
patch/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
if [ "$BASE" != "main" ] && [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ]; then
ALLOWED=false
REASON="Patch branches must target 'dev' or 'rc', not '${BASE}'"
REASON="Patch branches must target 'main', 'dev', or 'rc', not '${BASE}'"
fi
;;
hotfix/*)
@@ -86,10 +86,11 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`main\` or \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`patch/*\` → \`main\`, \`dev\`, or \`rc\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
@@ -146,12 +147,21 @@ jobs:
- name: Detect platform
id: platform
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
REPO: ${{ github.repository }}
run: |
# Read platform from XML manifest (<platform> tag) or plain text fallback
PLATFORM=$(sed -n 's/.*<platform>\([^<]*\)<\/platform>.*/\1/p' .mokogitea/manifest.xml 2>/dev/null | head -1)
[ -z "$PLATFORM" ] && PLATFORM=$(cat .mokogitea/manifest.xml 2>/dev/null | tr -d '[:space:]')
# Query metadata API for platform (manifest.xml is deprecated)
PLATFORM=""
if [ -n "$MOKOGITEA_TOKEN" ]; then
PLATFORM=$(curl -sf -H "Authorization: token ${MOKOGITEA_TOKEN}" \
"${MOKOGITEA_URL}/api/v1/repos/${REPO}/metadata" 2>/dev/null \
| sed -n 's/.*"platform"\s*:\s*"\([^"]*\)".*/\1/p' | head -1) || true
fi
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Detected platform: $PLATFORM"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
+2
View File
@@ -3,6 +3,7 @@
## [Unreleased]
### Added
- Code security scanner: pattern-based detection of SQL injection, XSS, command injection, path traversal, insecure deserialization, hardcoded credentials, and weak cryptography across Go/PHP/Python/JS/TS (#552)
- Cascade merge: auto-create PRs to downstream branches after merge with configurable rules per repo (#460)
- Issue status presets: 4 built-in templates (default, software-development, support-tickets, bug-tracking) with API + web UI (#507)
- Cross-org status migration: copy status definitions from one org to another via API (#507)
@@ -56,6 +57,7 @@
- Cherry-pick upstream v1.26.4: walk git log context error handling — regression fix (#38185)
### Fixed
- PR check: platform detection now queries metadata API instead of removed manifest.xml
- Cherry-pick upstream v1.26.2: handle empty pull request files view to allow reviews (#37783)
- Cherry-pick upstream v1.26.2: fix "run as root" check with snap container detection (#37622)
- Cherry-pick upstream: ack re-sent UpdateLog finalize idempotently (#37885)
+6 -2
View File
@@ -1,6 +1,6 @@
# MokoGitea
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, org metadata, CI standardization, and project board API.
Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, cascade merge, security scanning, org metadata, CI standardization, and project board API.
![Language](https://img.shields.io/badge/Go-00ADD8?style=flat-square&logo=go&logoColor=white) ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-green?style=flat-square)
@@ -11,8 +11,12 @@ Custom Gitea fork with enhanced wiki system, DLID licensing, issue statuses, org
- **Wiki System** -- wikilinks, categories, backlinks, template transclusion, revision diffs, rename redirects, folder ACL, enhanced ToC, print view, ZIP export ([details](https://git.mokoconsulting.tech/MokoConsulting/.mokogitea/wiki/standards/Wiki-Features))
- **DLID Licensing** -- license management, entitlements, domain activations, ed25519-signed downloads
- **API Token Scope Editing** -- edit token scopes via API (PATCH) or web UI after creation
- **Issue Statuses** -- custom workflow statuses per org with required baseline protection
- **Issue Statuses** -- custom workflow statuses per org with required baseline protection, presets, cross-org migration
- **Cascade Merge** -- auto-create PRs to downstream branches after merge with configurable rules per repo
- **Security Scanning** -- secret detection (pre-receive blocking) + code analysis (SQL injection, XSS, command injection, path traversal, and more) with REST API for alerts, config, and on-demand scans
- **Default Org Teams** -- auto-create Developers, Reviewers, and CI/CD teams on org creation
- **Org Metadata** -- per-repo metadata API (public GET, admin PUT), platform detection for versioning
- **Branch Protection** -- delete allowlist for protected branches (per-user/team/deploy-key)
- **Project Board API** -- REST endpoints for project columns and cards
- **CI Infrastructure** -- reusable workflows, centralized ci-issue-reporter, standardized MOKOGITEA_TOKEN naming
- **Dev Deploy Gate** -- builds deploy to dev environment first, production checks dev health
+6
View File
@@ -34,6 +34,7 @@ type apiSecurityConfig struct {
BlockOnPush bool `json:"block_on_push"`
SecretScanner bool `json:"secret_scanner"`
DependScanner bool `json:"depend_scanner"`
CodeScanner bool `json:"code_scanner"`
}
// ListSecurityAlerts returns all security alerts for a repo.
@@ -153,6 +154,7 @@ func UpdateSecurityConfig(ctx *context.APIContext) {
BlockOnPush *bool `json:"block_on_push"`
SecretScanner *bool `json:"secret_scanner"`
DependScanner *bool `json:"depend_scanner"`
CodeScanner *bool `json:"code_scanner"`
}
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusUnprocessableEntity, err)
@@ -177,6 +179,9 @@ func UpdateSecurityConfig(ctx *context.APIContext) {
if req.DependScanner != nil {
cfg.DependScanner = *req.DependScanner
}
if req.CodeScanner != nil {
cfg.CodeScanner = *req.CodeScanner
}
if err := security_model.SaveScannerConfig(ctx, cfg); err != nil {
ctx.APIErrorInternal(err)
@@ -210,5 +215,6 @@ func toAPISecurityConfig(cfg *security_model.SecurityScannerConfig) *apiSecurity
BlockOnPush: cfg.BlockOnPush,
SecretScanner: cfg.SecretScanner,
DependScanner: cfg.DependScanner,
CodeScanner: cfg.CodeScanner,
}
}
+335
View File
@@ -0,0 +1,335 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package security
import (
"bufio"
"crypto/sha256"
"fmt"
"io"
"regexp"
"strings"
security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
)
// CodeRule defines a pattern to detect insecure code patterns.
type CodeRule struct {
ID string
Title string
Pattern *regexp.Regexp
Severity security_model.AlertSeverity
Description string
CWE string
Languages []string // file extensions this rule applies to (empty = all)
}
// DefaultCodeRules contains the built-in code security patterns.
var DefaultCodeRules = []CodeRule{
// ── SQL Injection (CWE-89) ──────────────────────────────────────────
{
ID: "sqli-string-concat-go", Title: "SQL Injection: String Concatenation (Go)",
Severity: security_model.SeverityHigh, CWE: "CWE-89",
Pattern: regexp.MustCompile(`(?i)(?:db|tx|sess|engine|orm)\.\s*(?:Exec|Query|QueryRow|Raw|Where|And|Or|Select|SQL)\s*\(\s*(?:"[^"]*\s*\+|fmt\.Sprintf\s*\(\s*"[^"]*(?:%s|%v|%d))`),
Description: "SQL query built with string concatenation or fmt.Sprintf — use parameterized queries instead",
Languages: []string{".go"},
},
{
ID: "sqli-string-concat-php", Title: "SQL Injection: String Concatenation (PHP)",
Severity: security_model.SeverityHigh, CWE: "CWE-89",
Pattern: regexp.MustCompile(`(?i)(?:mysql_query|mysqli_query|pg_query|\$(?:db|pdo|conn|dbo|mysqli)->(?:query|exec|prepare|execute))\s*\(\s*(?:["'][^"']*\.\s*\$|["'][^"']*\{\$)`),
Description: "SQL query built with variable interpolation — use prepared statements instead",
Languages: []string{".php"},
},
{
ID: "sqli-string-concat-py", Title: "SQL Injection: String Concatenation (Python)",
Severity: security_model.SeverityHigh, CWE: "CWE-89",
Pattern: regexp.MustCompile(`(?i)(?:cursor|conn|db|engine)\.(?:execute|executemany|raw)\s*\(\s*(?:f["']|["'][^"']*%s|["'][^"']*\s*\+|["'][^"']*\.format\()`),
Description: "SQL query built with f-strings, %-formatting, or concatenation — use parameterized queries",
Languages: []string{".py"},
},
{
ID: "sqli-string-concat-js", Title: "SQL Injection: String Concatenation (JS)",
Severity: security_model.SeverityHigh, CWE: "CWE-89",
Pattern: regexp.MustCompile("(?i)(?:query|execute|raw|knex\\.raw|sequelize\\.query)\\s*\\(\\s*(?:`[^`]*\\$\\{|[\"'][^\"']*\\s*\\+)"),
Description: "SQL query built with template literals or concatenation — use parameterized queries",
Languages: []string{".js", ".ts", ".mjs", ".cjs"},
},
// ── Cross-Site Scripting / XSS (CWE-79) ─────────────────────────────
{
ID: "xss-innerhtml", Title: "XSS: innerHTML Assignment",
Severity: security_model.SeverityMedium, CWE: "CWE-79",
Pattern: regexp.MustCompile(`(?i)\.innerHTML\s*[+]?=\s*(?:[^"'` + "`" + `]|$)`),
Description: "Direct innerHTML assignment with non-literal value — use textContent or sanitize first",
Languages: []string{".js", ".ts", ".jsx", ".tsx", ".vue", ".svelte"},
},
{
ID: "xss-document-write", Title: "XSS: document.write()",
Severity: security_model.SeverityMedium, CWE: "CWE-79",
Pattern: regexp.MustCompile(`(?i)document\.write(?:ln)?\s*\(`),
Description: "document.write() can execute injected scripts — use DOM APIs instead",
Languages: []string{".js", ".ts", ".jsx", ".tsx"},
},
{
ID: "xss-echo-unescaped-php", Title: "XSS: Unescaped Output (PHP)",
Severity: security_model.SeverityMedium, CWE: "CWE-79",
Pattern: regexp.MustCompile(`(?:echo|print)\s+\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\s*\[`),
Description: "Direct output of superglobal without escaping — use htmlspecialchars()",
Languages: []string{".php"},
},
{
ID: "xss-dangerously-set-html", Title: "XSS: dangerouslySetInnerHTML (React)",
Severity: security_model.SeverityMedium, CWE: "CWE-79",
Pattern: regexp.MustCompile(`dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:`),
Description: "dangerouslySetInnerHTML bypasses React's XSS protection — ensure input is sanitized",
Languages: []string{".jsx", ".tsx", ".js", ".ts"},
},
// ── Command Injection (CWE-78) ──────────────────────────────────────
{
ID: "cmdi-exec-go", Title: "Command Injection: exec.Command with Variable (Go)",
Severity: security_model.SeverityHigh, CWE: "CWE-78",
Pattern: regexp.MustCompile(`exec\.Command\s*\(\s*(?:fmt\.Sprintf|[a-zA-Z_]+\s*\+|[a-zA-Z_]+\s*,)`),
Description: "exec.Command with dynamic arguments — validate and sanitize inputs",
Languages: []string{".go"},
},
{
ID: "cmdi-shell-exec-php", Title: "Command Injection: Shell Execution (PHP)",
Severity: security_model.SeverityCritical, CWE: "CWE-78",
Pattern: regexp.MustCompile(`(?i)(?:system|exec|passthru|shell_exec|popen|proc_open)\s*\(\s*\$`),
Description: "Shell command with variable input — use escapeshellarg() or avoid shell execution",
Languages: []string{".php"},
},
{
ID: "cmdi-child-process-js", Title: "Command Injection: child_process (Node.js)",
Severity: security_model.SeverityHigh, CWE: "CWE-78",
Pattern: regexp.MustCompile("(?i)(?:child_process|exec|execSync|spawn|spawnSync)\\s*\\(\\s*(?:`[^`]*\\$\\{|[\"'][^\"']*\\s*\\+)"),
Description: "Shell command built with dynamic input — use array arguments or sanitize",
Languages: []string{".js", ".ts", ".mjs", ".cjs"},
},
{
ID: "cmdi-subprocess-py", Title: "Command Injection: subprocess with shell=True (Python)",
Severity: security_model.SeverityHigh, CWE: "CWE-78",
Pattern: regexp.MustCompile(`(?i)subprocess\.(?:call|run|Popen|check_output|check_call)\s*\([^)]*shell\s*=\s*True`),
Description: "subprocess with shell=True is vulnerable to injection — use shell=False with list args",
Languages: []string{".py"},
},
{
ID: "cmdi-os-system-py", Title: "Command Injection: os.system() (Python)",
Severity: security_model.SeverityHigh, CWE: "CWE-78",
Pattern: regexp.MustCompile(`(?i)os\.(?:system|popen)\s*\(\s*(?:f["']|[a-zA-Z_])`),
Description: "os.system/popen with dynamic input — use subprocess with shell=False instead",
Languages: []string{".py"},
},
// ── Path Traversal (CWE-22) ─────────────────────────────────────────
{
ID: "path-traversal-join", Title: "Path Traversal: Unsanitized Path Join",
Severity: security_model.SeverityMedium, CWE: "CWE-22",
Pattern: regexp.MustCompile(`(?i)(?:filepath\.Join|path\.join|os\.path\.join)\s*\([^)]*(?:ctx\.|req\.|r\.|request\.|params\.|query\.)`),
Description: "User input in file path join without sanitization — validate against directory traversal",
Languages: []string{".go", ".js", ".ts", ".py"},
},
{
ID: "path-traversal-open", Title: "Path Traversal: File Open with User Input",
Severity: security_model.SeverityMedium, CWE: "CWE-22",
Pattern: regexp.MustCompile(`(?i)(?:os\.Open|ioutil\.ReadFile|os\.ReadFile)\s*\(\s*(?:fmt\.Sprintf|[a-zA-Z_]+\s*\+)`),
Description: "File operation with dynamic path — sanitize and restrict to safe directories",
Languages: []string{".go"},
},
{
ID: "path-traversal-php", Title: "Path Traversal: File Include/Open (PHP)",
Severity: security_model.SeverityHigh, CWE: "CWE-22",
Pattern: regexp.MustCompile(`(?i)(?:include|require|include_once|require_once|file_get_contents|fopen|readfile)\s*\(\s*\$`),
Description: "File operation with variable path — validate path against traversal",
Languages: []string{".php"},
},
// ── Insecure Deserialization (CWE-502) ──────────────────────────────
{
ID: "deserialize-php", Title: "Insecure Deserialization: unserialize() (PHP)",
Severity: security_model.SeverityHigh, CWE: "CWE-502",
Pattern: regexp.MustCompile(`(?i)unserialize\s*\(\s*\$`),
Description: "unserialize() with untrusted data can lead to remote code execution",
Languages: []string{".php"},
},
{
ID: "deserialize-yaml-py", Title: "Insecure Deserialization: yaml.load() (Python)",
Severity: security_model.SeverityHigh, CWE: "CWE-502",
Pattern: regexp.MustCompile(`yaml\.load\s*\([^)]*(?:Loader\s*=\s*yaml\.(?:Unsafe|Full)Loader|[^)]*\)(?!\s*#))`),
Description: "yaml.load() without SafeLoader allows arbitrary code execution — use yaml.safe_load()",
Languages: []string{".py"},
},
// ── Hardcoded Credentials in Code (CWE-798) ─────────────────────────
{
ID: "hardcoded-password-assignment", Title: "Hardcoded Password in Source",
Severity: security_model.SeverityHigh, CWE: "CWE-798",
Pattern: regexp.MustCompile(`(?i)(?:password|passwd|pwd)\s*(?::=|=)\s*["'][^"']{8,}["']`),
Description: "Hardcoded password in source code — use environment variables or config",
Languages: []string{".go", ".py", ".js", ".ts", ".php", ".rb", ".java"},
},
// ── Weak Cryptography (CWE-327) ─────────────────────────────────────
{
ID: "weak-crypto-md5", Title: "Weak Cryptography: MD5",
Severity: security_model.SeverityLow, CWE: "CWE-327",
Pattern: regexp.MustCompile(`(?i)(?:md5\.New|hashlib\.md5|MD5\.Create|MessageDigest\.getInstance\s*\(\s*["']MD5)`),
Description: "MD5 is cryptographically broken — use SHA-256 or stronger for security purposes",
Languages: []string{".go", ".py", ".cs", ".java"},
},
{
ID: "weak-crypto-sha1", Title: "Weak Cryptography: SHA-1 for Security",
Severity: security_model.SeverityLow, CWE: "CWE-327",
Pattern: regexp.MustCompile(`(?i)(?:sha1\.New|hashlib\.sha1|SHA1\.Create|MessageDigest\.getInstance\s*\(\s*["']SHA-?1)`),
Description: "SHA-1 is deprecated for security — use SHA-256 or stronger",
Languages: []string{".go", ".py", ".cs", ".java"},
},
}
// Language extensions for file filtering.
var codeFileExtensions = map[string]bool{
".go": true, ".py": true, ".js": true, ".ts": true, ".jsx": true, ".tsx": true,
".php": true, ".rb": true, ".java": true, ".cs": true, ".rs": true,
".vue": true, ".svelte": true, ".mjs": true, ".cjs": true,
}
// CodeScanner implements the Scanner interface for code pattern analysis.
type CodeScanner struct {
Rules []CodeRule
}
// NewCodeScanner creates a scanner with default code analysis rules.
func NewCodeScanner() *CodeScanner {
return &CodeScanner{Rules: DefaultCodeRules}
}
func (s *CodeScanner) Type() security_model.ScannerType {
return security_model.ScannerCode
}
func (s *CodeScanner) ScanCommit(commit *git.Commit) ([]Finding, error) {
return s.ScanTree(commit)
}
func (s *CodeScanner) ScanTree(commit *git.Commit) ([]Finding, error) {
if commit == nil {
return nil, nil
}
entries, err := commit.ListEntriesRecursiveFast()
if err != nil {
return nil, fmt.Errorf("ListEntriesRecursiveFast: %w", err)
}
var findings []Finding
for _, entry := range entries {
if !entry.IsRegular() {
continue
}
path := entry.Name()
if shouldSkipFile(path) {
continue
}
ext := fileExtension(path)
if !codeFileExtensions[ext] {
continue
}
if entry.Blob().Size() > 1024*1024 {
continue
}
applicableRules := s.rulesForExtension(ext)
if len(applicableRules) == 0 {
continue
}
reader, err := entry.Blob().DataAsync()
if err != nil {
log.Trace("CodeScanner: skip %s: %v", path, err)
continue
}
fileFindings := s.scanReader(reader, path, commit.ID.String(), applicableRules)
reader.Close()
findings = append(findings, fileFindings...)
}
return findings, nil
}
func (s *CodeScanner) rulesForExtension(ext string) []CodeRule {
var rules []CodeRule
for _, rule := range s.Rules {
if len(rule.Languages) == 0 {
rules = append(rules, rule)
continue
}
for _, lang := range rule.Languages {
if lang == ext {
rules = append(rules, rule)
break
}
}
}
return rules
}
func (s *CodeScanner) scanReader(r io.Reader, filePath, commitSHA string, rules []CodeRule) []Finding {
var findings []Finding
scanner := bufio.NewScanner(r)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if isCommentLine(trimmed) {
continue
}
for _, rule := range rules {
if rule.Pattern.MatchString(line) {
fingerprint := fmt.Sprintf("%x", sha256.Sum256([]byte(rule.ID+":"+filePath+":"+line)))
findings = append(findings, Finding{
Scanner: security_model.ScannerCode,
Severity: rule.Severity,
RuleID: rule.ID,
Title: rule.Title,
Description: rule.Description + " [" + rule.CWE + "]",
FilePath: filePath,
LineNumber: lineNum,
CommitSHA: commitSHA,
Fingerprint: fingerprint[:32],
Metadata: fmt.Sprintf(`{"cwe":"%s"}`, rule.CWE),
})
break
}
}
}
return findings
}
func fileExtension(path string) string {
lower := strings.ToLower(path)
if i := strings.LastIndex(lower, "."); i >= 0 {
return lower[i:]
}
return ""
}
func isCommentLine(line string) bool {
return strings.HasPrefix(line, "//") ||
strings.HasPrefix(line, "#") ||
strings.HasPrefix(line, "*") ||
strings.HasPrefix(line, "/*") ||
strings.HasPrefix(line, "<!--")
}
+3 -2
View File
@@ -56,8 +56,9 @@ func ScanOnPush(ctx context.Context, repo *repo_model.Repository, commit *git.Co
if cfg.DependScanner {
scanners = append(scanners, NewDependencyScanner())
}
// Future scanners added here:
// if cfg.CodeScanner { scanners = append(scanners, NewCodeScanner()) }
if cfg.CodeScanner {
scanners = append(scanners, NewCodeScanner())
}
if len(scanners) == 0 {
return