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 |
| 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 | |
| 60 | type 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 | |
| 68 | func parseIssue(issue *monorail.ProtoApiPb2V1IssueWrapper) (*VulnerabilityReport, error) { |
| 69 | report := VulnerabilityReport{IssueId: issue.Id} |
| 70 | |
Adrià Vilanova Martínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 71 | report.Title = issue.Title |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 72 | 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 | |
| 116 | func 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|. |
| 121 | func 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| |
| 159 | func 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 | |
| 186 | func 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 | |
| 197 | func 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 | |
| 221 | func 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ínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 229 | func 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 | |
| 243 | 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] | 244 | 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ínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 263 | |
Adrià Vilanova Martínez | 6b1b2c7 | 2023-04-05 01:48:30 +0200 | [diff] [blame^] | 264 | if twtrClient != nil && action.Type == "disclosure" { |
Adrià Vilanova Martínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 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ínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 271 | return nil |
| 272 | } |
| 273 | |
Adrià Vilanova Martínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 274 | 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] | 275 | 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ínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 283 | err = performAction(report, action, monorailSvc, twtrClient, ctx) |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 284 | if err != nil { |
| 285 | return fmt.Errorf("Error handling %s action: %v", action.Type, err) |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | return nil |
| 290 | } |
| 291 | |
| 292 | func 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ínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 308 | 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ínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 323 | 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ínez | e99c92a | 2023-04-04 02:55:34 +0200 | [diff] [blame] | 336 | err := handleIssue(issue, monorailSvc, twtrClient, ctx) |
Adrià Vilanova Martínez | e73997b | 2022-07-19 18:58:55 +0200 | [diff] [blame] | 337 | if err != nil { |
| 338 | log.Printf("[Issue %v] Error while handling issue: %v", issue.Id, err) |
| 339 | } |
| 340 | } |
| 341 | } |