First prototype
Change-Id: I66ebdd65b0323df4d96576c71916f19f0d03ea02
diff --git a/vulnzybot.go b/vulnzybot.go
new file mode 100644
index 0000000..a954b35
--- /dev/null
+++ b/vulnzybot.go
@@ -0,0 +1,298 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/cenkalti/backoff/v4"
+ "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
+ // Issue status
+ Status string
+ // Date that the status was modified
+ StatusModified 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
+ // 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)
+ 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.Status = issue.Status
+
+ var err error
+ report.StatusModified, err = time.Parse(dateTimeLayout, issue.StatusModified)
+ if err != nil {
+ return nil, err
+ }
+
+ deadlineFound := false
+ reportedFound := 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 !deadlineFound {
+ return nil, fmt.Errorf("The Deadline field isn't set.")
+ }
+ if !reportedFound {
+ return nil, fmt.Errorf("The Reported field isn't set.")
+ }
+
+ report.PublishDateIfFixed = report.StatusModified.Add(deadlineAfterFixed + gracePeriod)
+ report.DeadlineTime = report.Reported.Add(report.DeadlineDuration + gracePeriod)
+
+ 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 (
+ publishDateIfFixed time.Time
+ deadlineTime time.Time
+ )
+ if isWarning {
+ publishDateIfFixed = report.PublishDateIfFixed.Add(-warningPeriod)
+ deadlineTime = report.DeadlineTime.Add(-warningPeriod)
+ } else {
+ publishDateIfFixed = report.PublishDateIfFixed
+ deadlineTime = report.DeadlineTime
+ }
+
+ 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 (!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 performAction(report *VulnerabilityReport, action *Action, monorailSvc *monorail.Service, 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)
+ }
+ }
+ 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)
+ }
+ return nil
+}
+
+func handleIssue(issue *monorail.ProtoApiPb2V1IssueWrapper, monorailSvc *monorail.Service, 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, 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)
+ }
+
+ 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, ctx)
+ if err != nil {
+ log.Printf("[Issue %v] Error while handling issue: %v", issue.Id, err)
+ }
+ }
+}