fetch_ml/tools/fetchml-vet/analyzers/nobaredetector.go
Jeremie Fraeys 90ae9edfff
feat(verification): Custom linting tool (fetchml-vet) for structural invariants
Add golang.org/x/tools/go/analysis based linting tool:
- fetchml-vet: Custom go vet tool for security invariants

Add analyzers for critical security patterns:
- noBareDetector: Ensures CreateDetector always captures DetectionInfo
  (prevents silent metadata loss in GPU detection)
- manifestEnv: Validates functions returning Artifacts populate Environment
  (ensures reproducibility metadata capture)
- noInlineCredentials: Detects inline credential patterns in config structs
  (enforces environment variable references)
- hipaaComplete: Validates HIPAA mode configs have all required fields
  (structural check for compliance completeness)

Integration with make lint-custom:
- Builds bin/fetchml-vet from tools/fetchml-vet/cmd/fetchml-vet/
- Runs with: go vet -vettool=bin/fetchml-vet ./internal/...

Part of: V.4 custom linting from security plan
2026-02-23 19:44:00 -05:00

196 lines
5.1 KiB
Go

// Package analyzers provides custom Go analysis rules for FetchML code quality and security
package analyzers
import (
"go/ast"
"go/token"
"go/types"
"strings"
"golang.org/x/tools/go/analysis"
)
// NoBareDetectorAnalyzer flags calls to GPUDetectorFactory.CreateDetector() that
// silently discard DetectionInfo needed for manifest and audit log.
// Use CreateDetectorWithInfo() instead to capture both the detector and its metadata.
var NoBareDetectorAnalyzer = &analysis.Analyzer{
Name: "nobaredetector",
Doc: "flags bare CreateDetector calls that discard DetectionInfo",
Run: runNoBareDetector,
}
func runNoBareDetector(pass *analysis.Pass) (interface{}, error) {
for _, file := range pass.Files {
ast.Inspect(file, func(n ast.Node) bool {
// Look for call expressions
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// Check if it's a method call like obj.CreateDetector(...)
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
// Check if the method name is CreateDetector
if sel.Sel.Name != "CreateDetector" {
return true
}
// Check if the result is assigned (which is okay)
// If the parent is an assignment or short var decl, it's being captured
if isAssigned(pass, call) {
return true
}
// Check if the call is part of a selector chain that includes CreateDetectorWithInfo
// This is a heuristic - if the same object has CreateDetectorWithInfo called on it,
// we assume they're doing the right thing
if hasCreateDetectorWithInfo(pass, call, sel) {
return true
}
pass.Reportf(call.Pos(),
"bare CreateDetector() call detected - use CreateDetectorWithInfo() to capture DetectionInfo for manifest/audit")
return true
})
}
return nil, nil
}
// isAssigned checks if a call expression's result is being assigned to a variable
func isAssigned(pass *analysis.Pass, call *ast.CallExpr) bool {
// Walk up the AST to see if this call is the RHS of an assignment
// This is a simplified check - in a full implementation, we'd use pass.TypesInfo
// to track the parent node
pos := call.Pos()
for _, file := range pass.Files {
if file.Pos() <= pos && pos <= file.End() {
var found bool
ast.Inspect(file, func(n ast.Node) bool {
if n == nil {
return true
}
if n.Pos() == pos {
// Check if parent is an assignment or short var decl
return false // Stop searching
}
// Check for assignment statement containing this call
switch node := n.(type) {
case *ast.AssignStmt:
for _, rhs := range node.Rhs {
if rhs.Pos() == pos {
found = true
return false
}
}
case *ast.ValueSpec:
for _, val := range node.Values {
if val.Pos() == pos {
found = true
return false
}
}
}
return true
})
if found {
return true
}
}
}
return false
}
// hasCreateDetectorWithInfo checks if the same object that has CreateDetector called
// also has CreateDetectorWithInfo called somewhere in the function
func hasCreateDetectorWithInfo(pass *analysis.Pass, call *ast.CallExpr, sel *ast.SelectorExpr) bool {
// Get the object being called on (the receiver)
receiverObj := sel.X
// Find the containing function
var containingFunc *ast.FuncDecl
for _, file := range pass.Files {
if file.Pos() <= call.Pos() && call.Pos() <= file.End() {
ast.Inspect(file, func(n ast.Node) bool {
if fn, ok := n.(*ast.FuncDecl); ok {
if fn.Pos() <= call.Pos() && call.Pos() <= fn.End() {
containingFunc = fn
return false
}
}
return true
})
break
}
}
if containingFunc == nil {
return false
}
// Check if CreateDetectorWithInfo is called on the same object in the same function
found := false
ast.Inspect(containingFunc, func(n ast.Node) bool {
call2, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel2, ok := call2.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
if sel2.Sel.Name != "CreateDetectorWithInfo" {
return true
}
// Check if it's the same receiver object
if isSameObject(receiverObj, sel2.X) {
found = true
return false
}
return true
})
return found
}
// isSameObject checks if two AST expressions refer to the same object
func isSameObject(a, b ast.Expr) bool {
// Simple string comparison for identifiers and selectors
// In a full implementation, we'd use types.Object from type checking
aStr := exprToString(a)
bStr := exprToString(b)
return aStr == bStr
}
// exprToString converts an expression to a simple string representation
func exprToString(expr ast.Expr) string {
switch e := expr.(type) {
case *ast.Ident:
return e.Name
case *ast.SelectorExpr:
return exprToString(e.X) + "." + e.Sel.Name
default:
return ""
}
}
// getTypeName returns a simple type name string
func getTypeName(t types.Type) string {
if t == nil {
return ""
}
return strings.TrimPrefix(t.String(), "github.com/jfraeys/fetch_ml/")
}
// isWithinFunction checks if a position is within a function declaration
func isWithinFunction(fset *token.FileSet, pos token.Pos, fn *ast.FuncDecl) bool {
if fn == nil {
return false
}
return fn.Pos() <= pos && pos <= fn.End()
}