blob: 19f191ae2b6139a2c7d75fe5bb0cb9eb034d2592 [file]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package task
import (
"bytes"
"errors"
"fmt"
"net/http"
"net/mail"
"regexp"
"slices"
"strings"
"text/template"
"time"
"github.com/google/go-github/v74/github"
"golang.org/x/build/gerrit"
"golang.org/x/build/internal/relui/groups"
wf "golang.org/x/build/internal/workflow"
"golang.org/x/build/relmeta"
"golang.org/x/mod/semver"
"golang.org/x/sync/errgroup"
"golang.org/x/vulndb/report"
)
type PrivXPatch struct {
Git *Git
PublicGerrit GerritClient
PrivateGerrit GerritClient
// PublicRepoURL returns a git clone URL for repo
PublicRepoURL func(repo string) string
GitHub GitHubClientInterface
ApproveAction func(*wf.TaskContext) error
SendMail func(*wf.TaskContext, MailHeader, MailContent) error
AnnounceMailHeader MailHeader
AwaitAnnounceMail func(*wf.TaskContext, SentMail) (string, error)
}
func (x *PrivXPatch) NewDefinition(tagx *TagXReposTasks) *wf.Definition {
var (
wd = wf.New(wf.ACL{Groups: []string{groups.SecurityTeam}})
// TODO(nealpatel): SecurityMilestoneParameter says "Go release" which
// is technically incorrect documentation for the parameter here.
milestoneNum = wf.Param(wd, SecurityMilestoneParameter)
targetRepo = wf.Param(wd, wf.ParamDef[string]{Name: "Repository name", Example: "net"})
// TODO: probably always want to skip, might make sense to not include this
skipPostSubmit = wf.Param(wd, wf.ParamDef[bool]{Name: "Skip post submit result (optional)", ParamType: wf.Bool})
reviewers = wf.Param(wd, reviewersParam) // We don't fill this.
)
availableRepos := wf.Task0(wd, "Load all repositories", tagx.SelectRepos)
rm := wf.Task1(wd, "Pull release milestone", x.PullMilestone, milestoneNum)
patches := wf.Task3(wd, "Get changes for target x repo", x.FilterPatches, rm, targetRepo, availableRepos)
branch := wf.Task1(wd, "Create checkpoint branch", x.CreateCheckpoint, targetRepo)
patches = wf.Task2(wd, "Move and rebase all changes per x repo", x.MoveAndRebaseAll, branch, patches)
patches = wf.Task1(wd, "Waiting for submissions", x.AwaitSubmissions, patches)
// block for manual review before pushing changes to public
okayToDisclose := wf.Action0(wd, "Wait to disclose", x.ApproveAction) // TODO(nealpatel): Add warning text
patches = wf.Task2(wd, "Publish changes", x.PublishChanges, targetRepo, patches, wf.After(okayToDisclose))
tagged := wf.Expand4(wd, "Create single-repo plan", tagx.BuildSingleRepoPlan, availableRepos, targetRepo, skipPostSubmit, reviewers, wf.After(patches))
vulnerableAt := wf.Task1(wd, "Resolve vulnerable version", x.ResolveVulnerableVersion, tagged)
// wait for manual approval of the announcement message
okayToAnnounce := wf.Action0(wd, "Wait to Announce", x.ApproveAction, wf.After(tagged))
sentMail := wf.Task2(wd, "Mail announcement", x.MailAnnouncement, tagged, rm, wf.After(okayToAnnounce))
announcementURL := wf.Task1(wd, "await-announcement", x.AwaitAnnounceMail, sentMail)
wf.Output(wd, "Announcement URL", announcementURL)
// post-announcement tasks
changeID := wf.Task4(wd, "Create vuln reports", x.CreateVulnReports, rm, vulnerableAt, tagged, announcementURL)
wf.Output(wd, "File VulnDB Reports", changeID)
wf.Action1(wd, "Update GitHub issues", x.UpdateGitHubIssues, rm, wf.After(changeID))
wf.Output(wd, "done", tagged)
return wd
}
func (x *PrivXPatch) PullMilestone(ctx *wf.TaskContext, milestone string) (*relmeta.ReleaseMilestone, error) {
// TODO(nealpatel): Is this ceremony?
rm, err := fetchReleaseMilestone(ctx, x.PrivateGerrit, milestone)
return &rm, err
}
func (x *PrivXPatch) FilterPatches(ctx *wf.TaskContext, rm *relmeta.ReleaseMilestone, target string, found []TagRepo) (patches []*ref, _ error) {
for _, p := range rm.Patches {
repo, err := repoName(p.Package)
if err != nil {
return nil, err
}
if repo != target {
continue
}
if !slices.ContainsFunc(found, func(r TagRepo) bool { return r.Name == repo }) {
return nil, fmt.Errorf("no repository %q", repo)
}
var cls []*gerrit.ChangeInfo
for _, clLink := range p.Changelists {
if p.Track == relmeta.Public {
continue
}
clNum := clLink[strings.LastIndex(clLink, "/")+1:]
ci, err := x.PrivateGerrit.GetChange(ctx, clNum, gerrit.QueryChangesOpt{Fields: []string{"CURRENT_REVISION", "SUBMITTABLE"}})
if err != nil {
return nil, err
}
if !strings.Contains(p.Package, ci.Project) {
return nil, fmt.Errorf("CL is for unexpected project, got: %s, want %s", ci.Project, p.Package)
}
if !ci.Submittable {
return nil, fmt.Errorf("change %s is not submittable", internalXRepoChangeURL(target, clNum))
}
ra, err := x.PrivateGerrit.GetRevisionActions(ctx, clNum, "current")
if err != nil {
return nil, err
}
if ra["submit"] == nil || !ra["submit"].Enabled {
return nil, fmt.Errorf("change %s is not submittable", internalXRepoChangeURL(target, clNum))
}
// TODO: Add regex for CVE / GH
// TODO(nealpatel): Edge case; order matters for stacked changes.
cls = append(cls, ci)
}
patches = append(patches, &ref{Patch: p, Changes: cls})
}
return patches, nil
}
func internalXRepoChangeURL[T int | string](xrepo string, clNum T) string {
return fmt.Sprintf("https://go-internal-review.git.corp.google.com/c/%s/+/%v", xrepo, clNum)
}
type ref struct {
Patch *relmeta.SecurityPatch
Changes []*gerrit.ChangeInfo
}
// repoName returns the repo implied by the
// modPkg; for example, 'golang.org/x/crypto',
// returns 'crypto' as the repo.
func repoName(modPkg string) (string, error) {
// TODO(nealpatel): This is brittle. Surely, something more idiomatic.
pkg, found := strings.CutPrefix(modPkg, "golang.org/x/")
if !found {
return "", fmt.Errorf("malformed package: %q", modPkg)
}
repo, _, _ := strings.Cut(pkg, "/")
if repo == "" {
return "", fmt.Errorf("malformed package: %q", modPkg)
}
return repo, nil
}
func (x *PrivXPatch) CreateCheckpoint(ctx *wf.TaskContext, repoName string) (string, error) {
publicHead, err := x.PrivateGerrit.ReadBranchHead(ctx, repoName, "public")
if err != nil {
return "", err
}
// Append the formatted timestamp to make any restarts idempotent.
checkpointName := fmt.Sprintf("public-%s", time.Now().UTC().Format("20060102-150405"))
if _, err := x.PrivateGerrit.CreateBranch(ctx, repoName, checkpointName, gerrit.BranchInput{Revision: publicHead}); err != nil {
return "", err
}
return checkpointName, nil
}
func (x *PrivXPatch) MoveAndRebaseAll(ctx *wf.TaskContext, branch string, patches []*ref) ([]*ref, error) {
for _, p := range patches {
for i, ci := range p.Changes {
movedCI, err := x.PrivateGerrit.MoveChange(ctx, ci.ID, branch)
if err != nil {
// In case we need to re-run the Move step, tolerate the case where the change
// is already on the branch.
var httpErr *gerrit.HTTPError
if !errors.As(err, &httpErr) || httpErr.Res.StatusCode != http.StatusConflict || string(httpErr.Body) != "Change is already destined for the specified branch\n" {
return nil, err
}
} else {
ci = &movedCI
}
rebasedCI, err := x.PrivateGerrit.RebaseChange(ctx, ci.ID, "")
if err != nil {
// Don't fail if the branch is already up to date.
var httpErr *gerrit.HTTPError
if !errors.As(err, &httpErr) || httpErr.Res.StatusCode != http.StatusConflict || string(httpErr.Body) != "Change is already up to date.\n" {
return nil, err
}
} else {
ci = &rebasedCI
}
p.Changes[i] = ci
}
}
return patches, nil
}
func (x *PrivXPatch) AwaitSubmissions(ctx *wf.TaskContext, patches []*ref) ([]*ref, error) {
var g errgroup.Group
for _, p := range patches {
for _, cl := range p.Changes {
g.Go(func() error {
ctx.Printf("Awaiting review/submit of %v", cl.ID)
_, err := AwaitCondition(ctx, 10*time.Second, func() (string, bool, error) {
// The ChangeInfo object returned by RebaseChange doesn't contain
// information about submittability, so we need to refetch it using
// GetChange.
ci, err := x.PrivateGerrit.GetChange(ctx, cl.ID, gerrit.QueryChangesOpt{Fields: []string{"SUBMITTABLE"}})
if err != nil {
return "", false, err
}
// TODO(nealpatel): Make more robust/obvious.
if strings.ToLower(ci.Status) == "merged" {
return "", true, nil
}
if !ci.Submittable {
return "", false, nil
}
_, err = x.PrivateGerrit.SubmitChange(ctx, ci.ID)
if err != nil {
return "", false, err
}
return "", true, nil
})
return err
})
}
}
return patches, g.Wait()
}
func (x *PrivXPatch) ResolveVulnerableVersion(ctx *wf.TaskContext, tagged TagRepo) (*report.Version, error) {
tags, err := x.PublicGerrit.ListTags(ctx, tagged.Name)
if err != nil {
return nil, fmt.Errorf("listing tags for %s: %w", tagged.Name, err)
}
cutVersion := tagged.NewerVersion
if !semver.IsValid(cutVersion) {
return nil, fmt.Errorf("invalid tagged version: %q", cutVersion)
}
var versions []string
for _, t := range tags {
if semver.IsValid(t) && semver.Compare(t, cutVersion) < 0 {
versions = append(versions, t)
}
}
if len(versions) == 0 {
return nil, fmt.Errorf("no version tag preceding %s for %s", cutVersion, tagged.Name)
}
semver.Sort(versions)
predecessor := versions[len(versions)-1]
return report.VulnerableAt(predecessor[1:]), nil
}
func (x *PrivXPatch) PublishChanges(ctx *wf.TaskContext, repoName string, patches []*ref) ([]*ref, error) {
// TODO(nealpatel): Unless allowing for multiple modules, hoist to global.
clRE := regexp.MustCompile(fmt.Sprintf(`https://go-review\.googlesource\.com/c/%s/\+/(\d+)`, regexp.QuoteMeta(repoName)))
var disclosed []string
for _, p := range patches {
for i, change := range p.Changes {
cl, err := x.publishChange(ctx, repoName, p.Patch.Changelists[i], change, clRE)
if err != nil {
return nil, err
}
disclosed = append(disclosed, cl)
}
}
var g errgroup.Group
for _, cl := range disclosed {
g.Go(func() error {
ctx.Printf("Awaiting review/submit of %v", cl)
_, err := AwaitCondition(ctx, 10*time.Second, func() (string, bool, error) {
return x.PublicGerrit.Submitted(ctx, cl, "")
})
return err
})
}
// TODO(nealpatel): Is changeInfo supposed to be
// stored in .Changes similarly the other workflow?
//
// If not, this can be an ActionN.
return patches, g.Wait()
}
func (x *PrivXPatch) publishChange(ctx *wf.TaskContext, repoName, clLink string, change *gerrit.ChangeInfo, clRE *regexp.Regexp) (string, error) {
changeInfo, err := x.PrivateGerrit.GetChange(ctx, change.ID, gerrit.QueryChangesOpt{Fields: []string{"CURRENT_REVISION"}})
if err != nil {
return "", err
}
if changeInfo.Status != gerrit.ChangeStatusMerged {
return "", fmt.Errorf("CL %s not merged, status is %s", clLink, changeInfo.Status)
}
rev, ok := changeInfo.Revisions[changeInfo.CurrentRevision]
if !ok {
return "", errors.New("current revision not found")
}
fetch, ok := rev.Fetch["http"]
if !ok {
return "", errors.New("fetch info not found")
}
origin, ref := fetch.URL, fetch.Ref
// We directly use Git here, rather than the Gerrit API, as there are
// limitations to the types of patches which you can create using said
// API. In particular patches which contain any binary content are hard
// to replicate from one instance to another using the API alone. Rather
// than adding workarounds for those edge cases, we just use Git
// directly, which makes the process extremely simple.
repo, err := x.Git.Clone(ctx, x.PublicRepoURL(repoName))
if err != nil {
return "", err
}
defer repo.Close()
ctx.Printf("cloned repo into %s", repo.dir)
ctx.Printf("fetching %s from %s", ref, origin)
if _, err := repo.RunCommand(ctx, "fetch", origin, ref); err != nil {
return "", err
}
ctx.Printf("fetched")
if _, err := repo.RunCommand(ctx, "cherry-pick", "FETCH_HEAD"); err != nil {
return "", err
}
ctx.Printf("cherry-picked")
var refspec strings.Builder
refspec.WriteString("HEAD:refs/for/master%l=Auto-Submit,l=Commit-Queue+1")
// We don't typically specify reviews in the historical releases;
// so this should NOT be hardcoded; instead, it should pull from
// some ACL somewhere?
reviewerEmails, err := coordinatorEmails([]string{})
if err != nil {
return "", err
}
for _, reviewer := range reviewerEmails {
fmt.Fprintf(&refspec, ",r=%s", reviewer)
}
// Beyond this point we don't want to retry any of the following steps.
ctx.DisableRetries()
ctx.Printf("pushing %s to %s", refspec.String(), x.PublicRepoURL(repoName))
gitPushOutput, err := repo.RunGitPush(ctx, x.PublicRepoURL(repoName), refspec.String())
if err != nil {
return "", err
}
matches := clRE.FindSubmatch(gitPushOutput)
if len(matches) != 2 {
return "", errors.New("unable to find CL number")
}
return string(matches[1]), err
}
func (x *PrivXPatch) MailAnnouncement(ctx *wf.TaskContext, tagged TagRepo, rm *relmeta.ReleaseMilestone) (SentMail, error) {
var (
relNotes []string
subjectNoun = "Vulnerability"
bodyPhrase = "address a security issue:"
)
for _, p := range rm.Patches {
if !strings.Contains(p.Package, tagged.ModPath) {
continue
}
relNotes = append(relNotes, p.ReleaseNote)
}
if len(relNotes) > 1 {
subjectNoun = "Vulnerabilities"
bodyPhrase = "address the following security issues:"
}
var buf bytes.Buffer
if err := privXPatchAnnouncementTmpl.Execute(&buf, map[string]any{
"Module": tagged.ModPath,
"Version": tagged.NewerVersion,
"MaybePluralizeSubject": subjectNoun,
"MaybePluralizeBody": bodyPhrase,
"RelNotes": relNotes,
}); err != nil {
return SentMail{}, err
}
m, err := mail.ReadMessage(&buf)
if err != nil {
return SentMail{}, err
}
html, text, err := renderMarkdown(m.Body)
if err != nil {
return SentMail{}, err
}
mc := MailContent{m.Header.Get("Subject"), html, text}
ctx.Printf("announcement subject: %s\n\n", mc.Subject)
ctx.Printf("announcement body HTML:\n%s\n", mc.BodyHTML)
ctx.Printf("announcement body text:\n%s", mc.BodyText)
ctx.DisableRetries()
if err := x.SendMail(ctx, x.AnnounceMailHeader, mc); err != nil {
return SentMail{}, err
}
return SentMail{Subject: mc.Subject}, nil
}
func (x *PrivXPatch) CreateVulnReports(ctx *wf.TaskContext, rm *relmeta.ReleaseMilestone, vulnerableAt *report.Version, tagged TagRepo, announceURL string) (string, error) {
var reports []*report.Report
for _, p := range rm.Patches {
mod, err := x.vulnModuleInfo(p, tagged, vulnerableAt)
if err != nil {
return "", err
}
r, err := VulnReport(p, mod, announceURL)
if err != nil {
return "", err
}
reports = append(reports, r)
}
// TODO(nealpatel): At this point, we need to
// run the linter; for x-repo this is more trivial.
// For std, the symbol resolution is more complex.
//
// These will generate the cve5/osv files that
// must be included in the diff below.
return MailVulnReports(ctx, x.PublicGerrit, reports)
}
// vulnModuleInfo derives the [VulnModuleInfo] for a single patch. For
// golang.org/x patches it validates that the package belongs to the
// tagged repo and uses the network-resolved vulnerableAt (the
// historical x-repo behavior). std/cmd patches do not flow through a
// TagRepo, so for them the module value and vulnerable_at are derived
// locally from the patch via [DeriveVulnModuleInfo].
func (x *PrivXPatch) vulnModuleInfo(p *relmeta.SecurityPatch, tagged TagRepo, vulnerableAt *report.Version) (VulnModuleInfo, error) {
if strings.HasPrefix(p.Package, "golang.org/x/") {
repo, err := repoName(p.Package)
if err != nil {
return VulnModuleInfo{}, err
}
if got, want := repo, tagged.Name; got != want {
return VulnModuleInfo{}, fmt.Errorf("package mismatch: %q vs %q", got, want)
}
return VulnModuleInfo{Module: tagged.ModPath, VulnerableAt: vulnerableAt}, nil
}
return DeriveVulnModuleInfo(p)
}
var vulndbReviewers = []string{"neal@golang.org", "nsh@golang.org"}
func (x *PrivXPatch) UpdateGitHubIssues(ctx *wf.TaskContext, rm *relmeta.ReleaseMilestone) error {
return UpdateGitHubIssues(ctx, x.GitHub, rm)
}
// UpdateGitHubIssues updates the body of each security issue in rm
// with a disclosure notice. It is a no-op when rm is nil or has no patches.
func UpdateGitHubIssues(ctx *wf.TaskContext, gh GitHubClientInterface, rm *relmeta.ReleaseMilestone) error {
if rm == nil {
return nil
}
for _, p := range rm.Patches {
body := fmt.Sprintf(disclosureBody, p.ReleaseNote, p.Track, p.ID)
req := &github.IssueRequest{Body: &body}
if _, _, err := gh.EditIssue(ctx, "golang", "go", int(p.GitHubIssueID), req); err != nil {
return err
}
ctx.Printf("Updated https://go.dev/issue/%d", p.GitHubIssueID)
}
return nil
}
const disclosureBody = `%s
---
This was a %s issue originally tracked in http://b/%d.`
var privXPatchAnnouncementTmpl = template.Must(template.New("").Parse(`Subject: [security] {{.MaybePluralizeSubject}} in {{.Module}}
Hello gophers,
We have tagged version {{.Version}} of {{.Module}} in order to {{.MaybePluralizeBody}}
{{range .RelNotes}}
{{.}}
{{end}}
Cheers,
Go Security team`))