blob: a32efc31658d61aeb2ceed177fcc2f56d4b919b6 [file] [log] [blame]
// Copyright 2020 The Cockroach Authors.
// SPDX-License-Identifier: Apache-2.0
package fmtsafe
import (
"errors"
"fmt"
"go/ast"
"go/token"
"go/types"
"strings"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
"golang.org/x/tools/go/types/typeutil"
)
// Doc documents this pass.
var Doc = `checks that log and error functions don't leak PII.
This linter checks the following:
- that the format string in Infof(), Errorf() and similar calls is a
constant string.
This check is essential for correctness because format strings
are assumed to be PII-free and always safe for reporting in
telemetry or PII-free logs.
- that the message strings in errors.New() and similar calls that
construct error objects is a constant string.
This check is provided to encourage hygiene: errors
constructed using non-constant strings are better constructed using
a formatting function instead, which makes the construction more
readable and encourage the provision of PII-free reportable details.
It is possible for a call site *in a test file* to opt the format/message
string out of the linter using /* nolint:fmtsafe */ after the format
argument. This escape hatch is not available in non-test code.
`
// Analyzer defines this pass.
var Analyzer = &analysis.Analyzer{
Name: "fmtsafe",
Doc: Doc,
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: run,
}
func run(pass *analysis.Pass) (interface{}, error) {
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
// Our analyzer just wants to see function definitions
// and call points.
nodeFilter := []ast.Node{
(*ast.FuncDecl)(nil),
(*ast.CallExpr)(nil),
}
// fmtOrMsgStr, if non-nil, indicates an incoming
// format or message string in the argument list of the
// current function.
//
// The pointer is set at the beginning of every function declaration
// for a function recognized by this linter (= any of those listed
// in functions.go). In non-recognized function, it is set to nil to
// indicate there is no known format or message string.
var fmtOrMsgStr *types.Var
var enclosingFnName string
// Now traverse the ASTs. The preorder traversal visits each
// function declaration node before its body, so we always get to
// set fmtOrMsgStr before the call points inside the body are
// visited.
inspect.Preorder(nodeFilter, func(n ast.Node) {
// Catch-all for possible bugs in the linter code.
defer func() {
if r := recover(); r != nil {
if err, ok := r.(error); ok {
pass.Reportf(n.Pos(), "internal linter error: %v", err)
return
}
panic(r)
}
}()
if fd, ok := n.(*ast.FuncDecl); ok {
// This is the function declaration header. Obtain the formal
// parameter and the name of the function being defined.
// We use the name in subsequent error messages to provide
// more context, and to facilitate the definition
// of precise exceptions in lint_test.go.
enclosingFnName, fmtOrMsgStr = maybeGetConstStr(pass, fd)
return
}
// At a call site.
call := n.(*ast.CallExpr)
checkCallExpr(pass, enclosingFnName, call, fmtOrMsgStr)
})
return nil, nil
}
func maybeGetConstStr(
pass *analysis.Pass, fd *ast.FuncDecl,
) (enclosingFnName string, res *types.Var) {
if fd.Body == nil {
// No body. Since there won't be any callee, take
// an early return.
return "", nil
}
// What's the function being defined?
fn := pass.TypesInfo.Defs[fd.Name].(*types.Func)
if fn == nil {
return "", nil
}
fnName := stripVendor(fn.FullName())
var wantVariadic bool
var argIdx int
if requireConstFmt[fnName] {
// Expect a variadic function and the format parameter
// next-to-last in the parameter list.
wantVariadic = true
argIdx = -2
} else if requireConstMsg[fnName] {
// Expect a non-variadic function and the format parameter last in
// the parameter list.
wantVariadic = false
argIdx = -1
} else {
// Not a recognized function. Bail.
return fn.Name(), nil
}
sig := fn.Type().(*types.Signature)
if sig.Variadic() != wantVariadic {
panic(fmt.Errorf("expected variadic %v, got %v", wantVariadic, sig.Variadic()))
}
params := sig.Params()
nparams := params.Len()
// Is message or format param a string?
if nparams+argIdx < 0 {
panic(errors.New("not enough arguments"))
}
if p := params.At(nparams + argIdx); p.Type() == types.Typ[types.String] {
// Found it!
return fn.Name(), p
}
return fn.Name(), nil
}
func checkCallExpr(pass *analysis.Pass, enclosingFnName string, call *ast.CallExpr, fv *types.Var) {
// What's the function being called?
cfn := typeutil.Callee(pass.TypesInfo, call)
if cfn == nil {
// Not a call to a statically identified function.
// We can't lint.
return
}
fn, ok := cfn.(*types.Func)
if !ok {
// Not a function with a name. We can't lint either.
return
}
// What's the full name of the callee? This includes the package
// path and, for methods, the type of the supporting object.
fnName := stripVendor(fn.FullName())
var wantVariadic bool
var argIdx int
var argType string
// Do the analysis of the callee.
if requireConstFmt[fnName] {
// Expect a variadic function and the format parameter
// next-to-last in the parameter list.
wantVariadic = true
argIdx = -2
argType = "format"
} else if requireConstMsg[fnName] {
// Expect a non-variadic function and the format parameter last in
// the parameter list.
wantVariadic = false
argIdx = -1
argType = "message"
} else {
// Not a recognized function. Bail.
return
}
typ := pass.TypesInfo.Types[call.Fun].Type
if typ == nil {
panic(errors.New("can't find function type"))
}
sig, ok := typ.(*types.Signature)
if !ok {
panic(errors.New("can't derive signature"))
}
if sig.Variadic() != wantVariadic {
panic(fmt.Errorf("expected variadic %v, got %v", wantVariadic, sig.Variadic()))
}
idx := sig.Params().Len() + argIdx
if idx < 0 {
panic(errors.New("not enough parameters"))
}
lit := pass.TypesInfo.Types[call.Args[idx]].Value
if lit != nil {
// A literal or constant! All is well.
return
}
// Not a constant. If it's a variable and the variable
// refers to the incoming format/message from the arg list,
// tolerate that.
if fv != nil {
if id, ok := call.Args[idx].(*ast.Ident); ok {
if pass.TypesInfo.ObjectOf(id) == fv {
// Same arg as incoming. All good.
return
}
}
}
// If the argument is opting out of the linter with a special
// comment, tolerate that.
if hasNoLintComment(pass, call, idx) {
return
}
pass.Reportf(call.Lparen, escNl("%s(): %s argument is not a constant expression"+Tip),
enclosingFnName, argType)
}
// Tip is exported for use in tests.
var Tip = `
Tip: use YourFuncf("descriptive prefix %%s", ...) or list new formatting wrappers in pkg/testutils/lint/passes/fmtsafe/functions.go.`
func hasNoLintComment(pass *analysis.Pass, call *ast.CallExpr, idx int) bool {
fPos, f := findContainingFile(pass, call)
if !strings.HasSuffix(fPos.Name(), "_test.go") {
// The nolint: escape hatch is only supported in test files.
return false
}
startPos := call.Args[idx].End()
endPos := call.Rparen
if idx < len(call.Args)-1 {
endPos = call.Args[idx+1].Pos()
}
for _, cg := range f.Comments {
if cg.Pos() > endPos || cg.End() < startPos {
continue
}
for _, c := range cg.List {
if strings.Contains(c.Text, "nolint:fmtsafe") {
return true
}
}
}
return false
}
func findContainingFile(pass *analysis.Pass, n ast.Node) (*token.File, *ast.File) {
fPos := pass.Fset.File(n.Pos())
for _, f := range pass.Files {
if pass.Fset.File(f.Pos()) == fPos {
return fPos, f
}
}
panic(fmt.Errorf("cannot file file for %v", n))
}
func stripVendor(s string) string {
if i := strings.Index(s, "/vendor/"); i != -1 {
s = s[i+len("/vendor/"):]
}
return s
}
func escNl(msg string) string {
return strings.ReplaceAll(msg, "\n", "\\n++")
}