| package main |
| |
| import ( |
| "context" |
| "fmt" |
| "log" |
| "os" |
| "strconv" |
| "time" |
| |
| "github.com/cenkalti/backoff/v4" |
| "github.com/michimani/gotwi" |
| "github.com/michimani/gotwi/tweet/managetweet" |
| "github.com/michimani/gotwi/tweet/managetweet/types" |
| "go.skia.org/infra/go/monorail/v1" |
| "golang.org/x/exp/slices" |
| "google.golang.org/api/option" |
| ) |
| |
| const ( |
| monorailEndpointURL = "https://bugs.avm99963.com/_ah/api/monorail/v1/" |
| vulnzProject = "vulnz" |
| doNotPublishLabel = "DoNotPublish" |
| restrictedLabel = "Restrict-View-Commit" |
| // Days after which the report for a fixed vulnerability will be automatically |
| // published. |
| deadlineAfterFixed = 30 * 24 * time.Hour |
| // Grace period added to the deadlines |
| gracePeriod = 1 * 24 * time.Hour |
| // Time to warn about the automatic disclosure before it happens |
| warningPeriod = 5 * 24 * time.Hour |
| // Layouts returned by the monorail package |
| dateTimeLayout = "2006-01-02T15:04:05" |
| dateLayout = "2006-01-02" |
| ) |
| |
| type VulnerabilityReport struct { |
| // ID of the vulnerability report/issue in Monorail |
| IssueId int64 |
| // Title of the report |
| Title string |
| // Issue status |
| Status string |
| // Date that the status was modified |
| StatusModified time.Time |
| // Whether a PublishAt field exists |
| HasPublishAt bool |
| // Custom date when the vulnerability should be published. Takes precedence over the default disclosure flow |
| PublishAt time.Time |
| // Date when the vulnerability report was sent to the vendor |
| Reported time.Time |
| // Number of days since the reported date when the vulnerability should be automatically disclosed |
| DeadlineDays int |
| // DeadlineDays, converted to time.Duration |
| DeadlineDuration time.Duration |
| // Date when the vulnerability report should be published if it has been fixed |
| PublishDateIfFixed time.Time |
| // Whether DeadlineTime could be calculated or not. If false, the vulnerability should not be published if not fixed |
| HasValidDeadlineTime bool |
| // Deadline when the vulnerability report should be published if not fixed |
| DeadlineTime time.Time |
| HasDoNotPublishLabel bool |
| HasRestrictedLabel bool |
| } |
| |
| type Action struct { |
| MustPerform bool |
| // |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) |
| Reason string |
| // |Type| can be `warning` (warning before actually disclosing the report), `disclosure` (the report is actually disclosed) |
| Type string |
| } |
| |
| func parseIssue(issue *monorail.ProtoApiPb2V1IssueWrapper) (*VulnerabilityReport, error) { |
| report := VulnerabilityReport{IssueId: issue.Id} |
| |
| report.Title = issue.Title |
| report.Status = issue.Status |
| |
| var err error |
| report.StatusModified, err = time.Parse(dateTimeLayout, issue.StatusModified) |
| if err != nil { |
| return nil, err |
| } |
| |
| deadlineFound := false |
| reportedFound := false |
| report.HasPublishAt = false |
| for _, field := range issue.FieldValues { |
| if field.FieldName == "Deadline" { |
| deadlineFound = true |
| report.DeadlineDays, err = strconv.Atoi(field.FieldValue) |
| report.DeadlineDuration = time.Duration(report.DeadlineDays) * 24 * time.Hour |
| if err != nil { |
| return nil, fmt.Errorf("Error parsing Deadline field: %v") |
| } |
| } |
| |
| if field.FieldName == "Reported" { |
| reportedFound = true |
| report.Reported, err = time.Parse(dateLayout, field.FieldValue) |
| if err != nil { |
| return nil, fmt.Errorf("Error parsing Reported field: %v") |
| } |
| } |
| |
| if field.FieldName == "PublishAt" { |
| report.PublishAt, err = time.Parse(dateLayout, field.FieldValue) |
| if err != nil { |
| return nil, fmt.Errorf("Error parsing PublishAt field: %v") |
| } |
| report.PublishAt = report.PublishAt.Add(gracePeriod) |
| report.HasPublishAt = true |
| } |
| } |
| |
| report.PublishDateIfFixed = report.StatusModified.Add(deadlineAfterFixed + gracePeriod) |
| if reportedFound && deadlineFound { |
| report.HasValidDeadlineTime = true |
| report.DeadlineTime = report.Reported.Add(report.DeadlineDuration + gracePeriod) |
| } else { |
| report.HasValidDeadlineTime = false |
| } |
| |
| report.HasDoNotPublishLabel = slices.Contains(issue.Labels, doNotPublishLabel) |
| report.HasRestrictedLabel = slices.Contains(issue.Labels, restrictedLabel) |
| |
| return &report, nil |
| } |
| |
| func logIssue(message string, report *VulnerabilityReport) { |
| log.Printf("[Issue %d] %s", report.IssueId, message) |
| } |
| |
| // |actionType| can be one of the types allowed in |Action.Type|. |
| func getActionPartial(report *VulnerabilityReport, actionType string) *Action { |
| now := time.Now() |
| isWarning := actionType == "warning" |
| |
| // Take into account that warnings are published in advance (according to warningPeriod) |
| var ( |
| customPublishAtDate time.Time |
| publishDateIfFixed time.Time |
| deadlineTime time.Time |
| ) |
| if isWarning { |
| customPublishAtDate = report.PublishAt.Add(-warningPeriod) |
| publishDateIfFixed = report.PublishDateIfFixed.Add(-warningPeriod) |
| deadlineTime = report.DeadlineTime.Add(-warningPeriod) |
| } else { |
| customPublishAtDate = report.PublishAt |
| publishDateIfFixed = report.PublishDateIfFixed |
| deadlineTime = report.DeadlineTime |
| } |
| |
| if report.HasPublishAt { |
| if (!isWarning && customPublishAtDate.Before(now)) || (isWarning && dateEqual(customPublishAtDate, now)) { |
| return &Action{ |
| MustPerform: true, |
| Reason: "customDisclosureDeadline", |
| Type: actionType, |
| } |
| } |
| return &Action{MustPerform: false} |
| } |
| if report.Status == "Fixed" || report.Status == "Verified" { |
| if (!isWarning && publishDateIfFixed.Before(now)) || (isWarning && dateEqual(publishDateIfFixed, now)) { |
| return &Action{ |
| MustPerform: true, |
| Reason: "fixedDeadline", |
| Type: actionType, |
| } |
| } |
| return &Action{MustPerform: false} |
| } |
| if report.HasValidDeadlineTime && (!isWarning && deadlineTime.Before(now)) || (isWarning && dateEqual(deadlineTime, now)) { |
| return &Action{ |
| MustPerform: true, |
| Reason: "disclosureDeadline", |
| Type: actionType, |
| } |
| } |
| return &Action{MustPerform: false} |
| } |
| |
| // Get the actions that should be performed today on |report| |
| func getTodayActions(report *VulnerabilityReport) []*Action { |
| // By default, all the actions will be set to |false| |
| var actions []*Action |
| |
| // Do not publish any issue which has the DoNotPublish label set! |
| if report.HasDoNotPublishLabel { |
| logIssue(fmt.Sprintf("The %s label is set. Skipping.\n", doNotPublishLabel), report) |
| return actions |
| } |
| |
| // Check that the report is not already published |
| if !report.HasRestrictedLabel { |
| 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) |
| return actions |
| } |
| |
| warningAction := getActionPartial(report, "warning") |
| if warningAction.MustPerform { |
| actions = append(actions, warningAction) |
| } |
| disclosureAction := getActionPartial(report, "disclosure") |
| if disclosureAction.MustPerform { |
| actions = append(actions, disclosureAction) |
| } |
| return actions |
| } |
| |
| func newRequestExponentialBackOff() *backoff.ExponentialBackOff { |
| b := backoff.NewExponentialBackOff() |
| b.InitialInterval = 500 * time.Millisecond |
| b.RandomizationFactor = 0.5 |
| b.Multiplier = 1.5 |
| b.MaxInterval = 30 * time.Second |
| b.MaxElapsedTime = 5 * time.Minute |
| b.Reset() |
| return b |
| } |
| |
| func publishReportAttempt(message string, report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, ctx context.Context) error { |
| ctxRequest, cancel := context.WithDeadline(ctx, time.Now().Add(45*time.Second)) |
| defer cancel() |
| |
| comment := monorail.ProtoApiPb2V1IssueCommentWrapper{ |
| Content: message, |
| } |
| |
| if action.Type == "disclosure" { |
| // Remove the label which restricts the bug from public view |
| labels := make([]string, 1) |
| labels[0] = "-" + restrictedLabel |
| comment.Updates = &monorail.ProtoApiPb2V1Update{Labels: labels} |
| } |
| |
| _, err := monorailSvc.Issues.Comments.Insert(vulnzProject, report.IssueId, &comment).SendEmail(true).Context(ctxRequest).Do() |
| if err != nil { |
| return fmt.Errorf("The request to insert the comment failed: %v", err) |
| } |
| logIssue(fmt.Sprintf("Added comment (type: %s, reason: %s)", action.Type, action.Reason), report) |
| |
| return nil |
| } |
| |
| func publishReport(message string, report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, ctx context.Context) error { |
| exp := newRequestExponentialBackOff() |
| err := backoff.Retry(func() error { |
| return publishReportAttempt(message, report, action, monorailSvc, ctx) |
| }, exp) |
| return err |
| } |
| |
| func postTweet(report *VulnerabilityReport, twtrClient *gotwi.Client, ctx context.Context) error { |
| truncatedBugTitle := truncateStringWithEllipsis(report.Title, 240-(2+1+6+1+1+20)) |
| url := fmt.Sprintf("iavm.xyz/b/vulnz/%d", report.IssueId) |
| tweetText := fmt.Sprintf("🦋 b/%d %s %s", report.IssueId, truncatedBugTitle, url) |
| body := &types.CreateInput{ |
| Text: gotwi.String(tweetText), |
| } |
| _, err := managetweet.Create(ctx, twtrClient, body) |
| if err == nil { |
| logIssue(fmt.Sprintf("Tweeted: %s", tweetText), report) |
| } |
| return err |
| } |
| |
| func performAction(report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, twtrClient *gotwi.Client, ctx context.Context) error { |
| var message string |
| if action.Reason == "fixedDeadline" { |
| deadlineAfterFixedInt := deadlineAfterFixed / (24 * time.Hour) |
| if action.Type == "warning" { |
| 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) |
| } else { |
| message = fmt.Sprintf("**The vulnerability was fixed %d days ago** -- automatically publishing the vulnerability report.", deadlineAfterFixedInt) |
| } |
| } else if action.Reason == "disclosureDeadline" { |
| if action.Type == "warning" { |
| 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) |
| } else { |
| message = fmt.Sprintf("**The %d-day deadline has been exceeded** -- automatically publishing the vulnerability report.", report.DeadlineDays) |
| } |
| } else if action.Reason == "customDisclosureDeadline" { |
| if action.Type == "warning" { |
| 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) |
| } else { |
| message = "**The PublishAt date has passed** -- automatically publishing the vulnerability report." |
| } |
| } |
| err := publishReport(message, report, action, monorailSvc, ctx) |
| if err != nil { |
| return fmt.Errorf("Error trying to perform action type %s, for reason %s: %v", action.Type, action.Reason, err) |
| } |
| |
| if twtrClient != nil && action.Type == "disclosure" { |
| err := postTweet(report, twtrClient, ctx) |
| if err != nil { |
| return fmt.Errorf("Error trying to publish tweet: %v", err) |
| } |
| } |
| |
| return nil |
| } |
| |
| func handleIssue(issue *monorail.ProtoApiPb2V1IssueWrapper, monorailSvc *monorail.Service, twtrClient *gotwi.Client, ctx context.Context) error { |
| report, err := parseIssue(issue) |
| if err != nil { |
| return fmt.Errorf("Error parsing issue: %v", err) |
| } |
| |
| actions := getTodayActions(report) |
| |
| for _, action := range actions { |
| err = performAction(report, action, monorailSvc, twtrClient, ctx) |
| if err != nil { |
| return fmt.Errorf("Error handling %s action: %v", action.Type, err) |
| } |
| } |
| |
| return nil |
| } |
| |
| func main() { |
| log.Println("Starting vulnzybot...") |
| |
| ctx := context.Background() |
| |
| credentials, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS") |
| if !ok { |
| log.Println("GOOGLE_APPLICATION_CREDENTIALS environment variable is not set.") |
| return |
| } |
| |
| monorailSvc, err := monorail.NewService(ctx, option.WithEndpoint(monorailEndpointURL), option.WithCredentialsFile(credentials)) |
| if err != nil { |
| log.Fatalf("Can't create Monorail service: %v", err) |
| } |
| |
| twtrToken, okToken := os.LookupEnv("TWITTER_OAUTH_TOKEN") |
| twtrSecret, okSecret := os.LookupEnv("TWITTER_OAUTH_TOKEN_SECRET") |
| var twtrClient *gotwi.Client |
| if okToken && okSecret { |
| in := &gotwi.NewClientInput{ |
| AuthenticationMethod: gotwi.AuthenMethodOAuth1UserContext, |
| OAuthToken: twtrToken, |
| OAuthTokenSecret: twtrSecret, |
| } |
| twtrClient, err = gotwi.NewClient(in) |
| if err != nil { |
| log.Fatalf("Can't create Twitter client: %v", err) |
| } |
| } |
| |
| var listResponse *monorail.ProtoApiPb2V1IssuesListResponse |
| |
| exp := newRequestExponentialBackOff() |
| err = backoff.Retry(func() error { |
| var err error |
| listResponse, err = monorailSvc.Issues.List(vulnzProject).Can("all").Q("Type=VulnerabilityReport Restrict=View-Commit").MaxResults(100000).Do() |
| return err |
| }, exp) |
| if err != nil { |
| log.Fatalf("Can't list issues: %v", err) |
| } |
| |
| for _, issue := range listResponse.Items { |
| err := handleIssue(issue, monorailSvc, twtrClient, ctx) |
| if err != nil { |
| log.Printf("[Issue %v] Error while handling issue: %v", issue.Id, err) |
| } |
| } |
| } |