mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-03 20:13:03 +00:00
411 lines
12 KiB
Go
411 lines
12 KiB
Go
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
|
|
}
|