first commit

This commit is contained in:
Tomas Dvorak
2026-02-22 10:42:17 +01:00
commit 55885a0e8f
239 changed files with 103690 additions and 0 deletions
@@ -0,0 +1,410 @@
package analyzers
import (
"context"
"fmt"
"go/ast"
"go/parser"
"go/token"
"regexp"
"strings"
"github.com/yourorg/devour/internal/quality"
)
type SecurityDetector struct {
*quality.BaseDetector
patterns []SecurityPattern
}
type SecurityPattern struct {
Name string
Description string
Pattern *regexp.Regexp
Severity quality.Severity
Score int
}
func NewSecurityDetector(finder quality.FileFinder) *SecurityDetector {
d := &SecurityDetector{
BaseDetector: quality.NewBaseDetector("security", quality.SeverityT3, finder),
patterns: []SecurityPattern{
{
Name: "hardcoded_password",
Description: "Hardcoded password or secret detected",
Pattern: regexp.MustCompile(`(?i)(password|passwd|pwd|secret|api_key|apikey|token)\s*[:=]\s*["'][^"']+["']`),
Severity: quality.SeverityT4,
Score: 30,
},
{
Name: "sql_injection_risk",
Description: "Potential SQL injection - string concatenation in query",
Pattern: regexp.MustCompile(`fmt\.Sprintf.*SELECT|fmt\.Sprintf.*INSERT|fmt\.Sprintf.*UPDATE|fmt\.Sprintf.*DELETE`),
Severity: quality.SeverityT4,
Score: 25,
},
{
Name: "unsafe_sql_exec",
Description: "Direct string interpolation in SQL execution",
Pattern: regexp.MustCompile(`db\.(Exec|Query).*\+|db\.(Exec|Query).*fmt\.Sprintf`),
Severity: quality.SeverityT4,
Score: 25,
},
{
Name: "weak_random",
Description: "Using math/rand for security-sensitive operations",
Pattern: regexp.MustCompile(`math/rand.*token|math/rand.*password|math/rand.*secret|math/rand.*key`),
Severity: quality.SeverityT3,
Score: 15,
},
{
Name: "todo_security",
Description: "TODO/FIXME related to security",
Pattern: regexp.MustCompile(`(?i)(TODO|FIXME|XXX).*security|(?i)(TODO|FIXME|XXX).*auth|(?i)(TODO|FIXME|XXX).*password`),
Severity: quality.SeverityT2,
Score: 5,
},
{
Name: "os_exec_shell",
Description: "Command execution with potential shell injection",
Pattern: regexp.MustCompile(`exec\.Command.*sh.*-c|exec\.Command.*bash.*-c`),
Severity: quality.SeverityT4,
Score: 30,
},
},
}
return d
}
func (d *SecurityDetector) Name() string {
return "security"
}
func (d *SecurityDetector) Severity() quality.Severity {
return quality.SeverityT3
}
func (d *SecurityDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
files, err := d.FindFiles(path, "go")
if err != nil {
return nil, err
}
var findings []quality.Finding
for _, file := range files {
fileFindings, err := d.analyzeFile(file)
if err != nil {
continue
}
findings = append(findings, fileFindings...)
}
return findings, nil
}
func (d *SecurityDetector) analyzeFile(filePath string) ([]quality.Finding, error) {
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
if err != nil {
return nil, err
}
var findings []quality.Finding
ast.Inspect(node, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.CallExpr:
d.checkCallExpr(x, fset, filePath, &findings)
case *ast.AssignStmt:
d.checkAssignStmt(x, fset, filePath, &findings)
case *ast.ValueSpec:
d.checkValueSpec(x, fset, filePath, &findings)
}
return true
})
d.checkComments(node, fset, filePath, &findings)
return findings, nil
}
func (d *SecurityDetector) checkCallExpr(expr *ast.CallExpr, fset *token.FileSet, file string, findings *[]quality.Finding) {
exprStr := d.nodeToString(expr)
pos := fset.Position(expr.Pos())
for _, pattern := range d.patterns {
if pattern.Pattern.MatchString(exprStr) {
finding := quality.Finding{
ID: fmt.Sprintf("security::%s::%d", file, pos.Line),
Type: "security",
Title: pattern.Name,
Description: pattern.Description,
File: file,
Line: pos.Line,
Severity: pattern.Severity,
Score: pattern.Score,
Status: quality.StatusOpen,
Metadata: map[string]string{
"pattern": pattern.Name,
"match": exprStr,
"severity": fmt.Sprintf("%d", pattern.Severity),
},
}
*findings = append(*findings, finding)
break
}
}
}
func (d *SecurityDetector) checkAssignStmt(stmt *ast.AssignStmt, fset *token.FileSet, file string, findings *[]quality.Finding) {
for _, expr := range stmt.Lhs {
if ident, ok := expr.(*ast.Ident); ok {
if strings.Contains(strings.ToLower(ident.Name), "password") ||
strings.Contains(strings.ToLower(ident.Name), "secret") ||
strings.Contains(strings.ToLower(ident.Name), "token") {
for _, val := range stmt.Rhs {
if basicLit, ok := val.(*ast.BasicLit); ok && basicLit.Kind == token.STRING {
pos := fset.Position(stmt.Pos())
finding := quality.Finding{
ID: fmt.Sprintf("security::%s::%d", file, pos.Line),
Type: "security",
Title: "hardcoded_credential",
Description: fmt.Sprintf("Hardcoded credential in variable '%s'", ident.Name),
File: file,
Line: pos.Line,
Severity: quality.SeverityT4,
Score: 30,
Status: quality.StatusOpen,
Metadata: map[string]string{
"variable": ident.Name,
},
}
*findings = append(*findings, finding)
}
}
}
}
}
}
func (d *SecurityDetector) checkValueSpec(spec *ast.ValueSpec, fset *token.FileSet, file string, findings *[]quality.Finding) {
for i, name := range spec.Names {
lowerName := strings.ToLower(name.Name)
if strings.Contains(lowerName, "password") ||
strings.Contains(lowerName, "secret") ||
strings.Contains(lowerName, "apikey") ||
strings.Contains(lowerName, "token") {
if len(spec.Values) > i {
if basicLit, ok := spec.Values[i].(*ast.BasicLit); ok && basicLit.Kind == token.STRING {
pos := fset.Position(spec.Pos())
finding := quality.Finding{
ID: fmt.Sprintf("security::%s::%d", file, pos.Line),
Type: "security",
Title: "hardcoded_credential",
Description: fmt.Sprintf("Hardcoded credential in variable '%s'", name.Name),
File: file,
Line: pos.Line,
Severity: quality.SeverityT4,
Score: 30,
Status: quality.StatusOpen,
Metadata: map[string]string{
"variable": name.Name,
},
}
*findings = append(*findings, finding)
}
}
}
}
}
func (d *SecurityDetector) checkComments(node *ast.File, fset *token.FileSet, file string, findings *[]quality.Finding) {
for _, group := range node.Comments {
for _, comment := range group.List {
text := comment.Text
for _, pattern := range d.patterns {
if pattern.Pattern.MatchString(text) {
pos := fset.Position(comment.Pos())
finding := quality.Finding{
ID: fmt.Sprintf("security::%s::%d", file, pos.Line),
Type: "security",
Title: pattern.Name,
Description: pattern.Description,
File: file,
Line: pos.Line,
Severity: pattern.Severity,
Score: pattern.Score,
Status: quality.StatusOpen,
Metadata: map[string]string{
"pattern": pattern.Name,
"in_comment": "true",
},
}
*findings = append(*findings, finding)
break
}
}
}
}
}
func (d *SecurityDetector) nodeToString(node ast.Node) string {
var b strings.Builder
fmt.Fprint(&b, node)
return b.String()
}
type ComplexityASTDetector struct {
*quality.BaseDetector
maxComplexity int
maxNesting int
}
func NewComplexityASTDetector(finder quality.FileFinder) *ComplexityASTDetector {
return &ComplexityASTDetector{
BaseDetector: quality.NewBaseDetector("complexity_ast", quality.SeverityT2, finder),
maxComplexity: 15,
maxNesting: 4,
}
}
func (d *ComplexityASTDetector) Name() string {
return "complexity_ast"
}
func (d *ComplexityASTDetector) Severity() quality.Severity {
return quality.SeverityT2
}
func (d *ComplexityASTDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
files, err := d.FindFiles(path, "go")
if err != nil {
return nil, err
}
var findings []quality.Finding
for _, file := range files {
fileFindings, err := d.analyzeFile(file)
if err != nil {
continue
}
findings = append(findings, fileFindings...)
}
return findings, nil
}
func (d *ComplexityASTDetector) analyzeFile(filePath string) ([]quality.Finding, error) {
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, filePath, nil, 0)
if err != nil {
return nil, err
}
var findings []quality.Finding
for _, decl := range node.Decls {
if fn, ok := decl.(*ast.FuncDecl); ok {
complexity := d.calculateCyclomaticComplexity(fn)
nesting := d.calculateNestingDepth(fn)
if complexity > d.maxComplexity {
pos := fset.Position(fn.Pos())
finding := quality.Finding{
ID: fmt.Sprintf("complexity::%s::%s", filePath, fn.Name.Name),
Type: "complexity",
Title: fmt.Sprintf("High cyclomatic complexity in %s", fn.Name.Name),
Description: fmt.Sprintf("Function '%s' has cyclomatic complexity of %d (max: %d). Consider breaking it into smaller functions.", fn.Name.Name, complexity, d.maxComplexity),
File: filePath,
Line: pos.Line,
Severity: quality.SeverityT2,
Score: complexity - d.maxComplexity,
Status: quality.StatusOpen,
Metadata: map[string]string{
"function": fn.Name.Name,
"complexity": fmt.Sprintf("%d", complexity),
"max_complexity": fmt.Sprintf("%d", d.maxComplexity),
},
}
findings = append(findings, finding)
}
if nesting > d.maxNesting {
pos := fset.Position(fn.Pos())
finding := quality.Finding{
ID: fmt.Sprintf("nesting::%s::%s", filePath, fn.Name.Name),
Type: "complexity",
Title: fmt.Sprintf("Deep nesting in %s", fn.Name.Name),
Description: fmt.Sprintf("Function '%s' has nesting depth of %d (max: %d). Consider extracting logic into helper functions.", fn.Name.Name, nesting, d.maxNesting),
File: filePath,
Line: pos.Line,
Severity: quality.SeverityT3,
Score: (nesting - d.maxNesting) * 3,
Status: quality.StatusOpen,
Metadata: map[string]string{
"function": fn.Name.Name,
"nesting": fmt.Sprintf("%d", nesting),
"max_nesting": fmt.Sprintf("%d", d.maxNesting),
},
}
findings = append(findings, finding)
}
}
}
return findings, nil
}
func (d *ComplexityASTDetector) calculateCyclomaticComplexity(fn *ast.FuncDecl) int {
complexity := 1
ast.Inspect(fn, func(n ast.Node) bool {
switch n.(type) {
case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt:
complexity++
case *ast.CaseClause:
complexity++
case *ast.BinaryExpr:
complexity++
}
return true
})
return complexity
}
func (d *ComplexityASTDetector) calculateNestingDepth(fn *ast.FuncDecl) int {
maxDepth := 0
var visit func(n ast.Node, depth int)
visit = func(n ast.Node, depth int) {
if depth > maxDepth {
maxDepth = depth
}
switch stmt := n.(type) {
case *ast.IfStmt:
visit(stmt.Body, depth+1)
if stmt.Else != nil {
visit(stmt.Else, depth+1)
}
case *ast.ForStmt:
visit(stmt.Body, depth+1)
case *ast.RangeStmt:
visit(stmt.Body, depth+1)
case *ast.SwitchStmt:
visit(stmt.Body, depth+1)
case *ast.SelectStmt:
visit(stmt.Body, depth+1)
case *ast.BlockStmt:
for _, s := range stmt.List {
visit(s, depth)
}
}
}
visit(fn.Body, 0)
return maxDepth
}