blob: a32efc31658d61aeb2ceed177fcc2f56d4b919b6 [file] [log] [blame]
Tim Windelschmidt1f4590b2025-07-29 23:05:36 +02001// Copyright 2020 The Cockroach Authors.
2// SPDX-License-Identifier: Apache-2.0
3
4package fmtsafe
5
6import (
7 "errors"
8 "fmt"
9 "go/ast"
10 "go/token"
11 "go/types"
12 "strings"
13
14 "golang.org/x/tools/go/analysis"
15 "golang.org/x/tools/go/analysis/passes/inspect"
16 "golang.org/x/tools/go/ast/inspector"
17 "golang.org/x/tools/go/types/typeutil"
18)
19
20// Doc documents this pass.
21var Doc = `checks that log and error functions don't leak PII.
22
23This linter checks the following:
24
25- that the format string in Infof(), Errorf() and similar calls is a
26 constant string.
27
28 This check is essential for correctness because format strings
29 are assumed to be PII-free and always safe for reporting in
30 telemetry or PII-free logs.
31
32- that the message strings in errors.New() and similar calls that
33 construct error objects is a constant string.
34
35 This check is provided to encourage hygiene: errors
36 constructed using non-constant strings are better constructed using
37 a formatting function instead, which makes the construction more
38 readable and encourage the provision of PII-free reportable details.
39
40It is possible for a call site *in a test file* to opt the format/message
41string out of the linter using /* nolint:fmtsafe */ after the format
42argument. This escape hatch is not available in non-test code.
43`
44
45// Analyzer defines this pass.
46var Analyzer = &analysis.Analyzer{
47 Name: "fmtsafe",
48 Doc: Doc,
49 Requires: []*analysis.Analyzer{inspect.Analyzer},
50 Run: run,
51}
52
53func run(pass *analysis.Pass) (interface{}, error) {
54 inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
55
56 // Our analyzer just wants to see function definitions
57 // and call points.
58 nodeFilter := []ast.Node{
59 (*ast.FuncDecl)(nil),
60 (*ast.CallExpr)(nil),
61 }
62
63 // fmtOrMsgStr, if non-nil, indicates an incoming
64 // format or message string in the argument list of the
65 // current function.
66 //
67 // The pointer is set at the beginning of every function declaration
68 // for a function recognized by this linter (= any of those listed
69 // in functions.go). In non-recognized function, it is set to nil to
70 // indicate there is no known format or message string.
71 var fmtOrMsgStr *types.Var
72 var enclosingFnName string
73
74 // Now traverse the ASTs. The preorder traversal visits each
75 // function declaration node before its body, so we always get to
76 // set fmtOrMsgStr before the call points inside the body are
77 // visited.
78 inspect.Preorder(nodeFilter, func(n ast.Node) {
79 // Catch-all for possible bugs in the linter code.
80 defer func() {
81 if r := recover(); r != nil {
82 if err, ok := r.(error); ok {
83 pass.Reportf(n.Pos(), "internal linter error: %v", err)
84 return
85 }
86 panic(r)
87 }
88 }()
89
90 if fd, ok := n.(*ast.FuncDecl); ok {
91 // This is the function declaration header. Obtain the formal
92 // parameter and the name of the function being defined.
93 // We use the name in subsequent error messages to provide
94 // more context, and to facilitate the definition
95 // of precise exceptions in lint_test.go.
96 enclosingFnName, fmtOrMsgStr = maybeGetConstStr(pass, fd)
97 return
98 }
99 // At a call site.
100 call := n.(*ast.CallExpr)
101 checkCallExpr(pass, enclosingFnName, call, fmtOrMsgStr)
102 })
103 return nil, nil
104}
105
106func maybeGetConstStr(
107 pass *analysis.Pass, fd *ast.FuncDecl,
108) (enclosingFnName string, res *types.Var) {
109 if fd.Body == nil {
110 // No body. Since there won't be any callee, take
111 // an early return.
112 return "", nil
113 }
114
115 // What's the function being defined?
116 fn := pass.TypesInfo.Defs[fd.Name].(*types.Func)
117 if fn == nil {
118 return "", nil
119 }
120 fnName := stripVendor(fn.FullName())
121
122 var wantVariadic bool
123 var argIdx int
124
125 if requireConstFmt[fnName] {
126 // Expect a variadic function and the format parameter
127 // next-to-last in the parameter list.
128 wantVariadic = true
129 argIdx = -2
130 } else if requireConstMsg[fnName] {
131 // Expect a non-variadic function and the format parameter last in
132 // the parameter list.
133 wantVariadic = false
134 argIdx = -1
135 } else {
136 // Not a recognized function. Bail.
137 return fn.Name(), nil
138 }
139
140 sig := fn.Type().(*types.Signature)
141 if sig.Variadic() != wantVariadic {
142 panic(fmt.Errorf("expected variadic %v, got %v", wantVariadic, sig.Variadic()))
143 }
144
145 params := sig.Params()
146 nparams := params.Len()
147
148 // Is message or format param a string?
149 if nparams+argIdx < 0 {
150 panic(errors.New("not enough arguments"))
151 }
152 if p := params.At(nparams + argIdx); p.Type() == types.Typ[types.String] {
153 // Found it!
154 return fn.Name(), p
155 }
156 return fn.Name(), nil
157}
158
159func checkCallExpr(pass *analysis.Pass, enclosingFnName string, call *ast.CallExpr, fv *types.Var) {
160 // What's the function being called?
161 cfn := typeutil.Callee(pass.TypesInfo, call)
162 if cfn == nil {
163 // Not a call to a statically identified function.
164 // We can't lint.
165 return
166 }
167 fn, ok := cfn.(*types.Func)
168 if !ok {
169 // Not a function with a name. We can't lint either.
170 return
171 }
172
173 // What's the full name of the callee? This includes the package
174 // path and, for methods, the type of the supporting object.
175 fnName := stripVendor(fn.FullName())
176
177 var wantVariadic bool
178 var argIdx int
179 var argType string
180
181 // Do the analysis of the callee.
182 if requireConstFmt[fnName] {
183 // Expect a variadic function and the format parameter
184 // next-to-last in the parameter list.
185 wantVariadic = true
186 argIdx = -2
187 argType = "format"
188 } else if requireConstMsg[fnName] {
189 // Expect a non-variadic function and the format parameter last in
190 // the parameter list.
191 wantVariadic = false
192 argIdx = -1
193 argType = "message"
194 } else {
195 // Not a recognized function. Bail.
196 return
197 }
198
199 typ := pass.TypesInfo.Types[call.Fun].Type
200 if typ == nil {
201 panic(errors.New("can't find function type"))
202 }
203
204 sig, ok := typ.(*types.Signature)
205 if !ok {
206 panic(errors.New("can't derive signature"))
207 }
208 if sig.Variadic() != wantVariadic {
209 panic(fmt.Errorf("expected variadic %v, got %v", wantVariadic, sig.Variadic()))
210 }
211
212 idx := sig.Params().Len() + argIdx
213 if idx < 0 {
214 panic(errors.New("not enough parameters"))
215 }
216
217 lit := pass.TypesInfo.Types[call.Args[idx]].Value
218 if lit != nil {
219 // A literal or constant! All is well.
220 return
221 }
222
223 // Not a constant. If it's a variable and the variable
224 // refers to the incoming format/message from the arg list,
225 // tolerate that.
226 if fv != nil {
227 if id, ok := call.Args[idx].(*ast.Ident); ok {
228 if pass.TypesInfo.ObjectOf(id) == fv {
229 // Same arg as incoming. All good.
230 return
231 }
232 }
233 }
234
235 // If the argument is opting out of the linter with a special
236 // comment, tolerate that.
237 if hasNoLintComment(pass, call, idx) {
238 return
239 }
240
241 pass.Reportf(call.Lparen, escNl("%s(): %s argument is not a constant expression"+Tip),
242 enclosingFnName, argType)
243}
244
245// Tip is exported for use in tests.
246var Tip = `
247Tip: use YourFuncf("descriptive prefix %%s", ...) or list new formatting wrappers in pkg/testutils/lint/passes/fmtsafe/functions.go.`
248
249func hasNoLintComment(pass *analysis.Pass, call *ast.CallExpr, idx int) bool {
250 fPos, f := findContainingFile(pass, call)
251
252 if !strings.HasSuffix(fPos.Name(), "_test.go") {
253 // The nolint: escape hatch is only supported in test files.
254 return false
255 }
256
257 startPos := call.Args[idx].End()
258 endPos := call.Rparen
259 if idx < len(call.Args)-1 {
260 endPos = call.Args[idx+1].Pos()
261 }
262 for _, cg := range f.Comments {
263 if cg.Pos() > endPos || cg.End() < startPos {
264 continue
265 }
266 for _, c := range cg.List {
267 if strings.Contains(c.Text, "nolint:fmtsafe") {
268 return true
269 }
270 }
271 }
272 return false
273}
274
275func findContainingFile(pass *analysis.Pass, n ast.Node) (*token.File, *ast.File) {
276 fPos := pass.Fset.File(n.Pos())
277 for _, f := range pass.Files {
278 if pass.Fset.File(f.Pos()) == fPos {
279 return fPos, f
280 }
281 }
282 panic(fmt.Errorf("cannot file file for %v", n))
283}
284
285func stripVendor(s string) string {
286 if i := strings.Index(s, "/vendor/"); i != -1 {
287 s = s[i+len("/vendor/"):]
288 }
289 return s
290}
291
292func escNl(msg string) string {
293 return strings.ReplaceAll(msg, "\n", "\\n++")
294}