// 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(`(?i)yaml\.load\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, "