blob: 1c10799b035debd003c57240e14e00d6643e4817 [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
46 // Date when the vulnerability report was sent to the vendor
47 Reported time.Time
48 // Number of days since the reported date when the vulnerability should be automatically disclosed
49 DeadlineDays int
50 // DeadlineDays, converted to time.Duration
51 DeadlineDuration time.Duration
52 // Date when the vulnerability report should be published if it has been fixed
53 PublishDateIfFixed time.Time
54 // Deadline when the vulnerability report should be published if not fixed
55 DeadlineTime time.Time
56 HasDoNotPublishLabel bool
57 HasRestrictedLabel bool
58}
59
60type Action struct {
61 MustPerform bool
62 // |Reason| can be `disclosureDeadline` (the deadline has been exceeded), `fixedDeadline` (the issue has been fixed and |deadlineAfterFixed| has passed)
63 Reason string
64 // |Type| can be `warning` (warning before actually disclosing the report), `disclosure` (the report is actually disclosed)
65 Type string
66}
67
68func parseIssue(issue *monorail.ProtoApiPb2V1IssueWrapper) (*VulnerabilityReport, error) {
69 report := VulnerabilityReport{IssueId: issue.Id}
70
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +020071 report.Title = issue.Title
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +020072 report.Status = issue.Status
73
74 var err error
75 report.StatusModified, err = time.Parse(dateTimeLayout, issue.StatusModified)
76 if err != nil {
77 return nil, err
78 }
79
80 deadlineFound := false
81 reportedFound := false
82 for _, field := range issue.FieldValues {
83 if field.FieldName == "Deadline" {
84 deadlineFound = true
85 report.DeadlineDays, err = strconv.Atoi(field.FieldValue)
86 report.DeadlineDuration = time.Duration(report.DeadlineDays) * 24 * time.Hour
87 if err != nil {
88 return nil, fmt.Errorf("Error parsing Deadline field: %v")
89 }
90 }
91
92 if field.FieldName == "Reported" {
93 reportedFound = true
94 report.Reported, err = time.Parse(dateLayout, field.FieldValue)
95 if err != nil {
96 return nil, fmt.Errorf("Error parsing Reported field: %v")
97 }
98 }
99 }
100 if !deadlineFound {
101 return nil, fmt.Errorf("The Deadline field isn't set.")
102 }
103 if !reportedFound {
104 return nil, fmt.Errorf("The Reported field isn't set.")
105 }
106
107 report.PublishDateIfFixed = report.StatusModified.Add(deadlineAfterFixed + gracePeriod)
108 report.DeadlineTime = report.Reported.Add(report.DeadlineDuration + gracePeriod)
109
110 report.HasDoNotPublishLabel = slices.Contains(issue.Labels, doNotPublishLabel)
111 report.HasRestrictedLabel = slices.Contains(issue.Labels, restrictedLabel)
112
113 return &report, nil
114}
115
116func logIssue(message string, report *VulnerabilityReport) {
117 log.Printf("[Issue %d] %s", report.IssueId, message)
118}
119
120// |actionType| can be one of the types allowed in |Action.Type|.
121func getActionPartial(report *VulnerabilityReport, actionType string) *Action {
122 now := time.Now()
123 isWarning := actionType == "warning"
124
125 // Take into account that warnings are published in advance (according to warningPeriod)
126 var (
127 publishDateIfFixed time.Time
128 deadlineTime time.Time
129 )
130 if isWarning {
131 publishDateIfFixed = report.PublishDateIfFixed.Add(-warningPeriod)
132 deadlineTime = report.DeadlineTime.Add(-warningPeriod)
133 } else {
134 publishDateIfFixed = report.PublishDateIfFixed
135 deadlineTime = report.DeadlineTime
136 }
137
138 if report.Status == "Fixed" || report.Status == "Verified" {
139 if (!isWarning && publishDateIfFixed.Before(now)) || (isWarning && dateEqual(publishDateIfFixed, now)) {
140 return &Action{
141 MustPerform: true,
142 Reason: "fixedDeadline",
143 Type: actionType,
144 }
145 }
146 return &Action{MustPerform: false}
147 }
148 if (!isWarning && deadlineTime.Before(now)) || (isWarning && dateEqual(deadlineTime, now)) {
149 return &Action{
150 MustPerform: true,
151 Reason: "disclosureDeadline",
152 Type: actionType,
153 }
154 }
155 return &Action{MustPerform: false}
156}
157
158// Get the actions that should be performed today on |report|
159func getTodayActions(report *VulnerabilityReport) []*Action {
160 // By default, all the actions will be set to |false|
161 var actions []*Action
162
163 // Do not publish any issue which has the DoNotPublish label set!
164 if report.HasDoNotPublishLabel {
165 logIssue(fmt.Sprintf("The %s label is set. Skipping.\n", doNotPublishLabel), report)
166 return actions
167 }
168
169 // Check that the report is not already published
170 if !report.HasRestrictedLabel {
171 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)
172 return actions
173 }
174
175 warningAction := getActionPartial(report, "warning")
176 if warningAction.MustPerform {
177 actions = append(actions, warningAction)
178 }
179 disclosureAction := getActionPartial(report, "disclosure")
180 if disclosureAction.MustPerform {
181 actions = append(actions, disclosureAction)
182 }
183 return actions
184}
185
186func newRequestExponentialBackOff() *backoff.ExponentialBackOff {
187 b := backoff.NewExponentialBackOff()
188 b.InitialInterval = 500 * time.Millisecond
189 b.RandomizationFactor = 0.5
190 b.Multiplier = 1.5
191 b.MaxInterval = 30 * time.Second
192 b.MaxElapsedTime = 5 * time.Minute
193 b.Reset()
194 return b
195}
196
197func publishReportAttempt(message string, report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, ctx context.Context) error {
198 ctxRequest, cancel := context.WithDeadline(ctx, time.Now().Add(45*time.Second))
199 defer cancel()
200
201 comment := monorail.ProtoApiPb2V1IssueCommentWrapper{
202 Content: message,
203 }
204
205 if action.Type == "disclosure" {
206 // Remove the label which restricts the bug from public view
207 labels := make([]string, 1)
208 labels[0] = "-" + restrictedLabel
209 comment.Updates = &monorail.ProtoApiPb2V1Update{Labels: labels}
210 }
211
212 _, err := monorailSvc.Issues.Comments.Insert(vulnzProject, report.IssueId, &comment).SendEmail(true).Context(ctxRequest).Do()
213 if err != nil {
214 return fmt.Errorf("The request to insert the comment failed: %v", err)
215 }
216 logIssue(fmt.Sprintf("Added comment (type: %s, reason: %s)", action.Type, action.Reason), report)
217
218 return nil
219}
220
221func publishReport(message string, report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, ctx context.Context) error {
222 exp := newRequestExponentialBackOff()
223 err := backoff.Retry(func() error {
224 return publishReportAttempt(message, report, action, monorailSvc, ctx)
225 }, exp)
226 return err
227}
228
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200229func postTweet(report *VulnerabilityReport, twtrClient *gotwi.Client, ctx context.Context) error {
230 truncatedBugTitle := truncateStringWithEllipsis(report.Title, 240-(2+1+6+1+1+20))
231 url := fmt.Sprintf("iavm.xyz/b/vulnz/%d", report.IssueId)
232 tweetText := fmt.Sprintf("🦋 b/%d %s %s", report.IssueId, truncatedBugTitle, url)
233 body := &types.CreateInput{
234 Text: gotwi.String(tweetText),
235 }
236 _, err := managetweet.Create(ctx, twtrClient, body)
237 if err == nil {
238 logIssue(fmt.Sprintf("Tweeted: %s", tweetText), report)
239 }
240 return err
241}
242
243func performAction(report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, twtrClient *gotwi.Client, ctx context.Context) error {
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200244 var message string
245 if action.Reason == "fixedDeadline" {
246 deadlineAfterFixedInt := deadlineAfterFixed / (24 * time.Hour)
247 if action.Type == "warning" {
248 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)
249 } else {
250 message = fmt.Sprintf("**The vulnerability was fixed %d days ago** -- automatically publishing the vulnerability report.", deadlineAfterFixedInt)
251 }
252 } else if action.Reason == "disclosureDeadline" {
253 if action.Type == "warning" {
254 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)
255 } else {
256 message = fmt.Sprintf("**The %d-day deadline has been exceeded** -- automatically publishing the vulnerability report.", report.DeadlineDays)
257 }
258 }
259 err := publishReport(message, report, action, monorailSvc, ctx)
260 if err != nil {
261 return fmt.Errorf("Error trying to perform action type %s, for reason %s: %v", action.Type, action.Reason, err)
262 }
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200263
264 if twtrClient != nil && action.Reason == "disclosureDeadline" && action.Type == "disclosure" {
265 err := postTweet(report, twtrClient, ctx)
266 if err != nil {
267 return fmt.Errorf("Error trying to publish tweet: %v", err)
268 }
269 }
270
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200271 return nil
272}
273
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200274func handleIssue(issue *monorail.ProtoApiPb2V1IssueWrapper, monorailSvc *monorail.Service, twtrClient *gotwi.Client, ctx context.Context) error {
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200275 report, err := parseIssue(issue)
276 if err != nil {
277 return fmt.Errorf("Error parsing issue: %v", err)
278 }
279
280 actions := getTodayActions(report)
281
282 for _, action := range actions {
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200283 err = performAction(report, action, monorailSvc, twtrClient, ctx)
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200284 if err != nil {
285 return fmt.Errorf("Error handling %s action: %v", action.Type, err)
286 }
287 }
288
289 return nil
290}
291
292func main() {
293 log.Println("Starting vulnzybot...")
294
295 ctx := context.Background()
296
297 credentials, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS")
298 if !ok {
299 log.Println("GOOGLE_APPLICATION_CREDENTIALS environment variable is not set.")
300 return
301 }
302
303 monorailSvc, err := monorail.NewService(ctx, option.WithEndpoint(monorailEndpointURL), option.WithCredentialsFile(credentials))
304 if err != nil {
305 log.Fatalf("Can't create Monorail service: %v", err)
306 }
307
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200308 twtrToken, okToken := os.LookupEnv("TWITTER_OAUTH_TOKEN")
309 twtrSecret, okSecret := os.LookupEnv("TWITTER_OAUTH_TOKEN_SECRET")
310 var twtrClient *gotwi.Client
311 if okToken && okSecret {
312 in := &gotwi.NewClientInput{
313 AuthenticationMethod: gotwi.AuthenMethodOAuth1UserContext,
314 OAuthToken: twtrToken,
315 OAuthTokenSecret: twtrSecret,
316 }
317 twtrClient, err = gotwi.NewClient(in)
318 if err != nil {
319 log.Fatalf("Can't create Twitter client: %v", err)
320 }
321 }
322
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200323 var listResponse *monorail.ProtoApiPb2V1IssuesListResponse
324
325 exp := newRequestExponentialBackOff()
326 err = backoff.Retry(func() error {
327 var err error
328 listResponse, err = monorailSvc.Issues.List(vulnzProject).Can("all").Q("Type=VulnerabilityReport Restrict=View-Commit").MaxResults(100000).Do()
329 return err
330 }, exp)
331 if err != nil {
332 log.Fatalf("Can't list issues: %v", err)
333 }
334
335 for _, issue := range listResponse.Items {
Adrià Vilanova Martíneze99c92a2023-04-04 02:55:34 +0200336 err := handleIssue(issue, monorailSvc, twtrClient, ctx)
Adrià Vilanova Martíneze73997b2022-07-19 18:58:55 +0200337 if err != nil {
338 log.Printf("[Issue %v] Error while handling issue: %v", issue.Id, err)
339 }
340 }
341}