gopls/internal/vulncheck/vulntest: add test helpers

This package hosts helper utilities for vulncheck features. This package requires go1.18+.

Most of the code were adopted from golang.org/x/vulndb/internal.

The first batch is NewDatabase reads YAML-format vulnerability
information files
(https://github.com/golang/vulndb/blob/master/doc/format.md)
packaged in txtar, and creates a filesystem-based vuln database
that can be used as a data source of golang.org/x/vuln/client
APIs.

See db_test.go for example.

* Source of the code

db.go:
  golang.org/x/vulndb/internal/database#Generate
report.go:
  golang.org/x/vulndb/internal/report#Report
stdlib.go:
  golang.org/x/vulndb/internal/stdlib

This change adds a new dependency on "gopkg.in/yaml.v3"
for parsing YAMLs in testing

Change-Id: Ica5da4284c38a8a9531b1f943deb4288a2058c9b
Reviewed-on: https://go-review.googlesource.com/c/tools/+/435358
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
gopls-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Robert Findley <rfindley@google.com>
This commit is contained in:
Hana (Hyang-Ah) Kim 2022-09-27 17:12:41 -04:00 committed by Hyang-Ah Hana Kim
parent 2f57270232
commit 4ef38dc8f2
9 changed files with 664 additions and 0 deletions

View File

@ -13,6 +13,7 @@ require (
golang.org/x/text v0.3.7
golang.org/x/tools v0.1.13-0.20220928184430-f80e98464e27
golang.org/x/vuln v0.0.0-20221004232641-2aa0553d353b
gopkg.in/yaml.v3 v3.0.1
honnef.co/go/tools v0.3.3
mvdan.cc/gofumpt v0.3.1
mvdan.cc/xurls/v2 v2.4.0

View File

@ -76,11 +76,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.2.2/go.mod h1:lPVVZ2BS5TfnjLyizF7o7hv7j9/L+8cZY2hLyjP9cGY=
honnef.co/go/tools v0.3.3 h1:oDx7VAwstgpYpb3wv0oxiZlxY+foCpRAwY7Vk6XpAgA=
honnef.co/go/tools v0.3.3/go.mod h1:jzwdWgg7Jdq75wlfblQxO4neNaFFSvgc1tD5Wv8U0Yw=

View File

@ -0,0 +1,303 @@
// Copyright 2022 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.
//go:build go1.18
// +build go1.18
// Package vulntest provides helpers for vulncheck functionality testing.
package vulntest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sort"
"strings"
"time"
"golang.org/x/tools/internal/span"
"golang.org/x/tools/txtar"
"golang.org/x/vuln/client"
"golang.org/x/vuln/osv"
)
// NewDatabase returns a read-only DB containing the provided
// txtar-format collection of vulnerability reports.
// Each vulnerability report is a YAML file whose format
// is defined in golang.org/x/vulndb/doc/format.md.
// A report file name must have the id as its base name,
// and have .yaml as its extension.
//
// db, err := NewDatabase(ctx, reports)
// ...
// defer db.Clean()
// client, err := NewClient(db)
// ...
//
// The returned DB's Clean method must be called to clean up the
// generated database.
func NewDatabase(ctx context.Context, txtarReports []byte) (*DB, error) {
disk, err := ioutil.TempDir("", "vulndb-test")
if err != nil {
return nil, err
}
if err := generateDB(ctx, txtarReports, disk, false); err != nil {
os.RemoveAll(disk)
return nil, err
}
return &DB{disk: disk}, nil
}
// DB is a read-only vulnerability database on disk.
// Users can use this database with golang.org/x/vuln APIs
// by setting the `VULNDB“ environment variable.
type DB struct {
disk string
}
// URI returns the file URI that can be used for VULNDB environment
// variable.
func (db *DB) URI() string {
u := span.URIFromPath(db.disk)
return string(u)
}
// Clean deletes the database.
func (db *DB) Clean() error {
return os.RemoveAll(db.disk)
}
// NewClient returns a vuln DB client that works with the given DB.
func NewClient(db *DB) (client.Client, error) {
return client.NewClient([]string{db.URI()}, client.Options{})
}
//
// The following was selectively copied from golang.org/x/vulndb/internal/database
//
const (
dbURL = "https://pkg.go.dev/vuln/"
// idDirectory is the name of the directory that contains entries
// listed by their IDs.
idDirectory = "ID"
// stdFileName is the name of the .json file in the vulndb repo
// that will contain info on standard library vulnerabilities.
stdFileName = "stdlib"
// toolchainFileName is the name of the .json file in the vulndb repo
// that will contain info on toolchain (cmd/...) vulnerabilities.
toolchainFileName = "toolchain"
// cmdModule is the name of the module containing Go toolchain
// binaries.
cmdModule = "cmd"
// stdModule is the name of the module containing Go std packages.
stdModule = "std"
)
// generateDB generates the file-based vuln DB in the directory jsonDir.
func generateDB(ctx context.Context, txtarData []byte, jsonDir string, indent bool) error {
archive := txtar.Parse(txtarData)
jsonVulns, entries, err := generateEntries(ctx, archive)
if err != nil {
return err
}
index := make(client.DBIndex, len(jsonVulns))
for modulePath, vulns := range jsonVulns {
epath, err := client.EscapeModulePath(modulePath)
if err != nil {
return err
}
if err := writeVulns(filepath.Join(jsonDir, epath), vulns, indent); err != nil {
return err
}
for _, v := range vulns {
if v.Modified.After(index[modulePath]) {
index[modulePath] = v.Modified
}
}
}
if err := writeJSON(filepath.Join(jsonDir, "index.json"), index, indent); err != nil {
return err
}
if err := writeAliasIndex(jsonDir, entries, indent); err != nil {
return err
}
return writeEntriesByID(filepath.Join(jsonDir, idDirectory), entries, indent)
}
func generateEntries(_ context.Context, archive *txtar.Archive) (map[string][]osv.Entry, []osv.Entry, error) {
now := time.Now()
jsonVulns := map[string][]osv.Entry{}
var entries []osv.Entry
for _, f := range archive.Files {
if !strings.HasSuffix(f.Name, ".yaml") {
continue
}
r, err := readReport(bytes.NewReader(f.Data))
if err != nil {
return nil, nil, err
}
name := strings.TrimSuffix(filepath.Base(f.Name), filepath.Ext(f.Name))
linkName := fmt.Sprintf("%s%s", dbURL, name)
entry, modulePaths := generateOSVEntry(name, linkName, now, *r)
for _, modulePath := range modulePaths {
jsonVulns[modulePath] = append(jsonVulns[modulePath], entry)
}
entries = append(entries, entry)
}
return jsonVulns, entries, nil
}
func writeVulns(outPath string, vulns []osv.Entry, indent bool) error {
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return fmt.Errorf("failed to create directory %q: %s", filepath.Dir(outPath), err)
}
return writeJSON(outPath+".json", vulns, indent)
}
func writeEntriesByID(idDir string, entries []osv.Entry, indent bool) error {
// Write a directory containing entries by ID.
if err := os.MkdirAll(idDir, 0755); err != nil {
return fmt.Errorf("failed to create directory %q: %v", idDir, err)
}
var idIndex []string
for _, e := range entries {
outPath := filepath.Join(idDir, e.ID+".json")
if err := writeJSON(outPath, e, indent); err != nil {
return err
}
idIndex = append(idIndex, e.ID)
}
// Write an index.json in the ID directory with a list of all the IDs.
return writeJSON(filepath.Join(idDir, "index.json"), idIndex, indent)
}
// Write a JSON file containing a map from alias to GO IDs.
func writeAliasIndex(dir string, entries []osv.Entry, indent bool) error {
aliasToGoIDs := map[string][]string{}
for _, e := range entries {
for _, a := range e.Aliases {
aliasToGoIDs[a] = append(aliasToGoIDs[a], e.ID)
}
}
return writeJSON(filepath.Join(dir, "aliases.json"), aliasToGoIDs, indent)
}
func writeJSON(filename string, value any, indent bool) (err error) {
j, err := jsonMarshal(value, indent)
if err != nil {
return err
}
return os.WriteFile(filename, j, 0644)
}
func jsonMarshal(v any, indent bool) ([]byte, error) {
if indent {
return json.MarshalIndent(v, "", " ")
}
return json.Marshal(v)
}
// generateOSVEntry create an osv.Entry for a report. In addition to the report, it
// takes the ID for the vuln and a URL that will point to the entry in the vuln DB.
// It returns the osv.Entry and a list of module paths that the vuln affects.
func generateOSVEntry(id, url string, lastModified time.Time, r Report) (osv.Entry, []string) {
entry := osv.Entry{
ID: id,
Published: r.Published,
Modified: lastModified,
Withdrawn: r.Withdrawn,
Details: r.Description,
}
moduleMap := make(map[string]bool)
for _, m := range r.Modules {
switch m.Module {
case stdModule:
moduleMap[stdFileName] = true
case cmdModule:
moduleMap[toolchainFileName] = true
default:
moduleMap[m.Module] = true
}
entry.Affected = append(entry.Affected, generateAffected(m, url))
}
for _, ref := range r.References {
entry.References = append(entry.References, osv.Reference{
Type: string(ref.Type),
URL: ref.URL,
})
}
var modulePaths []string
for module := range moduleMap {
modulePaths = append(modulePaths, module)
}
// TODO: handle missing fields - Aliases
return entry, modulePaths
}
func generateAffectedRanges(versions []VersionRange) osv.Affects {
a := osv.AffectsRange{Type: osv.TypeSemver}
if len(versions) == 0 || versions[0].Introduced == "" {
a.Events = append(a.Events, osv.RangeEvent{Introduced: "0"})
}
for _, v := range versions {
if v.Introduced != "" {
a.Events = append(a.Events, osv.RangeEvent{Introduced: v.Introduced.Canonical()})
}
if v.Fixed != "" {
a.Events = append(a.Events, osv.RangeEvent{Fixed: v.Fixed.Canonical()})
}
}
return osv.Affects{a}
}
func generateImports(m *Module) (imps []osv.EcosystemSpecificImport) {
for _, p := range m.Packages {
syms := append([]string{}, p.Symbols...)
syms = append(syms, p.DerivedSymbols...)
sort.Strings(syms)
imps = append(imps, osv.EcosystemSpecificImport{
Path: p.Package,
GOOS: p.GOOS,
GOARCH: p.GOARCH,
Symbols: syms,
})
}
return imps
}
func generateAffected(m *Module, url string) osv.Affected {
name := m.Module
switch name {
case stdModule:
name = "stdlib"
case cmdModule:
name = "toolchain"
}
return osv.Affected{
Package: osv.Package{
Name: name,
Ecosystem: osv.GoEcosystem,
},
Ranges: generateAffectedRanges(m.Versions),
DatabaseSpecific: osv.DatabaseSpecific{URL: url},
EcosystemSpecific: osv.EcosystemSpecific{
Imports: generateImports(m),
},
}
}

View File

@ -0,0 +1,61 @@
// Copyright 2022 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.
//go:build go1.18
// +build go1.18
package vulntest
import (
"context"
"encoding/json"
"testing"
)
func TestNewDatabase(t *testing.T) {
ctx := context.Background()
in := []byte(`
-- GO-2020-0001.yaml --
modules:
- module: github.com/gin-gonic/gin
versions:
- fixed: 1.6.0
packages:
- package: github.com/gin-gonic/gin
symbols:
- defaultLogFormatter
description: |
Something.
published: 2021-04-14T20:04:52Z
references:
- fix: https://github.com/gin-gonic/gin/pull/2237
`)
db, err := NewDatabase(ctx, in)
if err != nil {
t.Fatal(err)
}
defer db.Clean()
cli, err := NewClient(db)
if err != nil {
t.Fatal(err)
}
got, err := cli.GetByID(ctx, "GO-2020-0001")
if err != nil {
t.Fatal(err)
}
if got.ID != "GO-2020-0001" {
m, _ := json.Marshal(got)
t.Errorf("got %s\nwant GO-2020-0001 entry", m)
}
gotAll, err := cli.GetByModule(ctx, "github.com/gin-gonic/gin")
if err != nil {
t.Fatal(err)
}
if len(gotAll) != 1 || gotAll[0].ID != "GO-2020-0001" {
m, _ := json.Marshal(got)
t.Errorf("got %s\nwant GO-2020-0001 entry", m)
}
}

View File

@ -0,0 +1,176 @@
// Copyright 2022 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.
//go:build go1.18
// +build go1.18
package vulntest
import (
"fmt"
"io"
"os"
"strings"
"time"
"golang.org/x/mod/semver"
"gopkg.in/yaml.v3"
)
//
// The following was selectively copied from golang.org/x/vulndb/internal/report
//
// readReport reads a Report in YAML format.
func readReport(in io.Reader) (*Report, error) {
d := yaml.NewDecoder(in)
// Require that all fields in the file are in the struct.
// This corresponds to v2's UnmarshalStrict.
d.KnownFields(true)
var r Report
if err := d.Decode(&r); err != nil {
return nil, fmt.Errorf("yaml.Decode: %v", err)
}
return &r, nil
}
// Report represents a vulnerability report in the vulndb.
// Remember to update doc/format.md when this structure changes.
type Report struct {
Modules []*Module `yaml:",omitempty"`
// Description is the CVE description from an existing CVE. If we are
// assigning a CVE ID ourselves, use CVEMetadata.Description instead.
Description string `yaml:",omitempty"`
Published time.Time `yaml:",omitempty"`
Withdrawn *time.Time `yaml:",omitempty"`
References []*Reference `yaml:",omitempty"`
}
// Write writes r to filename in YAML format.
func (r *Report) Write(filename string) (err error) {
f, err := os.Create(filename)
if err != nil {
return err
}
err = r.encode(f)
err2 := f.Close()
if err == nil {
err = err2
}
return err
}
// ToString encodes r to a YAML string.
func (r *Report) ToString() (string, error) {
var b strings.Builder
if err := r.encode(&b); err != nil {
return "", err
}
return b.String(), nil
}
func (r *Report) encode(w io.Writer) error {
e := yaml.NewEncoder(w)
defer e.Close()
e.SetIndent(4)
return e.Encode(r)
}
type VersionRange struct {
Introduced Version `yaml:"introduced,omitempty"`
Fixed Version `yaml:"fixed,omitempty"`
}
type Module struct {
Module string `yaml:",omitempty"`
Versions []VersionRange `yaml:",omitempty"`
Packages []*Package `yaml:",omitempty"`
}
type Package struct {
Package string `yaml:",omitempty"`
GOOS []string `yaml:"goos,omitempty"`
GOARCH []string `yaml:"goarch,omitempty"`
// Symbols originally identified as vulnerable.
Symbols []string `yaml:",omitempty"`
// Additional vulnerable symbols, computed from Symbols via static analysis
// or other technique.
DerivedSymbols []string `yaml:"derived_symbols,omitempty"`
}
// Version is an SemVer 2.0.0 semantic version with no leading "v" prefix,
// as used by OSV.
type Version string
// V returns the version with a "v" prefix.
func (v Version) V() string {
return "v" + string(v)
}
// IsValid reports whether v is a valid semantic version string.
func (v Version) IsValid() bool {
return semver.IsValid(v.V())
}
// Before reports whether v < v2.
func (v Version) Before(v2 Version) bool {
return semver.Compare(v.V(), v2.V()) < 0
}
// Canonical returns the canonical formatting of the version.
func (v Version) Canonical() string {
return strings.TrimPrefix(semver.Canonical(v.V()), "v")
}
// Reference type is a reference (link) type.
type ReferenceType string
const (
ReferenceTypeAdvisory = ReferenceType("ADVISORY")
ReferenceTypeArticle = ReferenceType("ARTICLE")
ReferenceTypeReport = ReferenceType("REPORT")
ReferenceTypeFix = ReferenceType("FIX")
ReferenceTypePackage = ReferenceType("PACKAGE")
ReferenceTypeEvidence = ReferenceType("EVIDENCE")
ReferenceTypeWeb = ReferenceType("WEB")
)
// ReferenceTypes is the set of reference types defined in OSV.
var ReferenceTypes = []ReferenceType{
ReferenceTypeAdvisory,
ReferenceTypeArticle,
ReferenceTypeReport,
ReferenceTypeFix,
ReferenceTypePackage,
ReferenceTypeEvidence,
ReferenceTypeWeb,
}
// A Reference is a link to some external resource.
//
// For ease of typing, References are represented in the YAML as a
// single-element mapping of type to URL.
type Reference struct {
Type ReferenceType `json:"type,omitempty"`
URL string `json:"url,omitempty"`
}
func (r *Reference) MarshalYAML() (interface{}, error) {
return map[string]string{
strings.ToLower(string(r.Type)): r.URL,
}, nil
}
func (r *Reference) UnmarshalYAML(n *yaml.Node) (err error) {
if n.Kind != yaml.MappingNode || len(n.Content) != 2 || n.Content[0].Kind != yaml.ScalarNode || n.Content[1].Kind != yaml.ScalarNode {
return &yaml.TypeError{Errors: []string{
fmt.Sprintf("line %d: report.Reference must contain a mapping with one value", n.Line),
}}
}
r.Type = ReferenceType(strings.ToUpper(n.Content[0].Value))
r.URL = n.Content[1].Value
return nil
}

View File

@ -0,0 +1,52 @@
// Copyright 2022 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.
//go:build go1.18
// +build go1.18
package vulntest
import (
"bytes"
"io"
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/google/go-cmp/cmp"
)
func readAll(t *testing.T, filename string) io.Reader {
d, err := ioutil.ReadFile(filename)
if err != nil {
t.Fatal(err)
}
return bytes.NewReader(d)
}
func TestRoundTrip(t *testing.T) {
// A report shouldn't change after being read and then written.
in := filepath.Join("testdata", "report.yaml")
r, err := readReport(readAll(t, in))
if err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "report.yaml")
if err := r.Write(out); err != nil {
t.Fatal(err)
}
want, err := os.ReadFile(in)
if err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("mismatch (-want, +got):\n%s", diff)
}
}

View File

@ -0,0 +1,26 @@
// Copyright 2022 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.
//go:build go1.18
// +build go1.18
package vulntest
import (
"strings"
"golang.org/x/mod/module"
)
// maybeStdlib reports whether the given import path could be part of the Go
// standard library, by reporting whether the first component lacks a '.'.
func maybeStdlib(path string) bool {
if err := module.CheckImportPath(path); err != nil {
return false
}
if i := strings.IndexByte(path, '/'); i != -1 {
path = path[:i]
}
return !strings.Contains(path, ".")
}

View File

@ -0,0 +1,27 @@
// Copyright 2022 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.
//go:build go1.18
// +build go1.18
package vulntest
import "testing"
func TestMaybeStdlib(t *testing.T) {
for _, test := range []struct {
in string
want bool
}{
{"", false},
{"math/crypto", true},
{"github.com/pkg/errors", false},
{"Path is unknown", false},
} {
got := maybeStdlib(test.in)
if got != test.want {
t.Errorf("%q: got %t, want %t", test.in, got, test.want)
}
}
}

View File

@ -0,0 +1,15 @@
modules:
- module: github.com/gin-gonic/gin
versions:
- fixed: 1.6.0
packages:
- package: github.com/gin-gonic/gin
symbols:
- defaultLogFormatter
description: |
The default Formatter for the Logger middleware (LoggerConfig.Formatter),
which is included in the Default engine, allows attackers to inject arbitrary
log entries by manipulating the request path.
references:
- fix: https://github.com/gin-gonic/gin/pull/1234
- fix: https://github.com/gin-gonic/gin/commit/abcdefg