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)
+		}
+	}
+}