|
1
|
|
|
/* Vuls - Vulnerability Scanner |
|
2
|
|
|
Copyright (C) 2016 Future Corporation , Japan. |
|
3
|
|
|
|
|
4
|
|
|
This program is free software: you can redistribute it and/or modify |
|
5
|
|
|
it under the terms of the GNU General Public License as published by |
|
6
|
|
|
the Free Software Foundation, either version 3 of the License, or |
|
7
|
|
|
(at your option) any later version. |
|
8
|
|
|
|
|
9
|
|
|
This program is distributed in the hope that it will be useful, |
|
10
|
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
11
|
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
12
|
|
|
GNU General Public License for more details. |
|
13
|
|
|
|
|
14
|
|
|
You should have received a copy of the GNU General Public License |
|
15
|
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
16
|
|
|
*/ |
|
17
|
|
|
|
|
18
|
|
|
package report |
|
19
|
|
|
|
|
20
|
|
|
import ( |
|
21
|
|
|
"encoding/json" |
|
22
|
|
|
"fmt" |
|
23
|
|
|
"sort" |
|
24
|
|
|
"strings" |
|
25
|
|
|
"time" |
|
26
|
|
|
|
|
27
|
|
|
"github.com/cenkalti/backoff" |
|
28
|
|
|
"github.com/future-architect/vuls/config" |
|
29
|
|
|
"github.com/future-architect/vuls/models" |
|
30
|
|
|
"github.com/nlopes/slack" |
|
31
|
|
|
"github.com/parnurzeal/gorequest" |
|
32
|
|
|
log "github.com/sirupsen/logrus" |
|
33
|
|
|
"golang.org/x/xerrors" |
|
34
|
|
|
) |
|
35
|
|
|
|
|
36
|
|
|
type field struct { |
|
37
|
|
|
Title string `json:"title"` |
|
38
|
|
|
Value string `json:"value"` |
|
39
|
|
|
Short bool `json:"short"` |
|
40
|
|
|
} |
|
41
|
|
|
|
|
42
|
|
|
type message struct { |
|
43
|
|
|
Text string `json:"text"` |
|
44
|
|
|
Username string `json:"username"` |
|
45
|
|
|
IconEmoji string `json:"icon_emoji"` |
|
46
|
|
|
Channel string `json:"channel"` |
|
47
|
|
|
Attachments []slack.Attachment `json:"attachments"` |
|
48
|
|
|
} |
|
49
|
|
|
|
|
50
|
|
|
// SlackWriter send report to slack |
|
51
|
|
|
type SlackWriter struct{} |
|
52
|
|
|
|
|
53
|
|
|
func (w SlackWriter) Write(rs ...models.ScanResult) (err error) { |
|
54
|
|
|
conf := config.Conf.Slack |
|
55
|
|
|
channel := conf.Channel |
|
56
|
|
|
token := conf.LegacyToken |
|
57
|
|
|
|
|
58
|
|
|
for _, r := range rs { |
|
59
|
|
|
if channel == "${servername}" { |
|
60
|
|
|
channel = fmt.Sprintf("#%s", r.ServerName) |
|
61
|
|
|
} |
|
62
|
|
|
|
|
63
|
|
|
// A maximum of 100 attachments are allowed on a message. |
|
64
|
|
|
// Split into chunks with 100 elements |
|
65
|
|
|
// https://api.slack.com/methods/chat.postMessage |
|
66
|
|
|
maxAttachments := 100 |
|
67
|
|
|
m := map[int][]slack.Attachment{} |
|
68
|
|
|
for i, a := range toSlackAttachments(r) { |
|
69
|
|
|
m[i/maxAttachments] = append(m[i/maxAttachments], a) |
|
70
|
|
|
} |
|
71
|
|
|
chunkKeys := []int{} |
|
72
|
|
|
for k := range m { |
|
73
|
|
|
chunkKeys = append(chunkKeys, k) |
|
74
|
|
|
} |
|
75
|
|
|
sort.Ints(chunkKeys) |
|
76
|
|
|
|
|
77
|
|
|
summary := fmt.Sprintf("%s\n%s", |
|
78
|
|
|
getNotifyUsers(config.Conf.Slack.NotifyUsers), |
|
79
|
|
|
formatOneLineSummary(r)) |
|
80
|
|
|
|
|
81
|
|
|
// Send slack by API |
|
82
|
|
|
if 0 < len(token) { |
|
83
|
|
|
api := slack.New(token) |
|
84
|
|
|
msgPrms := slack.PostMessageParameters{ |
|
85
|
|
|
Username: conf.AuthUser, |
|
86
|
|
|
IconEmoji: conf.IconEmoji, |
|
87
|
|
|
} |
|
88
|
|
|
|
|
89
|
|
|
var ts string |
|
90
|
|
|
if _, ts, err = api.PostMessage(channel, |
|
91
|
|
|
summary, msgPrms); err != nil { |
|
92
|
|
|
return err |
|
93
|
|
|
} |
|
94
|
|
|
|
|
95
|
|
|
if config.Conf.FormatOneLineText || 0 < len(r.Errors) { |
|
96
|
|
|
continue |
|
97
|
|
|
} |
|
98
|
|
|
|
|
99
|
|
|
for _, k := range chunkKeys { |
|
100
|
|
|
params := slack.PostMessageParameters{ |
|
101
|
|
|
Username: conf.AuthUser, |
|
102
|
|
|
IconEmoji: conf.IconEmoji, |
|
103
|
|
|
Attachments: m[k], |
|
104
|
|
|
ThreadTimestamp: ts, |
|
105
|
|
|
} |
|
106
|
|
|
if _, _, err = api.PostMessage(channel, "", params); err != nil { |
|
107
|
|
|
return err |
|
108
|
|
|
} |
|
109
|
|
|
} |
|
110
|
|
|
} else { |
|
111
|
|
|
msg := message{ |
|
112
|
|
|
Text: summary, |
|
113
|
|
|
Username: conf.AuthUser, |
|
114
|
|
|
IconEmoji: conf.IconEmoji, |
|
115
|
|
|
Channel: channel, |
|
116
|
|
|
} |
|
117
|
|
|
if err := send(msg); err != nil { |
|
118
|
|
|
return err |
|
119
|
|
|
} |
|
120
|
|
|
|
|
121
|
|
|
if config.Conf.FormatOneLineText || 0 < len(r.Errors) { |
|
122
|
|
|
continue |
|
123
|
|
|
} |
|
124
|
|
|
|
|
125
|
|
|
for _, k := range chunkKeys { |
|
126
|
|
|
txt := fmt.Sprintf("%d/%d for %s", |
|
127
|
|
|
k+1, |
|
128
|
|
|
len(chunkKeys), |
|
129
|
|
|
r.FormatServerName()) |
|
130
|
|
|
|
|
131
|
|
|
msg := message{ |
|
132
|
|
|
Text: txt, |
|
133
|
|
|
Username: conf.AuthUser, |
|
134
|
|
|
IconEmoji: conf.IconEmoji, |
|
135
|
|
|
Channel: channel, |
|
136
|
|
|
Attachments: m[k], |
|
137
|
|
|
} |
|
138
|
|
|
if err = send(msg); err != nil { |
|
139
|
|
|
return err |
|
140
|
|
|
} |
|
141
|
|
|
} |
|
142
|
|
|
} |
|
143
|
|
|
} |
|
144
|
|
|
return nil |
|
145
|
|
|
} |
|
146
|
|
|
|
|
147
|
|
|
func send(msg message) error { |
|
148
|
|
|
conf := config.Conf.Slack |
|
149
|
|
|
count, retryMax := 0, 10 |
|
150
|
|
|
|
|
151
|
|
|
bytes, _ := json.Marshal(msg) |
|
152
|
|
|
jsonBody := string(bytes) |
|
153
|
|
|
|
|
154
|
|
|
f := func() (err error) { |
|
155
|
|
|
resp, body, errs := gorequest.New().Proxy(config.Conf.HTTPProxy).Post(conf.HookURL).Send(string(jsonBody)).End() |
|
156
|
|
|
if 0 < len(errs) || resp == nil || resp.StatusCode != 200 { |
|
157
|
|
|
count++ |
|
158
|
|
|
if count == retryMax { |
|
159
|
|
|
return nil |
|
160
|
|
|
} |
|
161
|
|
|
return xerrors.Errorf( |
|
|
|
|
|
|
162
|
|
|
"HTTP POST error. url: %s, resp: %v, body: %s, err: %w", |
|
163
|
|
|
conf.HookURL, resp, body, errs) |
|
164
|
|
|
} |
|
165
|
|
|
return nil |
|
166
|
|
|
} |
|
167
|
|
|
notify := func(err error, t time.Duration) { |
|
168
|
|
|
log.Warnf("Error %s", err) |
|
169
|
|
|
log.Warn("Retrying in ", t) |
|
170
|
|
|
} |
|
171
|
|
|
boff := backoff.NewExponentialBackOff() |
|
172
|
|
|
if err := backoff.RetryNotify(f, boff, notify); err != nil { |
|
173
|
|
|
return xerrors.Errorf("HTTP error: %w", err) |
|
|
|
|
|
|
174
|
|
|
} |
|
175
|
|
|
if count == retryMax { |
|
176
|
|
|
return xerrors.New("Retry count exceeded") |
|
177
|
|
|
} |
|
178
|
|
|
return nil |
|
179
|
|
|
} |
|
180
|
|
|
|
|
181
|
|
|
func toSlackAttachments(r models.ScanResult) (attaches []slack.Attachment) { |
|
182
|
|
|
vinfos := r.ScannedCves.ToSortedSlice() |
|
183
|
|
|
for _, vinfo := range vinfos { |
|
184
|
|
|
|
|
185
|
|
|
installed, candidate := []string{}, []string{} |
|
186
|
|
|
for _, affected := range vinfo.AffectedPackages { |
|
187
|
|
|
if p, ok := r.Packages[affected.Name]; ok { |
|
188
|
|
|
installed = append(installed, |
|
189
|
|
|
fmt.Sprintf("%s-%s", p.Name, p.FormatVer())) |
|
190
|
|
|
} else { |
|
191
|
|
|
installed = append(installed, affected.Name) |
|
192
|
|
|
} |
|
193
|
|
|
|
|
194
|
|
|
if p, ok := r.Packages[affected.Name]; ok { |
|
195
|
|
|
if affected.NotFixedYet { |
|
196
|
|
|
candidate = append(candidate, "Not Fixed Yet") |
|
197
|
|
|
} else { |
|
198
|
|
|
candidate = append(candidate, p.FormatNewVer()) |
|
199
|
|
|
} |
|
200
|
|
|
} else { |
|
201
|
|
|
candidate = append(candidate, "?") |
|
202
|
|
|
} |
|
203
|
|
|
} |
|
204
|
|
|
|
|
205
|
|
|
for _, n := range vinfo.CpeURIs { |
|
206
|
|
|
installed = append(installed, n) |
|
207
|
|
|
candidate = append(candidate, "?") |
|
208
|
|
|
} |
|
209
|
|
|
for _, n := range vinfo.GitHubSecurityAlerts { |
|
210
|
|
|
installed = append(installed, n.PackageName) |
|
211
|
|
|
candidate = append(candidate, "?") |
|
212
|
|
|
} |
|
213
|
|
|
|
|
214
|
|
|
for _, wp := range vinfo.WpPackageFixStats { |
|
215
|
|
|
if p, ok := r.WordPressPackages.Find(wp.Name); ok { |
|
216
|
|
|
installed = append(installed, fmt.Sprintf("%s-%s", wp.Name, p.Version)) |
|
217
|
|
|
candidate = append(candidate, wp.FixedIn) |
|
218
|
|
|
} else { |
|
219
|
|
|
installed = append(installed, wp.Name) |
|
220
|
|
|
candidate = append(candidate, "?") |
|
221
|
|
|
} |
|
222
|
|
|
} |
|
223
|
|
|
|
|
224
|
|
|
a := slack.Attachment{ |
|
225
|
|
|
Title: vinfo.CveID, |
|
226
|
|
|
TitleLink: "https://nvd.nist.gov/vuln/detail/" + vinfo.CveID, |
|
227
|
|
|
Text: attachmentText(vinfo, r.Family, r.CweDict, r.Packages), |
|
228
|
|
|
MarkdownIn: []string{"text", "pretext"}, |
|
229
|
|
|
Fields: []slack.AttachmentField{ |
|
230
|
|
|
{ |
|
231
|
|
|
// Title: "Current Package/CPE", |
|
232
|
|
|
Title: "Installed", |
|
233
|
|
|
Value: strings.Join(installed, "\n"), |
|
234
|
|
|
Short: true, |
|
235
|
|
|
}, |
|
236
|
|
|
{ |
|
237
|
|
|
Title: "Candidate", |
|
238
|
|
|
Value: strings.Join(candidate, "\n"), |
|
239
|
|
|
Short: true, |
|
240
|
|
|
}, |
|
241
|
|
|
}, |
|
242
|
|
|
Color: cvssColor(vinfo.MaxCvssScore().Value.Score), |
|
243
|
|
|
} |
|
244
|
|
|
attaches = append(attaches, a) |
|
245
|
|
|
} |
|
246
|
|
|
return |
|
247
|
|
|
} |
|
248
|
|
|
|
|
249
|
|
|
// https://api.slack.com/docs/attachments |
|
250
|
|
|
func cvssColor(cvssScore float64) string { |
|
251
|
|
|
switch { |
|
252
|
|
|
case 7 <= cvssScore: |
|
253
|
|
|
return "danger" |
|
254
|
|
|
case 4 <= cvssScore && cvssScore < 7: |
|
255
|
|
|
return "warning" |
|
256
|
|
|
case cvssScore == 0: |
|
257
|
|
|
return "#C0C0C0" |
|
258
|
|
|
default: |
|
259
|
|
|
return "good" |
|
260
|
|
|
} |
|
261
|
|
|
} |
|
262
|
|
|
|
|
263
|
|
|
func attachmentText(vinfo models.VulnInfo, osFamily string, cweDict map[string]models.CweDictEntry, packs models.Packages) string { |
|
264
|
|
|
maxCvss := vinfo.MaxCvssScore() |
|
265
|
|
|
vectors := []string{} |
|
266
|
|
|
|
|
267
|
|
|
scores := append(vinfo.Cvss3Scores(), vinfo.Cvss2Scores(osFamily)...) |
|
268
|
|
|
for _, cvss := range scores { |
|
269
|
|
|
if cvss.Value.Severity == "" { |
|
270
|
|
|
continue |
|
271
|
|
|
} |
|
272
|
|
|
calcURL := "" |
|
273
|
|
|
switch cvss.Value.Type { |
|
274
|
|
|
case models.CVSS2: |
|
275
|
|
|
calcURL = fmt.Sprintf( |
|
276
|
|
|
"https://nvd.nist.gov/vuln-metrics/cvss/v2-calculator?name=%s", |
|
277
|
|
|
vinfo.CveID) |
|
278
|
|
|
case models.CVSS3: |
|
279
|
|
|
calcURL = fmt.Sprintf( |
|
280
|
|
|
"https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator?name=%s", |
|
281
|
|
|
vinfo.CveID) |
|
282
|
|
|
} |
|
283
|
|
|
|
|
284
|
|
|
if cont, ok := vinfo.CveContents[cvss.Type]; ok { |
|
285
|
|
|
v := fmt.Sprintf("<%s|%s> %s (<%s|%s>)", |
|
286
|
|
|
calcURL, |
|
287
|
|
|
fmt.Sprintf("%3.1f/%s", cvss.Value.Score, cvss.Value.Vector), |
|
288
|
|
|
cvss.Value.Severity, |
|
289
|
|
|
cont.SourceLink, |
|
290
|
|
|
cvss.Type) |
|
291
|
|
|
vectors = append(vectors, v) |
|
292
|
|
|
|
|
293
|
|
|
} else { |
|
294
|
|
|
if 0 < len(vinfo.DistroAdvisories) { |
|
295
|
|
|
links := []string{} |
|
296
|
|
|
for k, v := range vinfo.VendorLinks(osFamily) { |
|
297
|
|
|
links = append(links, fmt.Sprintf("<%s|%s>", |
|
298
|
|
|
v, k)) |
|
299
|
|
|
} |
|
300
|
|
|
|
|
301
|
|
|
v := fmt.Sprintf("<%s|%s> %s (%s)", |
|
302
|
|
|
calcURL, |
|
303
|
|
|
fmt.Sprintf("%3.1f/%s", cvss.Value.Score, cvss.Value.Vector), |
|
304
|
|
|
cvss.Value.Severity, |
|
305
|
|
|
strings.Join(links, ", ")) |
|
306
|
|
|
vectors = append(vectors, v) |
|
307
|
|
|
} |
|
308
|
|
|
} |
|
309
|
|
|
} |
|
310
|
|
|
|
|
311
|
|
|
severity := strings.ToUpper(maxCvss.Value.Severity) |
|
312
|
|
|
if severity == "" { |
|
313
|
|
|
severity = "?" |
|
314
|
|
|
} |
|
315
|
|
|
|
|
316
|
|
|
nwvec := vinfo.AttackVector() |
|
317
|
|
|
if nwvec == "Network" || nwvec == "remote" { |
|
318
|
|
|
nwvec = fmt.Sprintf("*%s*", nwvec) |
|
319
|
|
|
} |
|
320
|
|
|
|
|
321
|
|
|
mitigation := "" |
|
322
|
|
|
if vinfo.Mitigations(osFamily)[0].Type != models.Unknown { |
|
323
|
|
|
mitigation = fmt.Sprintf("\nMitigation:\n```%s```\n", |
|
324
|
|
|
vinfo.Mitigations(osFamily)[0].Value) |
|
325
|
|
|
} |
|
326
|
|
|
|
|
327
|
|
|
return fmt.Sprintf("*%4.1f (%s)* %s %s\n%s\n```\n%s\n```%s\n%s\n", |
|
328
|
|
|
maxCvss.Value.Score, |
|
329
|
|
|
severity, |
|
330
|
|
|
nwvec, |
|
331
|
|
|
vinfo.PatchStatus(packs), |
|
332
|
|
|
strings.Join(vectors, "\n"), |
|
333
|
|
|
vinfo.Summaries(config.Conf.Lang, osFamily)[0].Value, |
|
334
|
|
|
mitigation, |
|
335
|
|
|
cweIDs(vinfo, osFamily, cweDict), |
|
336
|
|
|
) |
|
337
|
|
|
} |
|
338
|
|
|
|
|
339
|
|
|
func cweIDs(vinfo models.VulnInfo, osFamily string, cweDict models.CweDict) string { |
|
340
|
|
|
links := []string{} |
|
341
|
|
|
for _, c := range vinfo.CveContents.UniqCweIDs(osFamily) { |
|
342
|
|
|
name, url, top10Rank, top10URL := cweDict.Get(c.Value, osFamily) |
|
343
|
|
|
line := "" |
|
344
|
|
|
if top10Rank != "" { |
|
345
|
|
|
line = fmt.Sprintf("<%s|[OWASP Top %s]>", |
|
346
|
|
|
top10URL, top10Rank) |
|
347
|
|
|
} |
|
348
|
|
|
links = append(links, fmt.Sprintf("%s <%s|%s>: %s", |
|
349
|
|
|
line, url, c.Value, name)) |
|
350
|
|
|
} |
|
351
|
|
|
return strings.Join(links, "\n") |
|
352
|
|
|
} |
|
353
|
|
|
|
|
354
|
|
|
// See testcase |
|
355
|
|
|
func getNotifyUsers(notifyUsers []string) string { |
|
356
|
|
|
slackStyleTexts := []string{} |
|
357
|
|
|
for _, username := range notifyUsers { |
|
358
|
|
|
slackStyleTexts = append(slackStyleTexts, fmt.Sprintf("<%s>", username)) |
|
359
|
|
|
} |
|
360
|
|
|
return strings.Join(slackStyleTexts, " ") |
|
361
|
|
|
} |
|
362
|
|
|
|