blob: 2465672d7023f019204b5be9fb1e6c94ea112f9d [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"
"strconv"
"strings"
"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,
})
versions, err := VulnReportVersions(p.TargetReleases)
if err != nil {
return nil, err
}
// 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: 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
VulnerableAt *report.Version
}
// VulnReportVersions constructs the expected
// list of [report.Versions] that flags which
// major.minor.patch semvers are affected.
//
// targetReleases are expected to conform to
// the [report.Version] semantics which notably
// do not include the prefixed 'v'.
func VulnReportVersions(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 {
semVers[i] = "v" + targetReleases[i]
if !semver.IsValid(semVers[i]) {
return nil, fmt.Errorf("invalid semver %q (target: %q)", semVers[i], targetReleases[i])
}
}
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))
}
versions = append(versions, report.Fixed(v))
}
return versions, nil
}
// DeriveVulnModuleInfo derives the [VulnModuleInfo] for a std/cmd
// security patch without a network call. The module value is taken
// from [VulnModule] and the VulnerableAt version is derived from
// its [VulnerableAtFromTargetReleases].
//
// The x-repo path uses [ResolveVulnerableVersion] instead, which
// resolves over the network; see [PrivXPatch.CreateVulnReports].
func DeriveVulnModuleInfo(p *relmeta.SecurityPatch) (VulnModuleInfo, error) {
vulnerableAt, err := VulnerableAtFromTargetReleases(p.TargetReleases)
if err != nil {
return VulnModuleInfo{}, err
}
return VulnModuleInfo{
Module: VulnModule(p.Package),
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 VulnerableAtFromTargetReleases(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 {
semVers[i] = "v" + targetReleases[i]
if !semver.IsValid(semVers[i]) {
return nil, fmt.Errorf("invalid semver %q (target: %q)", semVers[i], targetReleases[i])
}
}
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 marshals each report to data/reports/<id>.yaml,
// computes the commit subject via [Subject], and submits the change
// to project vulndb/master with auto-submit and vulndbReviewers.
func MailVulnReports(ctx *wf.TaskContext, gc GerritClient, reports []*report.Report) (string, error) {
if len(reports) == 0 {
return "", 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, vulndbReviewers, files)
}
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()
}