blob: cf69403277c5376df4d1f22437d107f8aea8f277 [file]
// 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)
}