fetch_ml/tools/fetchml-vet/analyzers/noinlinecredentials.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

120 lines
3 KiB
Go

package analyzers
import (
"go/ast"
"go/token"
"strings"
"golang.org/x/tools/go/analysis"
)
// NoInlineCredentialsAnalyzer flags struct literals of type worker.Config where
// sensitive fields (RedisPassword, SecretKey, AccessKey) are set to non-empty string
// literals instead of environment variable references. Credentials must not appear
// in source or config files.
var NoInlineCredentialsAnalyzer = &analysis.Analyzer{
Name: "noinlinecreds",
Doc: "flags inline credentials in Config struct literals",
Run: runNoInlineCredentials,
}
// sensitiveCredentialFields lists field names that should never have inline string literals
var sensitiveCredentialFields = []string{
"RedisPassword",
"SecretKey",
"AccessKey",
"Password",
"APIKey",
"Token",
"PrivateKey",
}
func runNoInlineCredentials(pass *analysis.Pass) (interface{}, error) {
for _, file := range pass.Files {
ast.Inspect(file, func(n ast.Node) bool {
// Look for composite literals (struct initialization)
composite, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
// Check if this is a Config type
typeInfo := pass.TypesInfo.TypeOf(composite)
if typeInfo == nil {
return true
}
typeStr := typeInfo.String()
// Match worker.Config or Config types
if !strings.Contains(typeStr, "Config") {
return true
}
// Check each field in the composite literal
for _, elt := range composite.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
key, ok := kv.Key.(*ast.Ident)
if !ok {
continue
}
// Check if this is a sensitive field
if !isSensitiveField(key.Name) {
continue
}
// Check if the value is a string literal (not env var or function call)
if isInlineStringLiteral(kv.Value) {
// Check if it's not empty (empty strings might be intentional)
if !isEmptyStringLiteral(kv.Value) {
pass.Reportf(kv.Value.Pos(),
"inline credential detected in field %s - use environment variables instead (e.g., os.Getenv)",
key.Name)
}
}
}
return true
})
}
return nil, nil
}
// isSensitiveField checks if a field name is in the sensitive credentials list
func isSensitiveField(name string) bool {
for _, sensitive := range sensitiveCredentialFields {
if name == sensitive {
return true
}
}
return false
}
// isInlineStringLiteral checks if an expression is a string literal (not env var ref)
func isInlineStringLiteral(expr ast.Expr) bool {
switch e := expr.(type) {
case *ast.BasicLit:
// String literal like "password123"
return e.Kind == token.STRING
case *ast.BinaryExpr:
// String concatenation like "prefix" + "suffix"
if e.Op == token.ADD {
return isInlineStringLiteral(e.X) || isInlineStringLiteral(e.Y)
}
}
return false
}
// isEmptyStringLiteral checks if an expression is an empty string literal
func isEmptyStringLiteral(expr ast.Expr) bool {
lit, ok := expr.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return false
}
// Remove quotes and check if empty
return lit.Value == `""` || lit.Value == "``"
}