| // Copyright 2026 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 ( |
| "errors" |
| "fmt" |
| "path" |
| "slices" |
| "strconv" |
| "strings" |
| "time" |
| |
| "golang.org/x/build/gerrit" |
| wf "golang.org/x/build/internal/workflow" |
| "golang.org/x/build/relmeta" |
| "golang.org/x/mod/semver" |
| "golang.org/x/vulndb/report" |
| "gopkg.in/yaml.v3" |
| ) |
| |
| // VulnReport derives a complete [report.Report] using p, mod, and announceURL. |
| func VulnReport(p *relmeta.SecurityPatch, mod VulnModuleInfo, announceURL string) (*report.Report, error) { |
| if p.GitHubIssueID == 0 { |
| return nil, errors.New("missing github issue id") |
| } |
| refs := []*report.Reference{ |
| { |
| Type: report.ReferenceTypeReport, |
| URL: fmt.Sprintf("https://go.dev/issue/%d", p.GitHubIssueID), |
| }, |
| } |
| |
| if len(p.Changelists) == 0 { |
| return nil, errors.New("missing changelists") |
| } |
| for _, cl := range p.Changelists { |
| refs = append(refs, &report.Reference{ |
| Type: report.ReferenceTypeFix, |
| URL: cl, |
| }) |
| } |
| |
| if announceURL == "" { |
| return nil, errors.New("missing golang-announce url") |
| } |
| refs = append(refs, &report.Reference{ |
| Type: report.ReferenceTypeWeb, |
| URL: announceURL, |
| }) |
| |
| if len(mod.Versions) == 0 { |
| return nil, errors.New("missing versions") |
| } |
| |
| // Derive the vuln report's summary and description |
| // from the release note, using the VulnReportDesc |
| // field if it is set. |
| // |
| // We expect these fields to be extremely well-formed |
| // and correctly linted upstream; therefore, we make |
| // no attempt at handling errors beyond failing the |
| // workflow in its tracks. |
| subject, body, found := strings.Cut(p.ReleaseNote, "\n") |
| if !found { |
| return nil, fmt.Errorf("malformed release note: %q", p.ReleaseNote) |
| } |
| body = strings.TrimSpace(body) |
| if p.VulnReportDesc != "" { |
| body = p.VulnReportDesc |
| } |
| pkg, summary, found := strings.Cut(subject, ":") |
| if !found { |
| return nil, fmt.Errorf("malformed release note subject: %q", subject) |
| } |
| summary = strings.TrimSpace(summary) |
| summary = strings.TrimSuffix(summary, ".") |
| if len(summary) == 0 || !startsWithASCII(summary) { |
| return nil, fmt.Errorf("malformed release note subject: %q", subject) |
| } |
| summary = fmt.Sprintf("%s%s in %s", strings.ToUpper(summary[:1]), summary[1:], pkg) |
| |
| // At this point, we have a complete vuln report that |
| // is ready to lint and generate the osv/cve5 entries. |
| r := &report.Report{ |
| ID: p.VulnReportID, |
| Modules: []*report.Module{ |
| { |
| Module: mod.Module, |
| Versions: mod.Versions, |
| VulnerableAt: mod.VulnerableAt, |
| // TODO(nealpatel): for completeness, we should |
| // support the edge case of vendored x/ repo fix |
| // having a std counterpart; however, for now, in |
| // the worst case, we have to pull down a mailed |
| // CL, make a small edit, re-push to submit. |
| Packages: []*report.Package{{Package: p.Package}}, |
| }, |
| }, |
| Summary: report.Summary(summary), |
| Description: report.Description(body), |
| Credits: p.Credits, |
| References: refs, |
| CVEMetadata: &report.CVEMeta{ID: p.CVE, CWE: p.CWE}, |
| SourceMeta: &report.SourceMeta{ID: "go-security-team"}, |
| ReviewStatus: report.Reviewed, |
| } |
| |
| return r, nil |
| } |
| |
| type VulnModuleInfo struct { |
| Module string |
| Versions report.Versions |
| VulnerableAt *report.Version |
| } |
| |
| // stdVulnReportVersions converts the go1.m.n version string into a format |
| // that [report.Version] expects and returns a sorted [report.Versions] |
| // from oldest to newest, adding the necessary introduced versions. |
| func stdVulnReportVersions(targetReleases []string) (report.Versions, error) { |
| if len(targetReleases) == 0 { |
| return nil, errors.New("missing target releases") |
| } |
| semVers := make([]string, len(targetReleases)) |
| for i := range targetReleases { |
| bare, err := goTagToBareSemver(targetReleases[i]) |
| if err != nil { |
| return nil, err |
| } |
| semVers[i] = "v" + bare |
| } |
| semver.Sort(semVers) |
| var versions report.Versions |
| for i, v := range semVers { |
| // If a second Go semantic version is |
| // present, this indicates a minor point |
| // release wherein we use a semantically |
| // zero sorted version to break up the |
| // various supported Go versions as a |
| // standard convention in golang/vulndb. |
| if i > 0 { |
| mm := semver.MajorMinor(v) + ".0-0" |
| versions = append(versions, report.Introduced(mm[1:])) |
| } |
| versions = append(versions, report.Fixed(v[1:])) |
| } |
| return versions, nil |
| } |
| |
| func goTagToBareSemver(goTag string) (string, error) { |
| bare, ok := strings.CutPrefix(goTag, "go") |
| if !ok || bare == "" { |
| return "", fmt.Errorf("target release %q is not a go1.X.Y tag", goTag) |
| } |
| if strings.Count(bare, ".") != 2 { |
| return "", fmt.Errorf("target release %q must have three components (go1.X.Y)", goTag) |
| } |
| v := "v" + bare |
| if !semver.IsValid(v) { |
| return "", fmt.Errorf("invalid semver %q (target: %q)", v, goTag) |
| } |
| return bare, nil |
| } |
| |
| // StdVulnModuleInfo derives the [VulnModuleInfo] for a std/cmd security patch. |
| func StdVulnModuleInfo(p *relmeta.SecurityPatch) (VulnModuleInfo, error) { |
| versions, err := stdVulnReportVersions(p.TargetReleases) |
| if err != nil { |
| return VulnModuleInfo{}, err |
| } |
| vulnerableAt, err := stdVulnerableAt(p.TargetReleases) |
| if err != nil { |
| return VulnModuleInfo{}, err |
| } |
| return VulnModuleInfo{ |
| Module: VulnModule(p.Package), |
| Versions: versions, |
| VulnerableAt: vulnerableAt, |
| }, nil |
| } |
| |
| func VulnModule(pkg string) string { |
| if pkg == "cmd" || strings.HasPrefix(pkg, "cmd/") { |
| return "cmd" |
| } |
| if rest, ok := strings.CutPrefix(pkg, "golang.org/x/"); ok { |
| if repo, _, _ := strings.Cut(rest, "/"); repo != "" { |
| return "golang.org/x/" + repo |
| } |
| } |
| return "std" |
| } |
| |
| func stdVulnerableAt(targetReleases []string) (*report.Version, error) { |
| if len(targetReleases) == 0 { |
| return nil, errors.New("missing target releases") |
| } |
| semVers := make([]string, len(targetReleases)) |
| for i := range targetReleases { |
| bare, err := goTagToBareSemver(targetReleases[i]) |
| if err != nil { |
| return nil, err |
| } |
| semVers[i] = "v" + bare |
| } |
| semver.Sort(semVers) |
| highest := semVers[len(semVers)-1] // e.g. "v1.26.3" |
| mm := semver.MajorMinor(highest) // e.g. "v1.26" |
| if len(highest) <= len(mm)+1 { |
| return nil, fmt.Errorf("version %q has no patch component", highest) |
| } |
| patch, err := strconv.Atoi(highest[len(mm)+1:]) |
| if err != nil { |
| return nil, fmt.Errorf("non-numeric patch in %q: %w", highest, err) |
| } |
| if patch <= 0 { |
| return nil, fmt.Errorf("patch component is %d in highest fixed %q; cannot derive vulnerable_at", patch, highest) |
| } |
| // Strip the leading "v" from MajorMinor; vulndb versions are bare. |
| return report.VulnerableAt(fmt.Sprintf("%s.%d", mm[1:], patch-1)), nil |
| } |
| |
| // MailVulnReports prepares the canonical [report.Report] for all |
| // patches and mails the change for golang/vulndb:master. |
| // |
| // It's a precondition that reviewers contain users with @google.com |
| // email domains; if this precondition is violated, the resulting CL |
| // may be routed to the wrong reviewers (harmless). |
| func MailVulnReports(ctx *wf.TaskContext, gc GerritClient, reports []*report.Report, reviewers []string) (string, error) { |
| if len(reports) == 0 { |
| return "", nil |
| } |
| |
| mailed, err := vulnReportsCL(ctx, gc, reports) |
| if err != nil { |
| return "", fmt.Errorf("checking open CLs: %w", err) |
| } |
| if mailed != "" { |
| return mailed, nil |
| } |
| |
| changeInput := gerrit.ChangeInput{ |
| Project: "vulndb", |
| Branch: "master", |
| Subject: Subject(reports), |
| } |
| files := make(map[string]string, len(reports)) |
| for _, r := range reports { |
| buf, err := yaml.Marshal(r) |
| if err != nil { |
| return "", err |
| } |
| files[path.Join("data", "reports", r.ID+".yaml")] = string(buf) |
| } |
| // TODO(nealpatel): Trybots are expected to fail for this change |
| // since it does not generate the reports that the upstream CI expects. |
| return gc.CreateAutoSubmitChange(ctx, changeInput, reviewers, files) |
| } |
| |
| func vulnReportsCL(ctx *wf.TaskContext, gc GerritClient, reports []*report.Report) (string, error) { |
| cls, err := gc.QueryChanges(ctx, "project:vulndb branch:master status:open") |
| if err != nil { |
| return "", err |
| } |
| var fixes []string |
| for _, r := range reports { |
| i := strings.LastIndex(r.ID, "-") |
| fixes = append(fixes, "Fixes golang/vulndb#"+r.ID[i+1:]) |
| } |
| for _, cl := range cls { |
| msg, err := gc.GetCommitMessage(ctx, cl.ID) |
| if err != nil { |
| return "", fmt.Errorf("getting commit message for CL %s: %w", cl.ID, err) |
| } |
| for line := range strings.SplitSeq(msg, "\n") { |
| if slices.Contains(fixes, strings.TrimSpace(line)) { |
| return cl.ID, nil |
| } |
| } |
| } |
| return "", nil |
| } |
| |
| func startsWithASCII(s string) bool { |
| return len(s) > 0 && s[0]|0x20 >= 'a' && s[0]|0x20 <= 'z' |
| } |
| |
| // Subject takes the reports and crafts the |
| // standardized golang/vulndb commit message. |
| func Subject(reports []*report.Report) string { |
| var sb strings.Builder |
| fmt.Fprintf(&sb, "data/reports: add %d first party reports", len(reports)) |
| sb.WriteString("\n\n") |
| for _, r := range reports { |
| i := strings.LastIndex(r.ID, "-") |
| fmt.Fprintf(&sb, "Fixes golang/vulndb#%s\n", r.ID[i+1:]) |
| } |
| // TODO(nealpatel) Do we need to put the change-id here? |
| return sb.String() |
| } |
| |
| // ConvertInternalChangelists fetches the release milestone from |
| // HEAD and replaces the internal CL references with public CL |
| // references as implied by the i2p map. |
| // |
| // If at least one change needs to be converted, it mails a CL |
| // and blocks until that CL is approved and submitted, returning |
| // the updated release milestone from HEAD. |
| func ConvertInternalChangelists(ctx *wf.TaskContext, private GerritClient, milestoneNum string, i2p map[string]string, reviewers []string) (relmeta.ReleaseMilestone, error) { |
| const project = "security-metadata" |
| head, err := private.ReadBranchHead(ctx, project, "main") |
| if err != nil { |
| return relmeta.ReleaseMilestone{}, err |
| } |
| fp := path.Join("data", "milestones", milestoneNum+".yaml") |
| buf, err := private.ReadFile(ctx, project, head, fp) |
| if err != nil { |
| return relmeta.ReleaseMilestone{}, err |
| } |
| converted := string(buf) |
| for internal, public := range i2p { |
| converted = strings.ReplaceAll(converted, internal+"\n", public+"\n") |
| } |
| if converted != string(buf) { |
| changeInput := gerrit.ChangeInput{ |
| Project: project, |
| Branch: "main", |
| Subject: "data/milestones: convert internal changelists for " + milestoneNum, |
| } |
| changeID, err := private.CreateAutoSubmitChange(ctx, changeInput, reviewers, map[string]string{fp: converted}) |
| if err != nil { |
| return relmeta.ReleaseMilestone{}, err |
| } |
| if changeID != "" { |
| ctx.Printf("Awaiting review/submit of %s", changeID) |
| if _, err := AwaitCondition(ctx, 10*time.Second, func() (string, bool, error) { |
| return private.Submitted(ctx, changeID, "") |
| }); err != nil { |
| return relmeta.ReleaseMilestone{}, err |
| } |
| } |
| } |
| return FetchReleaseMilestone(ctx, private, milestoneNum) |
| } |