check-safety/errleak.go
Alex Dunmow cd88c808b0 initial: standalone check-safety module hoisted from CMS
Static safety/lint runner for the BlockNinja codebase. ~25 invariant
checks across Go and frontend sources. Was at git.dev.alexdunmow.com:block/ninja
in backend/cmd/check-safety/ until the 2026-06-06 consolidation moved
the BlockNinja repos under a shared ~/src/blockninja/ parent.

This repo is the standalone extraction:
- Own go.mod (git.dev.alexdunmow.com/block/check-safety, go 1.26.4)
- Vendored internal/{helpers,theme} from CMS (Go's internal/ rule
  blocks cross-module imports; vendoring is the workaround)
- CLI contract unchanged: `check-safety <target-dir> [--flags]`
- CMS Makefile shells into ../check-safety for safety-check /
  install-safety-checker targets

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 13:04:02 +08:00

129 lines
3.2 KiB
Go

package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"slices"
"strings"
)
type errLeakViolation struct {
file string
line int
snippet string
}
// checkErrLeak finds places where err.Error() is sent directly to HTTP clients.
// These leak internal details. Use writeInternalServerError/writeServerError instead.
func checkErrLeak(root string) []errLeakViolation {
var violations []errLeakViolation
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
if strings.Contains(path, "/vendor/") || strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
relPath, _ := filepath.Rel(root, path)
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// Pattern 1: http.Error(w, err.Error(), ...)
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == "http" && sel.Sel.Name == "Error" {
if len(call.Args) >= 2 && containsErrorCall(call.Args[1]) {
pos := fset.Position(call.Pos())
violations = append(violations, errLeakViolation{
file: relPath,
line: pos.Line,
snippet: "http.Error(w, err.Error(), ...) \u2014 leaks internal error",
})
}
}
}
// Pattern 2: fmt.Fprintf(w, ... err.Error() ...)
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == "fmt" {
if sel.Sel.Name == "Fprintf" || sel.Sel.Name == "Fprintln" {
if len(call.Args) > 1 {
if slices.ContainsFunc(call.Args[1:], containsErrorCall) {
pos := fset.Position(call.Pos())
violations = append(violations, errLeakViolation{
file: relPath,
line: pos.Line,
snippet: fmt.Sprintf("fmt.%s(w, ...err.Error()...) \u2014 leaks internal error", sel.Sel.Name),
})
}
}
}
}
}
// Pattern 3: w.Write([]byte(err.Error()))
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
if sel.Sel.Name == "Write" && len(call.Args) == 1 {
if containsErrorCall(call.Args[0]) {
pos := fset.Position(call.Pos())
violations = append(violations, errLeakViolation{
file: relPath,
line: pos.Line,
snippet: "w.Write([]byte(err.Error())) \u2014 leaks internal error",
})
}
}
}
return true
})
return nil
}); err != nil {
violations = append(violations, errLeakViolation{
file: filepath.ToSlash(root),
line: 0,
snippet: err.Error(),
})
}
return violations
}
// containsErrorCall checks if an expression contains a call to .Error() on any receiver identifier.
func containsErrorCall(expr ast.Expr) bool {
found := false
ast.Inspect(expr, func(n ast.Node) bool {
if found {
return false
}
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Error" {
return true
}
if _, ok := sel.X.(*ast.Ident); ok {
found = true
}
return true
})
return found
}