/
rule.go
248 lines (206 loc) · 6.64 KB
/
rule.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
package rule
import (
"fmt"
"regexp"
"strings"
"github.com/get-woke/woke/pkg/util"
)
var ignoreRuleRegex = regexp.MustCompile(`wokeignore:rule=(\S+)`)
const wordBoundary = `\b`
// Rule is a linter rule
type Rule struct {
Name string `yaml:"name"`
Terms []string `yaml:"terms"`
Alternatives []string `yaml:"alternatives"`
Note string `yaml:"note"`
Severity Severity `yaml:"severity"`
Options Options `yaml:"options"`
re *regexp.Regexp
}
// FindMatchIndexes returns the start and end indexes for all rule findings for the text supplied.
func (r *Rule) FindMatchIndexes(text string) [][]int {
if r.Disabled() {
return [][]int(nil)
}
r.SetRegexp()
// Remove inline ignores from text to avoid matching against other rules
matches := r.re.FindAllStringSubmatchIndex(maskInlineIgnore(text), -1)
if matches == nil {
return [][]int(nil)
}
idx := [][]int{}
// Need to return a list of int pairs, which are the start and end index
// of all matches in all capture groups. For FindAllStringSubmatchIndex,
// Submatch 0 is the match of the entire expression, submatch 1 the match
// of the first parenthesized subexpression, and so on. We only care about Submatch 1+
for _, m := range matches {
if len(m) < 4 {
continue
}
// Right now, assume there's only one capture group.
// This should be updated to support more capture groups if necessary.
start := m[2]
end := m[3]
if start == -1 || end == -1 {
// something went wrong with the regex
continue
}
idx = append(idx, []int{start, end})
}
return idx
}
// SetRegexp populates the regex for matching this rule.
// This is meant to be idempotent, so calling it multiple times won't update the regex
func (r *Rule) SetRegexp() {
if r.re != nil {
return
}
r.setRegex()
}
// SetOptions sets new Options for the Rule and updates the regex.
func (r *Rule) SetOptions(o Options) {
r.Options = o
r.setRegex()
}
func (r *Rule) setRegex() {
group := strings.Join(escape(r.Terms), "|")
r.re = regexp.MustCompile(fmt.Sprintf(r.regexString(), group))
}
func (r *Rule) regexString() string {
regex := func(start, end string) string {
s := strings.Builder{}
s.WriteString("(?i)")
s.WriteString(start)
s.WriteString("(%s)")
s.WriteString(end)
return s.String()
}
if r.Options.WordBoundary {
return regex(wordBoundary, wordBoundary)
}
start := ""
end := ""
if r.Options.WordBoundaryStart {
start = wordBoundary
}
if r.Options.WordBoundaryEnd {
end = wordBoundary
}
return regex(start, end)
}
// Reason returns a human-readable reason for the rule finding
func (r *Rule) Reason(finding string) string {
// fall back to the rule name if no finding was found
// finding is mostly used for informational purposes
if len(finding) == 0 {
finding = r.Name
}
reason := new(strings.Builder)
reason.WriteString(util.MarkdownCodify(finding) + " may be insensitive, ")
if len(r.Alternatives) > 0 {
alt := make([]string, len(r.Alternatives))
for i, a := range r.Alternatives {
alt[i] = util.MarkdownCodify(a)
}
reason.WriteString(fmt.Sprintf("use %s instead", strings.Join(alt, ", ")))
} else {
reason.WriteString("try not to use it")
}
return reason.String()
}
func (r *Rule) includeNote() bool {
if r.Options.IncludeNote != nil {
return *r.Options.IncludeNote
}
return false
}
// ReasonWithNote returns a human-readable reason for the rule finding
// with an additional note, if defined.
func (r *Rule) ReasonWithNote(finding string) string {
if len(r.Note) == 0 || !r.includeNote() {
return r.Reason(finding)
}
return fmt.Sprintf("%s (%s)", r.Reason(finding), r.Note)
}
// CanIgnoreLine returns a boolean value if the line contains the ignore directive.
// For example, if a line has anywhere, wokeignore:rule=whitelist
// (should be commented out via whatever the language comment syntax is)
// it will not report that line in finding with the Rule with the name `whitelist` wokeignore:rule=whitelist
func (r *Rule) CanIgnoreLine(line string) bool {
matches := ignoreRuleRegex.FindAllStringSubmatch(line, -1)
if matches == nil {
return false
}
for _, match := range matches {
if len(match) < 1 {
continue
}
for _, m := range strings.Split(match[1], ",") {
if m == r.Name {
return true
}
}
}
return false
}
// IsDirectiveOnlyLine returns a boolean value if the line contains only the wokeignore directive.
// For example, if a line is only a single-line comment containing wokeignore:rule=xyz with no other
// alphanumeric characters to the left of the directive, it will return true that it is a directive-only line.
// Any text to the right of the wokeignore directive will not be considered by woke for findings.
func IsDirectiveOnlyLine(line string) bool {
indices := ignoreRuleRegex.FindStringIndex(line)
if indices == nil {
return false
}
// in a one-line comment, left-text should be all that is considered to be "outside" of the ignore directive
leftText := line[0:indices[0]]
return !util.ContainsAlphanumeric(leftText)
}
func escape(ss []string) []string {
for i, s := range ss {
ss[i] = regexp.QuoteMeta(s)
}
return ss
}
// maskInlineIgnore removes the entire match of the ignoreRuleRegex from the line
// and replaces it with the null terminator (\x00) character so the rule matcher won't
// attempt to find findings within the inline ignore
func maskInlineIgnore(line string) string {
inlineIgnoreMatch := ignoreRuleRegex.FindStringIndex(line)
if inlineIgnoreMatch == nil || len(inlineIgnoreMatch) < 2 {
return line
}
lineWithoutIgnoreRule := []rune(line)
start := inlineIgnoreMatch[0]
end := inlineIgnoreMatch[1]
for i := start; i < end; i++ {
// use null terminator to indicate a masked character
lineWithoutIgnoreRule[i] = rune(0)
}
return string(lineWithoutIgnoreRule)
}
// Disabled denotes if the rule is disabled
// If no terms are provided, this essentially disables the rule
// which is helpful for disabling default rules. Eventually, there should be a better
// way to disable a default rule, and then, if a rule has no Terms, it falls back to the Name.
func (r *Rule) Disabled() bool {
return len(r.Terms) == 0
}
// SetIncludeNote populates IncludeNote attributte in Options
// Options.IncludeNote is ussed in ReasonWithNote
// If "include_note" is already defined for the rule in yaml, it will not be overridden
func (r *Rule) SetIncludeNote(includeNote bool) {
if r.Options.IncludeNote != nil {
return
}
r.Options.IncludeNote = &includeNote
}
// ContainsCategory denotes if the provided category exists in the rule's Options.Categories
func (r *Rule) ContainsCategory(cat string) bool {
for _, ruleCat := range r.Options.Categories {
if ruleCat == cat {
return true
}
}
return false
}