Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "log" |
| 7 | "os" |
| 8 | "strconv" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/cenkalti/backoff/v4" |
Adrià Vilanova Martínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 12 | "github.com/michimani/gotwi" |
| 13 | "github.com/michimani/gotwi/tweet/managetweet" |
| 14 | "github.com/michimani/gotwi/tweet/managetweet/types" |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 15 | "go.skia.org/infra/go/monorail/v1" |
| 16 | "golang.org/x/exp/slices" |
| 17 | "google.golang.org/api/option" |
| 18 | ) |
| 19 | |
| 20 | const ( |
| 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 | |
| 37 | type VulnerabilityReport struct { |
| 38 | // ID of the vulnerability report/issue in Monorail |
| 39 | IssueId int64 |
Adrià Vilanova Martínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 40 | // Title of the report |
| 41 | Title string |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 42 | // Issue status |
| 43 | Status string |
| 44 | // Date that the status was modified |
| 45 | StatusModified time.Time |
Adrià Vilanova Martínez | 2103781 | 2023-04-10 19:52:07 +0200 | [diff] [blame] | 46 | // 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ínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 50 | // 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ínez | 2103781 | 2023-04-10 19:52:07 +0200 | [diff] [blame] | 58 | // Whether DeadlineTime could be calculated or not. If false, the vulnerability should not be published if not fixed |
| 59 | HasValidDeadlineTime bool |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 60 | // Deadline when the vulnerability report should be published if not fixed |
| 61 | DeadlineTime time.Time |
| 62 | HasDoNotPublishLabel bool |
| 63 | HasRestrictedLabel bool |
| 64 | } |
| 65 | |
| 66 | type Action struct { |
| 67 | MustPerform bool |
Adrià Vilanova Martínez | 2103781 | 2023-04-10 19:52:07 +0200 | [diff] [blame] | 68 | // |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ínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 69 | Reason string |
| 70 | // |Type| can be `warning` (warning before actually disclosing the report), `disclosure` (the report is actually disclosed) |
| 71 | Type string |
| 72 | } |
| 73 | |
| 74 | func parseIssue(issue *monorail.ProtoApiPb2V1IssueWrapper) (*VulnerabilityReport, error) { |
| 75 | report := VulnerabilityReport{IssueId: issue.Id} |
| 76 | |
Adrià Vilanova Martínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 77 | report.Title = issue.Title |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 78 | 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ínez | 2103781 | 2023-04-10 19:52:07 +0200 | [diff] [blame] | 88 | report.HasPublishAt = false |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 89 | 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ínez | 2103781 | 2023-04-10 19:52:07 +0200 | [diff] [blame] | 106 | |
| 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ínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 115 | } |
| 116 | |
| 117 | report.PublishDateIfFixed = report.StatusModified.Add(deadlineAfterFixed + gracePeriod) |
Adrià Vilanova Martínez | 2103781 | 2023-04-10 19:52:07 +0200 | [diff] [blame] | 118 | 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ínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 124 | |
| 125 | report.HasDoNotPublishLabel = slices.Contains(issue.Labels, doNotPublishLabel) |
| 126 | report.HasRestrictedLabel = slices.Contains(issue.Labels, restrictedLabel) |
| 127 | |
| 128 | return &report, nil |
| 129 | } |
| 130 | |
| 131 | func 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|. |
| 136 | func 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ínez | 2103781 | 2023-04-10 19:52:07 +0200 | [diff] [blame] | 142 | customPublishAtDate time.Time |
| 143 | publishDateIfFixed time.Time |
| 144 | deadlineTime time.Time |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 145 | ) |
| 146 | if isWarning { |
Adrià Vilanova Martínez | 2103781 | 2023-04-10 19:52:07 +0200 | [diff] [blame] | 147 | customPublishAtDate = report.PublishAt.Add(-warningPeriod) |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 148 | publishDateIfFixed = report.PublishDateIfFixed.Add(-warningPeriod) |
| 149 | deadlineTime = report.DeadlineTime.Add(-warningPeriod) |
| 150 | } else { |
Adrià Vilanova Martínez | 2103781 | 2023-04-10 19:52:07 +0200 | [diff] [blame] | 151 | customPublishAtDate = report.PublishAt |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 152 | publishDateIfFixed = report.PublishDateIfFixed |
| 153 | deadlineTime = report.DeadlineTime |
| 154 | } |
| 155 | |
Adrià Vilanova Martínez | 2103781 | 2023-04-10 19:52:07 +0200 | [diff] [blame] | 156 | 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ínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 166 | 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ínez | 2103781 | 2023-04-10 19:52:07 +0200 | [diff] [blame] | 176 | if report.HasValidDeadlineTime && (!isWarning && deadlineTime.Before(now)) || (isWarning && dateEqual(deadlineTime, now)) { |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 177 | 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| |
| 187 | func 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 | |
| 214 | func 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 | |
| 225 | func 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 | |
| 249 | func 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ínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 257 | func 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 | |
| 271 | func performAction(report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, twtrClient *gotwi.Client, ctx context.Context) error { |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 272 | 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ínez | 2103781 | 2023-04-10 19:52:07 +0200 | [diff] [blame] | 286 | } 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ínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 292 | } |
| 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ínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 297 | |
Adrià Vilanova Martínez | 6b1b2c7 | 2023-04-05 01:48:30 +0200 | [diff] [blame] | 298 | if twtrClient != nil && action.Type == "disclosure" { |
Adrià Vilanova Martínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 299 | 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ínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 305 | return nil |
| 306 | } |
| 307 | |
Adrià Vilanova Martínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 308 | func handleIssue(issue *monorail.ProtoApiPb2V1IssueWrapper, monorailSvc *monorail.Service, twtrClient *gotwi.Client, ctx context.Context) error { |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 309 | 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ínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 317 | err = performAction(report, action, monorailSvc, twtrClient, ctx) |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 318 | if err != nil { |
| 319 | return fmt.Errorf("Error handling %s action: %v", action.Type, err) |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | return nil |
| 324 | } |
| 325 | |
| 326 | func 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ínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 342 | 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ínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 357 | 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ínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 370 | err := handleIssue(issue, monorailSvc, twtrClient, ctx) |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 371 | if err != nil { |
| 372 | log.Printf("[Issue %v] Error while handling issue: %v", issue.Id, err) |
| 373 | } |
| 374 | } |
| 375 | } |