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" |
| 12 | "go.skia.org/infra/go/monorail/v1" |
| 13 | "golang.org/x/exp/slices" |
| 14 | "google.golang.org/api/option" |
| 15 | ) |
| 16 | |
| 17 | const ( |
| 18 | monorailEndpointURL = "https://bugs.avm99963.com/_ah/api/monorail/v1/" |
| 19 | vulnzProject = "vulnz" |
| 20 | doNotPublishLabel = "DoNotPublish" |
| 21 | restrictedLabel = "Restrict-View-Commit" |
| 22 | // Days after which the report for a fixed vulnerability will be automatically |
| 23 | // published. |
| 24 | deadlineAfterFixed = 30 * 24 * time.Hour |
| 25 | // Grace period added to the deadlines |
| 26 | gracePeriod = 1 * 24 * time.Hour |
| 27 | // Time to warn about the automatic disclosure before it happens |
| 28 | warningPeriod = 5 * 24 * time.Hour |
| 29 | // Layouts returned by the monorail package |
| 30 | dateTimeLayout = "2006-01-02T15:04:05" |
| 31 | dateLayout = "2006-01-02" |
| 32 | ) |
| 33 | |
| 34 | type VulnerabilityReport struct { |
| 35 | // ID of the vulnerability report/issue in Monorail |
| 36 | IssueId int64 |
| 37 | // Issue status |
| 38 | Status string |
| 39 | // Date that the status was modified |
| 40 | StatusModified time.Time |
| 41 | // Date when the vulnerability report was sent to the vendor |
| 42 | Reported time.Time |
| 43 | // Number of days since the reported date when the vulnerability should be automatically disclosed |
| 44 | DeadlineDays int |
| 45 | // DeadlineDays, converted to time.Duration |
| 46 | DeadlineDuration time.Duration |
| 47 | // Date when the vulnerability report should be published if it has been fixed |
| 48 | PublishDateIfFixed time.Time |
| 49 | // Deadline when the vulnerability report should be published if not fixed |
| 50 | DeadlineTime time.Time |
| 51 | HasDoNotPublishLabel bool |
| 52 | HasRestrictedLabel bool |
| 53 | } |
| 54 | |
| 55 | type Action struct { |
| 56 | MustPerform bool |
| 57 | // |Reason| can be `disclosureDeadline` (the deadline has been exceeded), `fixedDeadline` (the issue has been fixed and |deadlineAfterFixed| has passed) |
| 58 | Reason string |
| 59 | // |Type| can be `warning` (warning before actually disclosing the report), `disclosure` (the report is actually disclosed) |
| 60 | Type string |
| 61 | } |
| 62 | |
| 63 | func parseIssue(issue *monorail.ProtoApiPb2V1IssueWrapper) (*VulnerabilityReport, error) { |
| 64 | report := VulnerabilityReport{IssueId: issue.Id} |
| 65 | |
| 66 | report.Status = issue.Status |
| 67 | |
| 68 | var err error |
| 69 | report.StatusModified, err = time.Parse(dateTimeLayout, issue.StatusModified) |
| 70 | if err != nil { |
| 71 | return nil, err |
| 72 | } |
| 73 | |
| 74 | deadlineFound := false |
| 75 | reportedFound := false |
| 76 | for _, field := range issue.FieldValues { |
| 77 | if field.FieldName == "Deadline" { |
| 78 | deadlineFound = true |
| 79 | report.DeadlineDays, err = strconv.Atoi(field.FieldValue) |
| 80 | report.DeadlineDuration = time.Duration(report.DeadlineDays) * 24 * time.Hour |
| 81 | if err != nil { |
| 82 | return nil, fmt.Errorf("Error parsing Deadline field: %v") |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | if field.FieldName == "Reported" { |
| 87 | reportedFound = true |
| 88 | report.Reported, err = time.Parse(dateLayout, field.FieldValue) |
| 89 | if err != nil { |
| 90 | return nil, fmt.Errorf("Error parsing Reported field: %v") |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | if !deadlineFound { |
| 95 | return nil, fmt.Errorf("The Deadline field isn't set.") |
| 96 | } |
| 97 | if !reportedFound { |
| 98 | return nil, fmt.Errorf("The Reported field isn't set.") |
| 99 | } |
| 100 | |
| 101 | report.PublishDateIfFixed = report.StatusModified.Add(deadlineAfterFixed + gracePeriod) |
| 102 | report.DeadlineTime = report.Reported.Add(report.DeadlineDuration + gracePeriod) |
| 103 | |
| 104 | report.HasDoNotPublishLabel = slices.Contains(issue.Labels, doNotPublishLabel) |
| 105 | report.HasRestrictedLabel = slices.Contains(issue.Labels, restrictedLabel) |
| 106 | |
| 107 | return &report, nil |
| 108 | } |
| 109 | |
| 110 | func logIssue(message string, report *VulnerabilityReport) { |
| 111 | log.Printf("[Issue %d] %s", report.IssueId, message) |
| 112 | } |
| 113 | |
| 114 | // |actionType| can be one of the types allowed in |Action.Type|. |
| 115 | func getActionPartial(report *VulnerabilityReport, actionType string) *Action { |
| 116 | now := time.Now() |
| 117 | isWarning := actionType == "warning" |
| 118 | |
| 119 | // Take into account that warnings are published in advance (according to warningPeriod) |
| 120 | var ( |
| 121 | publishDateIfFixed time.Time |
| 122 | deadlineTime time.Time |
| 123 | ) |
| 124 | if isWarning { |
| 125 | publishDateIfFixed = report.PublishDateIfFixed.Add(-warningPeriod) |
| 126 | deadlineTime = report.DeadlineTime.Add(-warningPeriod) |
| 127 | } else { |
| 128 | publishDateIfFixed = report.PublishDateIfFixed |
| 129 | deadlineTime = report.DeadlineTime |
| 130 | } |
| 131 | |
| 132 | if report.Status == "Fixed" || report.Status == "Verified" { |
| 133 | if (!isWarning && publishDateIfFixed.Before(now)) || (isWarning && dateEqual(publishDateIfFixed, now)) { |
| 134 | return &Action{ |
| 135 | MustPerform: true, |
| 136 | Reason: "fixedDeadline", |
| 137 | Type: actionType, |
| 138 | } |
| 139 | } |
| 140 | return &Action{MustPerform: false} |
| 141 | } |
| 142 | if (!isWarning && deadlineTime.Before(now)) || (isWarning && dateEqual(deadlineTime, now)) { |
| 143 | return &Action{ |
| 144 | MustPerform: true, |
| 145 | Reason: "disclosureDeadline", |
| 146 | Type: actionType, |
| 147 | } |
| 148 | } |
| 149 | return &Action{MustPerform: false} |
| 150 | } |
| 151 | |
| 152 | // Get the actions that should be performed today on |report| |
| 153 | func getTodayActions(report *VulnerabilityReport) []*Action { |
| 154 | // By default, all the actions will be set to |false| |
| 155 | var actions []*Action |
| 156 | |
| 157 | // Do not publish any issue which has the DoNotPublish label set! |
| 158 | if report.HasDoNotPublishLabel { |
| 159 | logIssue(fmt.Sprintf("The %s label is set. Skipping.\n", doNotPublishLabel), report) |
| 160 | return actions |
| 161 | } |
| 162 | |
| 163 | // Check that the report is not already published |
| 164 | if !report.HasRestrictedLabel { |
| 165 | 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) |
| 166 | return actions |
| 167 | } |
| 168 | |
| 169 | warningAction := getActionPartial(report, "warning") |
| 170 | if warningAction.MustPerform { |
| 171 | actions = append(actions, warningAction) |
| 172 | } |
| 173 | disclosureAction := getActionPartial(report, "disclosure") |
| 174 | if disclosureAction.MustPerform { |
| 175 | actions = append(actions, disclosureAction) |
| 176 | } |
| 177 | return actions |
| 178 | } |
| 179 | |
| 180 | func newRequestExponentialBackOff() *backoff.ExponentialBackOff { |
| 181 | b := backoff.NewExponentialBackOff() |
| 182 | b.InitialInterval = 500 * time.Millisecond |
| 183 | b.RandomizationFactor = 0.5 |
| 184 | b.Multiplier = 1.5 |
| 185 | b.MaxInterval = 30 * time.Second |
| 186 | b.MaxElapsedTime = 5 * time.Minute |
| 187 | b.Reset() |
| 188 | return b |
| 189 | } |
| 190 | |
| 191 | func publishReportAttempt(message string, report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, ctx context.Context) error { |
| 192 | ctxRequest, cancel := context.WithDeadline(ctx, time.Now().Add(45*time.Second)) |
| 193 | defer cancel() |
| 194 | |
| 195 | comment := monorail.ProtoApiPb2V1IssueCommentWrapper{ |
| 196 | Content: message, |
| 197 | } |
| 198 | |
| 199 | if action.Type == "disclosure" { |
| 200 | // Remove the label which restricts the bug from public view |
| 201 | labels := make([]string, 1) |
| 202 | labels[0] = "-" + restrictedLabel |
| 203 | comment.Updates = &monorail.ProtoApiPb2V1Update{Labels: labels} |
| 204 | } |
| 205 | |
| 206 | _, err := monorailSvc.Issues.Comments.Insert(vulnzProject, report.IssueId, &comment).SendEmail(true).Context(ctxRequest).Do() |
| 207 | if err != nil { |
| 208 | return fmt.Errorf("The request to insert the comment failed: %v", err) |
| 209 | } |
| 210 | logIssue(fmt.Sprintf("Added comment (type: %s, reason: %s)", action.Type, action.Reason), report) |
| 211 | |
| 212 | return nil |
| 213 | } |
| 214 | |
| 215 | func publishReport(message string, report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, ctx context.Context) error { |
| 216 | exp := newRequestExponentialBackOff() |
| 217 | err := backoff.Retry(func() error { |
| 218 | return publishReportAttempt(message, report, action, monorailSvc, ctx) |
| 219 | }, exp) |
| 220 | return err |
| 221 | } |
| 222 | |
| 223 | func performAction(report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, ctx context.Context) error { |
| 224 | var message string |
| 225 | if action.Reason == "fixedDeadline" { |
| 226 | deadlineAfterFixedInt := deadlineAfterFixed / (24 * time.Hour) |
| 227 | if action.Type == "warning" { |
| 228 | 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) |
| 229 | } else { |
| 230 | message = fmt.Sprintf("**The vulnerability was fixed %d days ago** -- automatically publishing the vulnerability report.", deadlineAfterFixedInt) |
| 231 | } |
| 232 | } else if action.Reason == "disclosureDeadline" { |
| 233 | if action.Type == "warning" { |
| 234 | 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) |
| 235 | } else { |
| 236 | message = fmt.Sprintf("**The %d-day deadline has been exceeded** -- automatically publishing the vulnerability report.", report.DeadlineDays) |
| 237 | } |
| 238 | } |
| 239 | err := publishReport(message, report, action, monorailSvc, ctx) |
| 240 | if err != nil { |
| 241 | return fmt.Errorf("Error trying to perform action type %s, for reason %s: %v", action.Type, action.Reason, err) |
| 242 | } |
| 243 | return nil |
| 244 | } |
| 245 | |
| 246 | func handleIssue(issue *monorail.ProtoApiPb2V1IssueWrapper, monorailSvc *monorail.Service, ctx context.Context) error { |
| 247 | report, err := parseIssue(issue) |
| 248 | if err != nil { |
| 249 | return fmt.Errorf("Error parsing issue: %v", err) |
| 250 | } |
| 251 | |
| 252 | actions := getTodayActions(report) |
| 253 | |
| 254 | for _, action := range actions { |
| 255 | err = performAction(report, action, monorailSvc, ctx) |
| 256 | if err != nil { |
| 257 | return fmt.Errorf("Error handling %s action: %v", action.Type, err) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | return nil |
| 262 | } |
| 263 | |
| 264 | func main() { |
| 265 | log.Println("Starting vulnzybot...") |
| 266 | |
| 267 | ctx := context.Background() |
| 268 | |
| 269 | credentials, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS") |
| 270 | if !ok { |
| 271 | log.Println("GOOGLE_APPLICATION_CREDENTIALS environment variable is not set.") |
| 272 | return |
| 273 | } |
| 274 | |
| 275 | monorailSvc, err := monorail.NewService(ctx, option.WithEndpoint(monorailEndpointURL), option.WithCredentialsFile(credentials)) |
| 276 | if err != nil { |
| 277 | log.Fatalf("Can't create Monorail service: %v", err) |
| 278 | } |
| 279 | |
| 280 | var listResponse *monorail.ProtoApiPb2V1IssuesListResponse |
| 281 | |
| 282 | exp := newRequestExponentialBackOff() |
| 283 | err = backoff.Retry(func() error { |
| 284 | var err error |
| 285 | listResponse, err = monorailSvc.Issues.List(vulnzProject).Can("all").Q("Type=VulnerabilityReport Restrict=View-Commit").MaxResults(100000).Do() |
| 286 | return err |
| 287 | }, exp) |
| 288 | if err != nil { |
| 289 | log.Fatalf("Can't list issues: %v", err) |
| 290 | } |
| 291 | |
| 292 | for _, issue := range listResponse.Items { |
| 293 | err := handleIssue(issue, monorailSvc, ctx) |
| 294 | if err != nil { |
| 295 | log.Printf("[Issue %v] Error while handling issue: %v", issue.Id, err) |
| 296 | } |
| 297 | } |
| 298 | } |