diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml index c834bf5f8b..21676fabcf 100644 --- a/.mokogitea/workflows/pr-check.yml +++ b/.mokogitea/workflows/pr-check.yml @@ -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 ( tag) or plain text fallback - PLATFORM=$(sed -n 's/.*\([^<]*\)<\/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' diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d1a98dd2..0aea9e753f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 8e887760d8..1aba6495e5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/routers/api/v1/repo/security.go b/routers/api/v1/repo/security.go index cba4ef0fef..5a28ca01ba 100644 --- a/routers/api/v1/repo/security.go +++ b/routers/api/v1/repo/security.go @@ -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, } } diff --git a/services/security/code_scanner.go b/services/security/code_scanner.go new file mode 100644 index 0000000000..61788173fe --- /dev/null +++ b/services/security/code_scanner.go @@ -0,0 +1,335 @@ +// Copyright 2026 Moko Consulting +// 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, "