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
|
|
|
// Send slack by API |
78
|
|
|
if 0 < len(token) { |
79
|
|
|
api := slack.New(token) |
80
|
|
|
ParentMsg := slack.PostMessageParameters{ |
81
|
|
|
// Text: msgText(r), |
82
|
|
|
Username: conf.AuthUser, |
83
|
|
|
IconEmoji: conf.IconEmoji, |
84
|
|
|
} |
85
|
|
|
|
86
|
|
|
if config.Conf.FormatOneLineText { |
87
|
|
|
if _, _, err = api.PostMessage(channel, formatOneLineSummary(r), ParentMsg); err != nil { |
88
|
|
|
return err |
89
|
|
|
} |
90
|
|
|
continue |
91
|
|
|
} |
92
|
|
|
|
93
|
|
|
var ts string |
94
|
|
|
if _, ts, err = api.PostMessage(channel, msgText(r), ParentMsg); err != nil { |
95
|
|
|
return err |
96
|
|
|
} |
97
|
|
|
|
98
|
|
|
for _, k := range chunkKeys { |
99
|
|
|
params := slack.PostMessageParameters{ |
100
|
|
|
// Text: msgText(r), |
101
|
|
|
Username: conf.AuthUser, |
102
|
|
|
IconEmoji: conf.IconEmoji, |
103
|
|
|
Attachments: m[k], |
104
|
|
|
ThreadTimestamp: ts, |
105
|
|
|
} |
106
|
|
|
if _, _, err = api.PostMessage(channel, msgText(r), params); err != nil { |
107
|
|
|
return err |
108
|
|
|
} |
109
|
|
|
} |
110
|
|
|
} else { |
111
|
|
|
if config.Conf.FormatOneLineText { |
112
|
|
|
msg := message{ |
113
|
|
|
Text: formatOneLineSummary(r), |
114
|
|
|
Username: conf.AuthUser, |
115
|
|
|
IconEmoji: conf.IconEmoji, |
116
|
|
|
Channel: channel, |
117
|
|
|
} |
118
|
|
|
if err := send(msg); err != nil { |
119
|
|
|
return err |
120
|
|
|
} |
121
|
|
|
continue |
122
|
|
|
} |
123
|
|
|
|
124
|
|
|
for i, k := range chunkKeys { |
125
|
|
|
txt := "" |
126
|
|
|
if i == 0 { |
127
|
|
|
txt = msgText(r) |
128
|
|
|
} |
129
|
|
|
msg := message{ |
130
|
|
|
Text: txt, |
131
|
|
|
Username: conf.AuthUser, |
132
|
|
|
IconEmoji: conf.IconEmoji, |
133
|
|
|
Channel: channel, |
134
|
|
|
Attachments: m[k], |
135
|
|
|
} |
136
|
|
|
if err = send(msg); err != nil { |
137
|
|
|
return err |
138
|
|
|
} |
139
|
|
|
} |
140
|
|
|
} |
141
|
|
|
} |
142
|
|
|
return nil |
143
|
|
|
} |
144
|
|
|
|
145
|
|
|
func send(msg message) error { |
146
|
|
|
conf := config.Conf.Slack |
147
|
|
|
count, retryMax := 0, 10 |
148
|
|
|
|
149
|
|
|
bytes, _ := json.Marshal(msg) |
150
|
|
|
jsonBody := string(bytes) |
151
|
|
|
|
152
|
|
|
f := func() (err error) { |
153
|
|
|
resp, body, errs := gorequest.New().Proxy(config.Conf.HTTPProxy).Post(conf.HookURL).Send(string(jsonBody)).End() |
154
|
|
|
if 0 < len(errs) || resp == nil || resp.StatusCode != 200 { |
155
|
|
|
count++ |
156
|
|
|
if count == retryMax { |
157
|
|
|
return nil |
158
|
|
|
} |
159
|
|
|
return xerrors.Errorf( |
|
|
|
|
160
|
|
|
"HTTP POST error. url: %s, resp: %v, body: %s, err: %w", |
161
|
|
|
conf.HookURL, resp, body, errs) |
162
|
|
|
} |
163
|
|
|
return nil |
164
|
|
|
} |
165
|
|
|
notify := func(err error, t time.Duration) { |
166
|
|
|
log.Warnf("Error %s", err) |
167
|
|
|
log.Warn("Retrying in ", t) |
168
|
|
|
} |
169
|
|
|
boff := backoff.NewExponentialBackOff() |
170
|
|
|
if err := backoff.RetryNotify(f, boff, notify); err != nil { |
171
|
|
|
return xerrors.Errorf("HTTP error: %w", err) |
|
|
|
|
172
|
|
|
} |
173
|
|
|
if count == retryMax { |
174
|
|
|
return xerrors.New("Retry count exceeded") |
175
|
|
|
} |
176
|
|
|
return nil |
177
|
|
|
} |
178
|
|
|
|
179
|
|
|
func msgText(r models.ScanResult) string { |
180
|
|
|
notifyUsers := "" |
181
|
|
|
if 0 < len(r.ScannedCves) { |
182
|
|
|
notifyUsers = getNotifyUsers(config.Conf.Slack.NotifyUsers) |
183
|
|
|
} |
184
|
|
|
serverInfo := fmt.Sprintf("*%s*", r.ServerInfo()) |
185
|
|
|
|
186
|
|
|
if 0 < len(r.Errors) { |
187
|
|
|
return fmt.Sprintf("%s\n%s\n%s\n%s\n%s\nError: %s", |
188
|
|
|
notifyUsers, |
189
|
|
|
serverInfo, |
190
|
|
|
r.ScannedCves.FormatCveSummary(), |
191
|
|
|
r.ScannedCves.FormatFixedStatus(r.Packages), |
192
|
|
|
r.FormatUpdatablePacksSummary(), |
193
|
|
|
r.Errors) |
194
|
|
|
} |
195
|
|
|
return fmt.Sprintf("%s\n%s\n%s\n%s\n%s", |
196
|
|
|
notifyUsers, |
197
|
|
|
serverInfo, |
198
|
|
|
r.ScannedCves.FormatCveSummary(), |
199
|
|
|
r.ScannedCves.FormatFixedStatus(r.Packages), |
200
|
|
|
r.FormatUpdatablePacksSummary()) |
201
|
|
|
} |
202
|
|
|
|
203
|
|
|
func toSlackAttachments(r models.ScanResult) (attaches []slack.Attachment) { |
204
|
|
|
vinfos := r.ScannedCves.ToSortedSlice() |
205
|
|
|
for _, vinfo := range vinfos { |
206
|
|
|
|
207
|
|
|
installed, candidate := []string{}, []string{} |
208
|
|
|
for _, affected := range vinfo.AffectedPackages { |
209
|
|
|
if p, ok := r.Packages[affected.Name]; ok { |
210
|
|
|
installed = append(installed, |
211
|
|
|
fmt.Sprintf("%s-%s", p.Name, p.FormatVer())) |
212
|
|
|
} else { |
213
|
|
|
installed = append(installed, affected.Name) |
214
|
|
|
} |
215
|
|
|
|
216
|
|
|
if p, ok := r.Packages[affected.Name]; ok { |
217
|
|
|
if affected.NotFixedYet { |
218
|
|
|
candidate = append(candidate, "Not Fixed Yet") |
219
|
|
|
} else { |
220
|
|
|
candidate = append(candidate, p.FormatNewVer()) |
221
|
|
|
} |
222
|
|
|
} else { |
223
|
|
|
candidate = append(candidate, "?") |
224
|
|
|
} |
225
|
|
|
} |
226
|
|
|
|
227
|
|
|
for _, n := range vinfo.CpeURIs { |
228
|
|
|
installed = append(installed, n) |
229
|
|
|
candidate = append(candidate, "?") |
230
|
|
|
} |
231
|
|
|
for _, n := range vinfo.GitHubSecurityAlerts { |
232
|
|
|
installed = append(installed, n.PackageName) |
233
|
|
|
candidate = append(candidate, "?") |
234
|
|
|
} |
235
|
|
|
|
236
|
|
|
for _, wp := range vinfo.WpPackageFixStats { |
237
|
|
|
if p, ok := r.WordPressPackages.Find(wp.Name); ok { |
238
|
|
|
installed = append(installed, fmt.Sprintf("%s-%s", wp.Name, p.Version)) |
239
|
|
|
candidate = append(candidate, wp.FixedIn) |
240
|
|
|
} else { |
241
|
|
|
installed = append(installed, wp.Name) |
242
|
|
|
candidate = append(candidate, "?") |
243
|
|
|
} |
244
|
|
|
} |
245
|
|
|
|
246
|
|
|
a := slack.Attachment{ |
247
|
|
|
Title: vinfo.CveID, |
248
|
|
|
TitleLink: "https://nvd.nist.gov/vuln/detail/" + vinfo.CveID, |
249
|
|
|
Text: attachmentText(vinfo, r.Family, r.CweDict, r.Packages), |
250
|
|
|
MarkdownIn: []string{"text", "pretext"}, |
251
|
|
|
Fields: []slack.AttachmentField{ |
252
|
|
|
{ |
253
|
|
|
// Title: "Current Package/CPE", |
254
|
|
|
Title: "Installed", |
255
|
|
|
Value: strings.Join(installed, "\n"), |
256
|
|
|
Short: true, |
257
|
|
|
}, |
258
|
|
|
{ |
259
|
|
|
Title: "Candidate", |
260
|
|
|
Value: strings.Join(candidate, "\n"), |
261
|
|
|
Short: true, |
262
|
|
|
}, |
263
|
|
|
}, |
264
|
|
|
Color: cvssColor(vinfo.MaxCvssScore().Value.Score), |
265
|
|
|
} |
266
|
|
|
attaches = append(attaches, a) |
267
|
|
|
} |
268
|
|
|
return |
269
|
|
|
} |
270
|
|
|
|
271
|
|
|
// https://api.slack.com/docs/attachments |
272
|
|
|
func cvssColor(cvssScore float64) string { |
273
|
|
|
switch { |
274
|
|
|
case 7 <= cvssScore: |
275
|
|
|
return "danger" |
276
|
|
|
case 4 <= cvssScore && cvssScore < 7: |
277
|
|
|
return "warning" |
278
|
|
|
case cvssScore == 0: |
279
|
|
|
return "#C0C0C0" |
280
|
|
|
default: |
281
|
|
|
return "good" |
282
|
|
|
} |
283
|
|
|
} |
284
|
|
|
|
285
|
|
|
func attachmentText(vinfo models.VulnInfo, osFamily string, cweDict map[string]models.CweDictEntry, packs models.Packages) string { |
286
|
|
|
maxCvss := vinfo.MaxCvssScore() |
287
|
|
|
vectors := []string{} |
288
|
|
|
|
289
|
|
|
scores := append(vinfo.Cvss3Scores(), vinfo.Cvss2Scores(osFamily)...) |
290
|
|
|
for _, cvss := range scores { |
291
|
|
|
if cvss.Value.Severity == "" { |
292
|
|
|
continue |
293
|
|
|
} |
294
|
|
|
calcURL := "" |
295
|
|
|
switch cvss.Value.Type { |
296
|
|
|
case models.CVSS2: |
297
|
|
|
calcURL = fmt.Sprintf( |
298
|
|
|
"https://nvd.nist.gov/vuln-metrics/cvss/v2-calculator?name=%s", |
299
|
|
|
vinfo.CveID) |
300
|
|
|
case models.CVSS3: |
301
|
|
|
calcURL = fmt.Sprintf( |
302
|
|
|
"https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator?name=%s", |
303
|
|
|
vinfo.CveID) |
304
|
|
|
} |
305
|
|
|
|
306
|
|
|
if cont, ok := vinfo.CveContents[cvss.Type]; ok { |
307
|
|
|
v := fmt.Sprintf("<%s|%s> %s (<%s|%s>)", |
308
|
|
|
calcURL, |
309
|
|
|
fmt.Sprintf("%3.1f/%s", cvss.Value.Score, cvss.Value.Vector), |
310
|
|
|
cvss.Value.Severity, |
311
|
|
|
cont.SourceLink, |
312
|
|
|
cvss.Type) |
313
|
|
|
vectors = append(vectors, v) |
314
|
|
|
|
315
|
|
|
} else { |
316
|
|
|
if 0 < len(vinfo.DistroAdvisories) { |
317
|
|
|
links := []string{} |
318
|
|
|
for k, v := range vinfo.VendorLinks(osFamily) { |
319
|
|
|
links = append(links, fmt.Sprintf("<%s|%s>", |
320
|
|
|
v, k)) |
321
|
|
|
} |
322
|
|
|
|
323
|
|
|
v := fmt.Sprintf("<%s|%s> %s (%s)", |
324
|
|
|
calcURL, |
325
|
|
|
fmt.Sprintf("%3.1f/%s", cvss.Value.Score, cvss.Value.Vector), |
326
|
|
|
cvss.Value.Severity, |
327
|
|
|
strings.Join(links, ", ")) |
328
|
|
|
vectors = append(vectors, v) |
329
|
|
|
} |
330
|
|
|
} |
331
|
|
|
} |
332
|
|
|
|
333
|
|
|
severity := strings.ToUpper(maxCvss.Value.Severity) |
334
|
|
|
if severity == "" { |
335
|
|
|
severity = "?" |
336
|
|
|
} |
337
|
|
|
|
338
|
|
|
nwvec := vinfo.AttackVector() |
339
|
|
|
if nwvec == "Network" || nwvec == "remote" { |
340
|
|
|
nwvec = fmt.Sprintf("*%s*", nwvec) |
341
|
|
|
} |
342
|
|
|
|
343
|
|
|
mitigation := "" |
344
|
|
|
if vinfo.Mitigations(osFamily)[0].Type != models.Unknown { |
345
|
|
|
mitigation = fmt.Sprintf("\nMitigation:\n```%s```\n", |
346
|
|
|
vinfo.Mitigations(osFamily)[0].Value) |
347
|
|
|
} |
348
|
|
|
|
349
|
|
|
return fmt.Sprintf("*%4.1f (%s)* %s %s\n%s\n```\n%s\n```%s\n%s\n", |
350
|
|
|
maxCvss.Value.Score, |
351
|
|
|
severity, |
352
|
|
|
nwvec, |
353
|
|
|
vinfo.PatchStatus(packs), |
354
|
|
|
strings.Join(vectors, "\n"), |
355
|
|
|
vinfo.Summaries(config.Conf.Lang, osFamily)[0].Value, |
356
|
|
|
mitigation, |
357
|
|
|
cweIDs(vinfo, osFamily, cweDict), |
358
|
|
|
) |
359
|
|
|
} |
360
|
|
|
|
361
|
|
|
func cweIDs(vinfo models.VulnInfo, osFamily string, cweDict models.CweDict) string { |
362
|
|
|
links := []string{} |
363
|
|
|
for _, c := range vinfo.CveContents.UniqCweIDs(osFamily) { |
364
|
|
|
name, url, top10Rank, top10URL := cweDict.Get(c.Value, osFamily) |
365
|
|
|
line := "" |
366
|
|
|
if top10Rank != "" { |
367
|
|
|
line = fmt.Sprintf("<%s|[OWASP Top %s]>", |
368
|
|
|
top10URL, top10Rank) |
369
|
|
|
} |
370
|
|
|
links = append(links, fmt.Sprintf("%s <%s|%s>: %s", |
371
|
|
|
line, url, c.Value, name)) |
372
|
|
|
} |
373
|
|
|
return strings.Join(links, "\n") |
374
|
|
|
} |
375
|
|
|
|
376
|
|
|
// See testcase |
377
|
|
|
func getNotifyUsers(notifyUsers []string) string { |
378
|
|
|
slackStyleTexts := []string{} |
379
|
|
|
for _, username := range notifyUsers { |
380
|
|
|
slackStyleTexts = append(slackStyleTexts, fmt.Sprintf("<%s>", username)) |
381
|
|
|
} |
382
|
|
|
return strings.Join(slackStyleTexts, " ") |
383
|
|
|
} |
384
|
|
|
|