blob: 0f3ba391a9e6121a6f3673e12bcf899df58b0dc4 [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"
"context"
"errors"
"fmt"
"net/mail"
"path"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-github/v74/github"
"golang.org/x/build/gerrit"
wf "golang.org/x/build/internal/workflow"
"golang.org/x/build/relmeta"
"golang.org/x/vulndb/report"
yaml "gopkg.in/yaml.v3"
)
type fakePrivXGerrit struct {
*FakeGerrit
changes map[string]*gerrit.ChangeInfo
}
type fakePrivXGitHub struct {
*FakeGitHub
t *testing.T
gerrit *FakeGerrit
vulndbBase string
}
func (g *fakePrivXGitHub) EditIssue(ctx context.Context, owner, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error) {
head, err := g.gerrit.ReadBranchHead(ctx, "vulndb", "master")
if err != nil {
g.t.Errorf("EditIssue(%d): reading vulndb head: %v", number, err)
} else if head == g.vulndbBase {
g.t.Errorf("EditIssue(%d) called before vuln reports were submitted", number)
}
return g.FakeGitHub.EditIssue(ctx, owner, repo, number, issue)
}
func (g *fakePrivXGerrit) GetChange(_ context.Context, changeID string, _ ...gerrit.QueryChangesOpt) (*gerrit.ChangeInfo, error) {
ci, ok := g.changes[changeID]
if !ok {
return nil, errors.New("GetChange: not found")
}
return ci, nil
}
func (g *fakePrivXGerrit) GetRevisionActions(_ context.Context, changeID string, revision string) (map[string]*gerrit.ActionInfo, error) {
if _, ok := g.changes[changeID]; !ok {
return nil, nil
}
return map[string]*gerrit.ActionInfo{
"submit": {Enabled: true},
}, nil
}
func (g *fakePrivXGerrit) MoveChange(ctx context.Context, changeID string, branch string) (gerrit.ChangeInfo, error) {
ci, ok := g.changes[changeID]
if !ok {
return gerrit.ChangeInfo{}, errors.New("MoveChange: not found")
}
ci.Branch = branch
return *ci, nil
}
func (g *fakePrivXGerrit) RebaseChange(ctx context.Context, changeID string, baseRev string) (gerrit.ChangeInfo, error) {
ci, ok := g.changes[changeID]
if !ok {
return gerrit.ChangeInfo{}, errors.New("RebaseChange: not found")
}
return *ci, nil
}
func (g *fakePrivXGerrit) SubmitChange(ctx context.Context, changeID string) (gerrit.ChangeInfo, error) {
ci, ok := g.changes[changeID]
if !ok {
return gerrit.ChangeInfo{}, errors.New("SubmitChange: not found")
}
ci.Status = gerrit.ChangeStatusMerged
return *ci, nil
}
const privXMilestoneYAML = `id: 88810010
security_patches:
- id: 10001
package: golang.org/x/net/http2
track: PRIVATE
changelists:
- https://go-internal-review.git.corp.google.com/c/net/+/1111
- https://go-internal-review.git.corp.google.com/c/net/+/2222
release_note: |
net/http2: turbulence in the frame buffers causes gophers to levitate.
Sending a specially crafted SETTINGS frame with the
ENABLE_LEVITATION=1 causes all subsequent gophers to
float indefinitely.
Thanks to a very levitated gopher for reporting this issue.
This is CVE-1970-0001 and Go issue https://go.dev/issue/4294967296.
target_releases:
- 1.1.0
cve: CVE-1970-0001
github_issue_id: 4294967296
vuln_report_id: GO-1970-0001
credits:
- a very levitated gopher
- id: 10002
package: golang.org/x/net/html
track: PUBLIC
changelists:
- https://go.dev/cl/3333
release_note: |
net/html: tokenizer emits poetry instead of tokens under a full moon.
When the system clock aligns with a lunar cycle, the HTML
tokenizer replaces all div elements with haikus about the
Go garbage collector.
Thanks to a confused poet for reporting this issue.
This is CVE-1970-0002 and Go issue https://go.dev/issue/4294967297.
target_releases:
- 1.1.0
cve: CVE-1970-0002
github_issue_id: 4294967297
vuln_report_id: GO-1970-0002
credits:
- a confused poet
- id: 10003
package: golang.org/x/net/http2
track: PRIVATE
changelists:
- https://go-internal-review.git.corp.google.com/c/net/+/4444
- https://go-internal-review.git.corp.google.com/c/net/+/5555
release_note: |
net/http2: turbulence in the frame buffers causes gophers to levitate.
Sending a specially crafted SETTINGS frame with the
ENABLE_LEVITATION=1 causes all subsequent gophers to
float indefinitely.
Thanks to a very levitated gopher for reporting this issue.
This is CVE-1970-0003 and Go issue https://go.dev/issue/4294967298.
target_releases:
- 1.1.0
cve: CVE-1970-0003
github_issue_id: 4294967298
vuln_report_id: GO-1970-0003
credits:
- a very levitated gopher`
func TestPrivXPatch(t *testing.T) {
netRepo := NewFakeRepo(t, "net")
smRepo := NewFakeRepo(t, "security-metadata")
head := smRepo.History()[0]
smRepo.Branch("main", head)
smRepo.CommitOnBranch("main", map[string]string{
path.Join("data", "milestones", "88810010.yaml"): privXMilestoneYAML,
})
netHead := netRepo.History()[0]
netRepo.Branch("public", netHead)
privCommit := netRepo.CommitOnBranch("master", map[string]string{"fix.go": "package fix"})
netRepo.runGit("update-ref", "refs/changes/1111/1", privCommit)
privCommit2 := netRepo.CommitOnBranch("master", map[string]string{"fix2.go": "package fix"})
netRepo.runGit("update-ref", "refs/changes/2222/1", privCommit2)
// privCommit3 := netRepo.CommitOnBranch("master", map[string]string{"fix3.go": "package fix"})
// netRepo.runGit("update-ref", "refs/changes/3333/1", privCommit3)
privCommit4 := netRepo.CommitOnBranch("master", map[string]string{"fix4.go": "package fix"})
netRepo.runGit("update-ref", "refs/changes/4444/1", privCommit4)
privCommit5 := netRepo.CommitOnBranch("master", map[string]string{"fix5.go": "package fix"})
netRepo.runGit("update-ref", "refs/changes/5555/1", privCommit5)
privGerrit := &fakePrivXGerrit{
FakeGerrit: NewFakeGerrit(t, netRepo, smRepo),
changes: map[string]*gerrit.ChangeInfo{
"1111": {
ID: "1111",
ChangeID: "1111",
ChangeNumber: 1111,
Project: "net",
Branch: "public",
Submittable: true,
CurrentRevision: "rev1111",
Status: gerrit.ChangeStatusMerged,
Revisions: map[string]gerrit.RevisionInfo{
"rev1111": {
Fetch: map[string]*gerrit.FetchInfo{
"http": {
URL: netRepo.dir.dir,
Ref: "refs/changes/1111/1",
},
},
},
},
},
"2222": {
ID: "2222",
ChangeID: "2222",
ChangeNumber: 2222,
Project: "net",
Branch: "public",
Submittable: true,
CurrentRevision: "rev2222",
Status: gerrit.ChangeStatusMerged,
Revisions: map[string]gerrit.RevisionInfo{
"rev2222": {
Fetch: map[string]*gerrit.FetchInfo{
"http": {
URL: netRepo.dir.dir,
Ref: "refs/changes/2222/1",
},
},
},
},
},
"4444": {
ID: "4444",
ChangeID: "4444",
ChangeNumber: 4444,
Project: "net",
Branch: "public",
Submittable: true,
CurrentRevision: "rev4444",
Status: gerrit.ChangeStatusMerged,
Revisions: map[string]gerrit.RevisionInfo{
"rev4444": {
Fetch: map[string]*gerrit.FetchInfo{
"http": {
URL: netRepo.dir.dir,
Ref: "refs/changes/4444/1",
},
},
},
},
},
"5555": {
ID: "5555",
ChangeID: "5555",
ChangeNumber: 5555,
Project: "net",
Branch: "public",
Submittable: true,
CurrentRevision: "rev5555",
Status: gerrit.ChangeStatusMerged,
Revisions: map[string]gerrit.RevisionInfo{
"rev5555": {
Fetch: map[string]*gerrit.FetchInfo{
"http": {
URL: netRepo.dir.dir,
Ref: "refs/changes/5555/1",
},
},
},
},
},
},
}
pubRepo := NewFakeRepo(t, "net")
pubRepo.CommitOnBranch("master", map[string]string{"go.mod": "module golang.org/x/net\n\ngo 1.24"})
pubRepo.runGit("tag", "v1.0.0")
pubRepo.SetHook("post-receive", `#!/bin/bash -eu
read old new refname
git update-ref refs/heads/master "$new"
echo "Resolving deltas: 100% (5/5)"
echo "Waiting for private key checker: 1/1 objects left"
echo "Processing changes: refs: 1, new: 1, done"
echo
echo "SUCCESS"
echo
echo " https://go-review.googlesource.com/c/net/+/558675 some change [NEW]"
echo`)
vulndbRepo := NewFakeRepo(t, "vulndb")
vulndbBase := vulndbRepo.CommitOnBranch("master", map[string]string{"README": "vulndb"})
pubBase, _ := strings.CutSuffix(pubRepo.dir.dir, filepath.Base(pubRepo.dir.dir))
pubGerrit := NewFakeGerrit(t, pubRepo, vulndbRepo)
pubGerrit.ConsiderChangeSubmitted(pubRepo, "558675")
fakeGH := &FakeGitHub{Issues: map[int]*github.Issue{
4294967296: {Number: github.Ptr(4294967296)},
4294967297: {Number: github.Ptr(4294967297)},
4294967298: {Number: github.Ptr(4294967298)},
}}
orderedGH := &fakePrivXGitHub{
FakeGitHub: fakeGH,
t: t,
gerrit: pubGerrit,
vulndbBase: vulndbBase,
}
var announcementHeader MailHeader
var announcementMessage MailContent
p := &PrivXPatch{
Git: &Git{},
PrivateGerrit: privGerrit,
PublicGerrit: pubGerrit,
PublicRepoURL: func(repo string) string {
return pubBase + "/" + repo
},
GitHub: orderedGH,
ApproveAction: func(*wf.TaskContext) error { return nil },
SendMail: func(_ *wf.TaskContext, mh MailHeader, mc MailContent) error {
announcementHeader, announcementMessage = mh, mc
return nil
},
AnnounceMailHeader: MailHeader{
From: mail.Address{Address: "security@golang.org"},
To: mail.Address{Address: "golang-announce@googlegroups.com"},
},
AwaitAnnounceMail: func(_ *wf.TaskContext, m SentMail) (string, error) {
return "https://groups.google.com/g/golang-announce/c/test", nil
},
}
tagxGerrit := NewFakeGerrit(t, pubRepo)
wd := p.NewDefinition(&TagXReposTasks{Gerrit: tagxGerrit})
w, err := wf.Start(wd, map[string]any{
"Release Milestone": "88810010",
reviewersParam.Name: []string{},
"Repository name": "net",
"Skip post submit result (optional)": true,
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
_, err = w.Run(&wf.TaskContext{Context: ctx, Logger: &testLogger{t: t}}, &verboseListener{t: t})
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(p.AnnounceMailHeader, announcementHeader); diff != "" {
t.Errorf("announcement header mismatch (-want +got):\n%s", diff)
}
wantSubject := `[security] Vulnerabilities in golang.org/x/net`
if announcementMessage.Subject != wantSubject {
t.Errorf("announcement subject:\ngot %q\nwant %q", announcementMessage.Subject, wantSubject)
}
wantText := `Hello gophers,
We have tagged version v1.1.0 of golang.org/x/net in order to address the following security issues:
net/http2: turbulence in the frame buffers causes gophers to levitate.
Sending a specially crafted SETTINGS frame with the
ENABLE_LEVITATION=1 causes all subsequent gophers to
float indefinitely.
Thanks to a very levitated gopher for reporting this issue.
This is CVE-1970-0001 and Go issue https://go.dev/issue/4294967296.
net/html: tokenizer emits poetry instead of tokens under a full moon.
When the system clock aligns with a lunar cycle, the HTML
tokenizer replaces all div elements with haikus about the
Go garbage collector.
Thanks to a confused poet for reporting this issue.
This is CVE-1970-0002 and Go issue https://go.dev/issue/4294967297.
net/http2: turbulence in the frame buffers causes gophers to levitate.
Sending a specially crafted SETTINGS frame with the
ENABLE_LEVITATION=1 causes all subsequent gophers to
float indefinitely.
Thanks to a very levitated gopher for reporting this issue.
This is CVE-1970-0003 and Go issue https://go.dev/issue/4294967298.
Cheers,
Go Security team
`
if diff := cmp.Diff(wantText, announcementMessage.BodyText); diff != "" {
t.Errorf("announcement text mismatch (-want +got):\n%s", diff)
}
wantHTML := `<p>Hello gophers,</p>
<p>We have tagged version v1.1.0 of golang.org/x/net in order to address the following security issues:</p>
<p>net/http2: turbulence in the frame buffers causes gophers to levitate.</p>
<p>Sending a specially crafted SETTINGS frame with the<br>
ENABLE_LEVITATION=1 causes all subsequent gophers to<br>
float indefinitely.</p>
<p>Thanks to a very levitated gopher for reporting this issue.</p>
<p>This is CVE-1970-0001 and Go issue <a href="https://go.dev/issue/4294967296">https://go.dev/issue/4294967296</a>.</p>
<p>net/html: tokenizer emits poetry instead of tokens under a full moon.</p>
<p>When the system clock aligns with a lunar cycle, the HTML<br>
tokenizer replaces all div elements with haikus about the<br>
Go garbage collector.</p>
<p>Thanks to a confused poet for reporting this issue.</p>
<p>This is CVE-1970-0002 and Go issue <a href="https://go.dev/issue/4294967297">https://go.dev/issue/4294967297</a>.</p>
<p>net/http2: turbulence in the frame buffers causes gophers to levitate.</p>
<p>Sending a specially crafted SETTINGS frame with the<br>
ENABLE_LEVITATION=1 causes all subsequent gophers to<br>
float indefinitely.</p>
<p>Thanks to a very levitated gopher for reporting this issue.</p>
<p>This is CVE-1970-0003 and Go issue <a href="https://go.dev/issue/4294967298">https://go.dev/issue/4294967298</a>.</p>
<p>Cheers,<br>
Go Security team</p>
`
if diff := cmp.Diff(wantHTML, announcementMessage.BodyHTML); diff != "" {
t.Errorf("announcement HTML mismatch (-want +got):\n%s", diff)
}
// Verify that vuln reports were submitted to vulndb.
vulndbHead, err := pubGerrit.ReadBranchHead(ctx, "vulndb", "master")
if err != nil {
t.Fatal(err)
}
var rm relmeta.ReleaseMilestone
if err := yaml.Unmarshal([]byte(privXMilestoneYAML), &rm); err != nil {
t.Fatal(err)
}
const announceURL = "https://groups.google.com/g/golang-announce/c/test"
for _, p := range rm.Patches {
reportPath := path.Join("data", "reports", p.VulnReportID+".yaml")
b, err := pubGerrit.ReadFile(ctx, "vulndb", vulndbHead, reportPath)
if err != nil {
t.Fatalf("patch %d: reading %s: %v", p.ID, reportPath, err)
}
if !bytes.Contains(b, []byte(announceURL)) {
t.Errorf("patch %d: report %s does not contain %s", p.ID, reportPath, announceURL)
}
var vr report.Report
if err := yaml.Unmarshal(b, &vr); err != nil {
t.Fatalf("patch %d: unmarshal %s: %v", p.ID, reportPath, err)
}
if vr.ID != p.VulnReportID {
t.Errorf("patch %d: ID = %q, want %q", p.ID, vr.ID, p.VulnReportID)
}
// Module and package.
if len(vr.Modules) != 1 {
t.Errorf("patch %d: got %d modules, want 1", p.ID, len(vr.Modules))
continue
}
mod := vr.Modules[0]
if mod.Module != "golang.org/x/net" {
t.Errorf("patch %d: module = %q, want %q", p.ID, mod.Module, "golang.org/x/net")
}
if len(mod.Packages) != 1 {
t.Errorf("patch %d: got %d packages, want 1", p.ID, len(mod.Packages))
continue
}
if mod.Packages[0].Package != p.Package {
t.Errorf("patch %d: package = %q, want %q", p.ID, mod.Packages[0].Package, p.Package)
}
// CVE metadata.
if vr.CVEMetadata == nil {
t.Errorf("patch %d: CVEMetadata is nil", p.ID)
continue
}
if vr.CVEMetadata.ID != p.CVE {
t.Errorf("patch %d: CVEMetadata.ID = %q, want %q", p.ID, vr.CVEMetadata.ID, p.CVE)
}
// Credits.
if diff := cmp.Diff(p.Credits, vr.Credits); diff != "" {
t.Errorf("patch %d: credits mismatch (-want +got):\n%s", p.ID, diff)
}
// Description must be non-empty.
if vr.Description == "" {
t.Errorf("patch %d: description is empty", p.ID)
}
// Summary must be non-empty.
if vr.Summary == "" {
t.Errorf("patch %d: summary is empty", p.ID)
}
// ReviewStatus must be Reviewed.
if vr.ReviewStatus != report.Reviewed {
t.Errorf("patch %d: review_status = %v, want Reviewed", p.ID, vr.ReviewStatus)
}
// Source metadata.
if vr.SourceMeta == nil || vr.SourceMeta.ID != "go-security-team" {
t.Errorf("patch %d: source meta = %v, want id=go-security-team", p.ID, vr.SourceMeta)
}
// References: must contain a REPORT ref for the GitHub issue, FIX refs
// for each changelist, and a WEB ref for the announcement URL.
refsByType := map[report.ReferenceType][]string{}
for _, ref := range vr.References {
refsByType[ref.Type] = append(refsByType[ref.Type], ref.URL)
}
if p.GitHubIssueID != 0 {
wantIssueURL := fmt.Sprintf("https://go.dev/issue/%d", p.GitHubIssueID)
if urls := refsByType[report.ReferenceTypeReport]; len(urls) != 1 || urls[0] != wantIssueURL {
t.Errorf("patch %d: REPORT refs = %v, want [%s]", p.ID, urls, wantIssueURL)
}
}
if got, want := len(refsByType[report.ReferenceTypeFix]), len(p.Changelists); got != want {
t.Errorf("patch %d: got %d FIX refs, want %d", p.ID, got, want)
}
if urls := refsByType[report.ReferenceTypeWeb]; len(urls) != 1 || urls[0] != announceURL {
t.Errorf("patch %d: WEB refs = %v, want [%s]", p.ID, urls, announceURL)
}
}
// Verify that GitHub issues were updated with the release note + trailer.
for _, p := range rm.Patches {
issue, ok := fakeGH.Issues[int(p.GitHubIssueID)]
if !ok {
t.Errorf("patch %d: GitHub issue %d not found", p.ID, p.GitHubIssueID)
continue
}
body := issue.GetBody()
if !strings.Contains(body, p.ReleaseNote) {
t.Errorf("patch %d: issue body missing release note", p.ID)
}
wantTrailer := fmt.Sprintf("This was a %s issue originally tracked in http://b/%d.", p.Track, p.ID)
if !strings.Contains(body, wantTrailer) {
t.Errorf("patch %d: issue body missing trailer, got:\n%s", p.ID, body)
}
}
}
func TestResolveVulnerableVersion(t *testing.T) {
tests := []struct {
name string
tags []string
cutVersion string
want string
wantErr bool
}{
{
"predecessor without new tag in list",
[]string{"v0.1.0", "v0.2.0", "v0.3.0"},
"v0.4.0",
"0.3.0",
false,
},
{
"predecessor with new tag in list",
[]string{"v0.1.0", "v0.2.0", "v0.3.0", "v0.4.0"},
"v0.4.0",
"0.3.0",
false,
},
{
"non-version tags ignored",
[]string{"v0.5.0", "release", "nightly", "v0.6.0"},
"v0.7.0",
"0.6.0",
false,
},
{
"semantic ordering not lexical",
[]string{"v0.2.0", "v0.10.0", "v0.9.0"},
"v0.11.0",
"0.10.0",
false,
},
{
"no predecessor",
[]string{"v0.1.0"},
"v0.1.0",
"",
true,
},
{
"no version tags",
[]string{"release", "nightly"},
"v0.1.0",
"",
true,
},
{
"no tags at all",
nil,
"v0.1.0",
"",
true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := NewFakeRepo(t, "net")
head := repo.Commit(map[string]string{"go.mod": "module golang.org/x/net\n"})
for _, tag := range tt.tags {
repo.Tag(tag, head)
}
fg := NewFakeGerrit(t, repo)
x := &PrivXPatch{PublicGerrit: fg}
ctx := &wf.TaskContext{
Context: context.Background(),
Logger: &testLogger{t: t},
}
tagged := TagRepo{Name: "net", NewerVersion: tt.cutVersion}
got, err := x.ResolveVulnerableVersion(ctx, tagged)
if (err != nil) != tt.wantErr {
t.Fatalf("ResolveVulnerableVersion: err = %v, wantErr = %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
want := report.VulnerableAt(tt.want)
if !reflect.DeepEqual(got, want) {
t.Errorf("ResolveVulnerableVersion = %v, want %v", got, want)
}
})
}
}
func TestUpdateGitHubIssues(t *testing.T) {
ctx := &wf.TaskContext{
Context: context.Background(),
Logger: &testLogger{t: t},
}
t.Run("nil milestone", func(t *testing.T) {
if err := UpdateGitHubIssues(ctx, nil, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("patches", func(t *testing.T) {
fakeGH := &FakeGitHub{}
rm := &relmeta.ReleaseMilestone{
Patches: []*relmeta.SecurityPatch{
{
ID: 100,
Track: relmeta.Private,
Package: "crypto/tls",
ReleaseNote: "crypto/tls: bad handshake.\n\nA crafted ClientHello causes a panic.",
GitHubIssueID: 11111,
},
{
ID: 200,
Track: relmeta.Public,
Package: "net/http",
ReleaseNote: "net/http: request smuggling.\n\nMalformed headers bypass validation.",
GitHubIssueID: 22222,
},
},
}
if err := UpdateGitHubIssues(ctx, fakeGH, rm); err != nil {
t.Fatalf("UpdateGitHubIssues: %v", err)
}
for _, p := range rm.Patches {
issue, ok := fakeGH.Issues[int(p.GitHubIssueID)]
if !ok {
t.Errorf("patch %d: GitHub issue %d not found", p.ID, p.GitHubIssueID)
continue
}
body := issue.GetBody()
if !strings.Contains(body, p.ReleaseNote) {
t.Errorf("patch %d: issue body missing release note", p.ID)
}
wantTrailer := fmt.Sprintf("This was a %s issue originally tracked in http://b/%d.", p.Track, p.ID)
if !strings.Contains(body, wantTrailer) {
t.Errorf("patch %d: issue body missing trailer, got:\n%s", p.ID, body)
}
}
})
}
func TestRepoName(t *testing.T) {
tests := []struct {
name string
pkg string
want string
wantErr bool
}{
{"subpackage", "golang.org/x/net/http2", "net", false},
{"root module", "golang.org/x/net", "net", false},
{"different repo", "golang.org/x/crypto/ssh", "crypto", false},
{"non-x module", "github.com/foo/bar", "", true},
{"trailing slash", "golang.org/x/", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := repoName(tt.pkg)
if (err != nil) != tt.wantErr {
t.Errorf("repoName(%q): err = %v, wantErr = %v", tt.pkg, err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("repoName(%q) = %q, want %q", tt.pkg, got, tt.want)
}
})
}
}