blob: 459c7b5321267123674e469234babdbb672836e0 [file] [log] [blame]
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +02001package main
2
3import (
4 "context"
5 "fmt"
6 "log"
7 "os"
8 "strconv"
9 "time"
10
11 "github.com/cenkalti/backoff/v4"
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +020012 "github.com/michimani/gotwi"
13 "github.com/michimani/gotwi/tweet/managetweet"
14 "github.com/michimani/gotwi/tweet/managetweet/types"
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +020015 "go.skia.org/infra/go/monorail/v1"
16 "golang.org/x/exp/slices"
17 "google.golang.org/api/option"
18)
19
20const (
21 monorailEndpointURL = "https://bugs.avm99963.com/_ah/api/monorail/v1/"
22 vulnzProject = "vulnz"
23 doNotPublishLabel = "DoNotPublish"
24 restrictedLabel = "Restrict-View-Commit"
25 // Days after which the report for a fixed vulnerability will be automatically
26 // published.
27 deadlineAfterFixed = 30 * 24 * time.Hour
28 // Grace period added to the deadlines
29 gracePeriod = 1 * 24 * time.Hour
30 // Time to warn about the automatic disclosure before it happens
31 warningPeriod = 5 * 24 * time.Hour
32 // Layouts returned by the monorail package
33 dateTimeLayout = "2006-01-02T15:04:05"
34 dateLayout = "2006-01-02"
35)
36
37type VulnerabilityReport struct {
38 // ID of the vulnerability report/issue in Monorail
39 IssueId int64
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +020040 // Title of the report
41 Title string
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +020042 // Issue status
43 Status string
44 // Date that the status was modified
45 StatusModified time.Time
Adrià Vilanova Martínez21037812023-04-10 19:52:07 +020046 // Whether a PublishAt field exists
47 HasPublishAt bool
48 // Custom date when the vulnerability should be published. Takes precedence over the default disclosure flow
49 PublishAt time.Time
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +020050 // Date when the vulnerability report was sent to the vendor
51 Reported time.Time
52 // Number of days since the reported date when the vulnerability should be automatically disclosed
53 DeadlineDays int
54 // DeadlineDays, converted to time.Duration
55 DeadlineDuration time.Duration
56 // Date when the vulnerability report should be published if it has been fixed
57 PublishDateIfFixed time.Time
Adrià Vilanova Martínez21037812023-04-10 19:52:07 +020058 // Whether DeadlineTime could be calculated or not. If false, the vulnerability should not be published if not fixed
59 HasValidDeadlineTime bool
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +020060 // Deadline when the vulnerability report should be published if not fixed
61 DeadlineTime time.Time
62 HasDoNotPublishLabel bool
63 HasRestrictedLabel bool
64}
65
66type Action struct {
67 MustPerform bool
Adrià Vilanova Martínez21037812023-04-10 19:52:07 +020068 // |Reason| can be `disclosureDeadline` (the deadline has been exceeded), `fixedDeadline` (the issue has been fixed and |deadlineAfterFixed| has passed), `customDisclosureDeadline` (the custom PublishAt date has passed)
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +020069 Reason string
70 // |Type| can be `warning` (warning before actually disclosing the report), `disclosure` (the report is actually disclosed)
71 Type string
72}
73
74func parseIssue(issue *monorail.ProtoApiPb2V1IssueWrapper) (*VulnerabilityReport, error) {
75 report := VulnerabilityReport{IssueId: issue.Id}
76
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +020077 report.Title = issue.Title
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +020078 report.Status = issue.Status
79
80 var err error
81 report.StatusModified, err = time.Parse(dateTimeLayout, issue.StatusModified)
82 if err != nil {
83 return nil, err
84 }
85
86 deadlineFound := false
87 reportedFound := false
Adrià Vilanova Martínez21037812023-04-10 19:52:07 +020088 report.HasPublishAt = false
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +020089 for _, field := range issue.FieldValues {
90 if field.FieldName == "Deadline" {
91 deadlineFound = true
92 report.DeadlineDays, err = strconv.Atoi(field.FieldValue)
93 report.DeadlineDuration = time.Duration(report.DeadlineDays) * 24 * time.Hour
94 if err != nil {
95 return nil, fmt.Errorf("Error parsing Deadline field: %v")
96 }
97 }
98
99 if field.FieldName == "Reported" {
100 reportedFound = true
101 report.Reported, err = time.Parse(dateLayout, field.FieldValue)
102 if err != nil {
103 return nil, fmt.Errorf("Error parsing Reported field: %v")
104 }
105 }
Adrià Vilanova Martínez21037812023-04-10 19:52:07 +0200106
107 if field.FieldName == "PublishAt" {
108 report.PublishAt, err = time.Parse(dateLayout, field.FieldValue)
109 if err != nil {
110 return nil, fmt.Errorf("Error parsing PublishAt field: %v")
111 }
112 report.PublishAt = report.PublishAt.Add(gracePeriod)
113 report.HasPublishAt = true
114 }
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200115 }
116
117 report.PublishDateIfFixed = report.StatusModified.Add(deadlineAfterFixed + gracePeriod)
Adrià Vilanova Martínez21037812023-04-10 19:52:07 +0200118 if reportedFound && deadlineFound {
119 report.HasValidDeadlineTime = true
120 report.DeadlineTime = report.Reported.Add(report.DeadlineDuration + gracePeriod)
121 } else {
122 report.HasValidDeadlineTime = false
123 }
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200124
125 report.HasDoNotPublishLabel = slices.Contains(issue.Labels, doNotPublishLabel)
126 report.HasRestrictedLabel = slices.Contains(issue.Labels, restrictedLabel)
127
128 return &report, nil
129}
130
131func logIssue(message string, report *VulnerabilityReport) {
132 log.Printf("[Issue %d] %s", report.IssueId, message)
133}
134
135// |actionType| can be one of the types allowed in |Action.Type|.
136func getActionPartial(report *VulnerabilityReport, actionType string) *Action {
137 now := time.Now()
138 isWarning := actionType == "warning"
139
140 // Take into account that warnings are published in advance (according to warningPeriod)
141 var (
Adrià Vilanova Martínez21037812023-04-10 19:52:07 +0200142 customPublishAtDate time.Time
143 publishDateIfFixed time.Time
144 deadlineTime time.Time
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200145 )
146 if isWarning {
Adrià Vilanova Martínez21037812023-04-10 19:52:07 +0200147 customPublishAtDate = report.PublishAt.Add(-warningPeriod)
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200148 publishDateIfFixed = report.PublishDateIfFixed.Add(-warningPeriod)
149 deadlineTime = report.DeadlineTime.Add(-warningPeriod)
150 } else {
Adrià Vilanova Martínez21037812023-04-10 19:52:07 +0200151 customPublishAtDate = report.PublishAt
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200152 publishDateIfFixed = report.PublishDateIfFixed
153 deadlineTime = report.DeadlineTime
154 }
155
Adrià Vilanova Martínez21037812023-04-10 19:52:07 +0200156 if report.HasPublishAt {
157 if (!isWarning && customPublishAtDate.Before(now)) || (isWarning && dateEqual(customPublishAtDate, now)) {
158 return &Action{
159 MustPerform: true,
160 Reason: "customDisclosureDeadline",
161 Type: actionType,
162 }
163 }
164 return &Action{MustPerform: false}
165 }
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200166 if report.Status == "Fixed" || report.Status == "Verified" {
167 if (!isWarning && publishDateIfFixed.Before(now)) || (isWarning && dateEqual(publishDateIfFixed, now)) {
168 return &Action{
169 MustPerform: true,
170 Reason: "fixedDeadline",
171 Type: actionType,
172 }
173 }
174 return &Action{MustPerform: false}
175 }
Adrià Vilanova Martínez21037812023-04-10 19:52:07 +0200176 if report.HasValidDeadlineTime && (!isWarning && deadlineTime.Before(now)) || (isWarning && dateEqual(deadlineTime, now)) {
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200177 return &Action{
178 MustPerform: true,
179 Reason: "disclosureDeadline",
180 Type: actionType,
181 }
182 }
183 return &Action{MustPerform: false}
184}
185
186// Get the actions that should be performed today on |report|
187func getTodayActions(report *VulnerabilityReport) []*Action {
188 // By default, all the actions will be set to |false|
189 var actions []*Action
190
191 // Do not publish any issue which has the DoNotPublish label set!
192 if report.HasDoNotPublishLabel {
193 logIssue(fmt.Sprintf("The %s label is set. Skipping.\n", doNotPublishLabel), report)
194 return actions
195 }
196
197 // Check that the report is not already published
198 if !report.HasRestrictedLabel {
199 logIssue(fmt.Sprintf("The %s label isn't set (so in theory it shouldn't have been returned by the query). Skipping.\n", restrictedLabel), report)
200 return actions
201 }
202
203 warningAction := getActionPartial(report, "warning")
204 if warningAction.MustPerform {
205 actions = append(actions, warningAction)
206 }
207 disclosureAction := getActionPartial(report, "disclosure")
208 if disclosureAction.MustPerform {
209 actions = append(actions, disclosureAction)
210 }
211 return actions
212}
213
214func newRequestExponentialBackOff() *backoff.ExponentialBackOff {
215 b := backoff.NewExponentialBackOff()
216 b.InitialInterval = 500 * time.Millisecond
217 b.RandomizationFactor = 0.5
218 b.Multiplier = 1.5
219 b.MaxInterval = 30 * time.Second
220 b.MaxElapsedTime = 5 * time.Minute
221 b.Reset()
222 return b
223}
224
225func publishReportAttempt(message string, report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, ctx context.Context) error {
226 ctxRequest, cancel := context.WithDeadline(ctx, time.Now().Add(45*time.Second))
227 defer cancel()
228
229 comment := monorail.ProtoApiPb2V1IssueCommentWrapper{
230 Content: message,
231 }
232
233 if action.Type == "disclosure" {
234 // Remove the label which restricts the bug from public view
235 labels := make([]string, 1)
236 labels[0] = "-" + restrictedLabel
237 comment.Updates = &monorail.ProtoApiPb2V1Update{Labels: labels}
238 }
239
240 _, err := monorailSvc.Issues.Comments.Insert(vulnzProject, report.IssueId, &comment).SendEmail(true).Context(ctxRequest).Do()
241 if err != nil {
242 return fmt.Errorf("The request to insert the comment failed: %v", err)
243 }
244 logIssue(fmt.Sprintf("Added comment (type: %s, reason: %s)", action.Type, action.Reason), report)
245
246 return nil
247}
248
249func publishReport(message string, report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, ctx context.Context) error {
250 exp := newRequestExponentialBackOff()
251 err := backoff.Retry(func() error {
252 return publishReportAttempt(message, report, action, monorailSvc, ctx)
253 }, exp)
254 return err
255}
256
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200257func postTweet(report *VulnerabilityReport, twtrClient *gotwi.Client, ctx context.Context) error {
258 truncatedBugTitle := truncateStringWithEllipsis(report.Title, 240-(2+1+6+1+1+20))
259 url := fmt.Sprintf("iavm.xyz/b/vulnz/%d", report.IssueId)
260 tweetText := fmt.Sprintf("🦋 b/%d %s %s", report.IssueId, truncatedBugTitle, url)
261 body := &types.CreateInput{
262 Text: gotwi.String(tweetText),
263 }
264 _, err := managetweet.Create(ctx, twtrClient, body)
265 if err == nil {
266 logIssue(fmt.Sprintf("Tweeted: %s", tweetText), report)
267 }
268 return err
269}
270
271func performAction(report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, twtrClient *gotwi.Client, ctx context.Context) error {
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200272 var message string
273 if action.Reason == "fixedDeadline" {
274 deadlineAfterFixedInt := deadlineAfterFixed / (24 * time.Hour)
275 if action.Type == "warning" {
276 message = fmt.Sprintf("This report will be published in %d days, since at that time %d days will have passed since the vulnerability was fixed.\n\n_Please add the `%s` label if you want to stop the automatic disclosure._", warningPeriod/(24*time.Hour), deadlineAfterFixedInt, doNotPublishLabel)
277 } else {
278 message = fmt.Sprintf("**The vulnerability was fixed %d days ago** -- automatically publishing the vulnerability report.", deadlineAfterFixedInt)
279 }
280 } else if action.Reason == "disclosureDeadline" {
281 if action.Type == "warning" {
282 message = fmt.Sprintf("This report will be published in %d days, since at that time %d days will have passed since the vulnerability was reported.\n\n_Please add the `%s` label if you want to stop the automatic disclosure._", warningPeriod/(24*time.Hour), report.DeadlineDays, doNotPublishLabel)
283 } else {
284 message = fmt.Sprintf("**The %d-day deadline has been exceeded** -- automatically publishing the vulnerability report.", report.DeadlineDays)
285 }
Adrià Vilanova Martínez21037812023-04-10 19:52:07 +0200286 } else if action.Reason == "customDisclosureDeadline" {
287 if action.Type == "warning" {
288 message = fmt.Sprintf("This report will be published in %d days, as set in the PublishAt field.\n\n_Please unset `PublishAt` or add the `%s` label if you want to stop the automatic disclosure._", warningPeriod/(24*time.Hour), doNotPublishLabel)
289 } else {
290 message = "**The PublishAt date has passed** -- automatically publishing the vulnerability report."
291 }
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200292 }
293 err := publishReport(message, report, action, monorailSvc, ctx)
294 if err != nil {
295 return fmt.Errorf("Error trying to perform action type %s, for reason %s: %v", action.Type, action.Reason, err)
296 }
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200297
Adrià Vilanova Martínez6b1b2c72023-04-05 01:48:30 +0200298 if twtrClient != nil && action.Type == "disclosure" {
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200299 err := postTweet(report, twtrClient, ctx)
300 if err != nil {
301 return fmt.Errorf("Error trying to publish tweet: %v", err)
302 }
303 }
304
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200305 return nil
306}
307
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200308func handleIssue(issue *monorail.ProtoApiPb2V1IssueWrapper, monorailSvc *monorail.Service, twtrClient *gotwi.Client, ctx context.Context) error {
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200309 report, err := parseIssue(issue)
310 if err != nil {
311 return fmt.Errorf("Error parsing issue: %v", err)
312 }
313
314 actions := getTodayActions(report)
315
316 for _, action := range actions {
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200317 err = performAction(report, action, monorailSvc, twtrClient, ctx)
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200318 if err != nil {
319 return fmt.Errorf("Error handling %s action: %v", action.Type, err)
320 }
321 }
322
323 return nil
324}
325
326func main() {
327 log.Println("Starting vulnzybot...")
328
329 ctx := context.Background()
330
331 credentials, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS")
332 if !ok {
333 log.Println("GOOGLE_APPLICATION_CREDENTIALS environment variable is not set.")
334 return
335 }
336
337 monorailSvc, err := monorail.NewService(ctx, option.WithEndpoint(monorailEndpointURL), option.WithCredentialsFile(credentials))
338 if err != nil {
339 log.Fatalf("Can't create Monorail service: %v", err)
340 }
341
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200342 twtrToken, okToken := os.LookupEnv("TWITTER_OAUTH_TOKEN")
343 twtrSecret, okSecret := os.LookupEnv("TWITTER_OAUTH_TOKEN_SECRET")
344 var twtrClient *gotwi.Client
345 if okToken && okSecret {
346 in := &gotwi.NewClientInput{
347 AuthenticationMethod: gotwi.AuthenMethodOAuth1UserContext,
348 OAuthToken: twtrToken,
349 OAuthTokenSecret: twtrSecret,
350 }
351 twtrClient, err = gotwi.NewClient(in)
352 if err != nil {
353 log.Fatalf("Can't create Twitter client: %v", err)
354 }
355 }
356
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200357 var listResponse *monorail.ProtoApiPb2V1IssuesListResponse
358
359 exp := newRequestExponentialBackOff()
360 err = backoff.Retry(func() error {
361 var err error
362 listResponse, err = monorailSvc.Issues.List(vulnzProject).Can("all").Q("Type=VulnerabilityReport Restrict=View-Commit").MaxResults(100000).Do()
363 return err
364 }, exp)
365 if err != nil {
366 log.Fatalf("Can't list issues: %v", err)
367 }
368
369 for _, issue := range listResponse.Items {
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200370 err := handleIssue(issue, monorailSvc, twtrClient, ctx)
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200371 if err != nil {
372 log.Printf("[Issue %v] Error while handling issue: %v", issue.Id, err)
373 }
374 }
375}