-
Notifications
You must be signed in to change notification settings - Fork 3.8k
/
fmtsafe.go
301 lines (260 loc) · 8.13 KB
/
fmtsafe.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
// Copyright 2020 The Cockroach Authors.
//
// Use of this software is governed by the Business Source License
// included in the file licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0, included in the file
// licenses/APL.txt.
package fmtsafe
import (
"fmt"
"go/ast"
"go/token"
"go/types"
"strings"
"github.com/cockroachdb/errors"
"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(errors.Newf("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(errors.Newf("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++")
}