blob: 2e9663b5e7d144d9413fe7cf092885184c6fcd3b [file] [log] [blame]
// Copyright 2023 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 client
import (
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"strings"
"time"
"golang.org/x/mod/module"
"golang.org/x/vuln/internal"
"golang.org/x/vuln/internal/derrors"
"golang.org/x/vuln/internal/osv"
"golang.org/x/vuln/internal/web"
)
func NewLegacyClient(source string, opts *Options) (_ Client, err error) {
source = strings.TrimRight(source, "/")
uri, err := url.Parse(source)
if err != nil {
return nil, err
}
switch uri.Scheme {
case "http", "https":
return newLegacyHTTPClient(uri, opts), nil
case "file":
return newLegacyLocalClient(uri)
default:
return nil, fmt.Errorf("source %q has unsupported scheme", uri)
}
}
// Pseudo-module paths used for parts of the Go system.
// These are technically not valid module paths, so we
// mustn't pass them to module.EscapePath.
// Keep in sync with vulndb/internal/database/generate.go.
var specialCaseModulePaths = map[string]bool{
internal.GoStdModulePath: true,
internal.GoCmdModulePath: true,
}
// dbIndex contains a mapping of vulnerable packages to the last time a new
// vulnerability was added to the database.
type dbIndex map[string]time.Time
type httpClient struct {
c *http.Client
url string // the base URI of the source (without trailing "/"). e.g. https://vuln.golang.org
// indexCalls counts the number of times index() has been called.
// httpCalls counts the number of times ByModule makes an http request
// to vulndb for a module path. Used for testing privacy properties of
// httpSource.
indexCalls int
httpCalls int
}
func newLegacyHTTPClient(uri *url.URL, opts *Options) (_ *httpClient) {
hs := &httpClient{url: uri.String()}
if opts != nil && opts.HTTPClient != nil {
hs.c = opts.HTTPClient
} else {
hs.c = new(http.Client)
}
return hs
}
func (hs *httpClient) index(ctx context.Context) (_ dbIndex, err error) {
hs.indexCalls++ // for testing privacy properties
defer derrors.Wrap(&err, "Index()")
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/index.json", hs.url), nil)
if err != nil {
return nil, err
}
resp, err := hs.c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var index dbIndex
if err = json.Unmarshal(b, &index); err != nil {
return nil, err
}
return index, nil
}
func (hs *httpClient) ByModule(ctx context.Context, modulePath string) (_ []*osv.Entry, err error) {
defer derrors.Wrap(&err, "httpSource.ByModule(%q)", modulePath)
index, err := hs.index(ctx)
if err != nil {
return nil, err
}
_, present := index[modulePath]
if !present {
return nil, nil
}
epath, err := escapeModulePath(modulePath)
if err != nil {
return nil, err
}
hs.httpCalls++ // for testing privacy properties
entries, err := httpReadJSON[[]*osv.Entry](ctx, hs, epath+".json")
if err != nil || entries == nil {
return nil, err
}
return entries, nil
}
// escapeModulePath should be called by cache implementations or other users of
// this package that want to use module paths as filesystem paths. It is like
// golang.org/x/mod/module, but accounts for special paths used by the
// vulnerability database.
func escapeModulePath(path string) (string, error) {
if specialCaseModulePaths[path] {
return path, nil
}
return module.EscapePath(path)
}
func httpReadJSON[T any](ctx context.Context, hs *httpClient, relativePath string) (T, error) {
var zero T
content, err := hs.readBody(ctx, fmt.Sprintf("%s/%s", hs.url, relativePath))
if err != nil {
return zero, err
}
if len(content) == 0 {
return zero, nil
}
var t T
if err := json.Unmarshal(content, &t); err != nil {
return zero, err
}
return t, nil
}
// This is the format for the last-modified header, as described at
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified.
var lastModifiedFormat = "Mon, 2 Jan 2006 15:04:05 GMT"
func (hs *httpClient) LastModifiedTime(ctx context.Context) (_ time.Time, err error) {
defer derrors.Wrap(&err, "LastModifiedTime()")
// Assume that if anything changes, the index does.
url := fmt.Sprintf("%s/index.json", hs.url)
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return time.Time{}, err
}
resp, err := hs.c.Do(req)
if err != nil {
return time.Time{}, err
}
if resp.StatusCode != 200 {
return time.Time{}, fmt.Errorf("got status code %d", resp.StatusCode)
}
h := resp.Header.Get("Last-Modified")
return time.Parse(lastModifiedFormat, h)
}
func (hs *httpClient) readBody(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := hs.c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("got HTTP status %s", resp.Status)
}
// might want this to be a LimitedReader
return io.ReadAll(resp.Body)
}
type Options struct {
HTTPClient *http.Client
}
type localClient struct {
fs fs.FS
}
func newFSClient(fs fs.FS) (*localClient, error) {
return &localClient{fs: fs}, nil
}
func newLegacyLocalClient(uri *url.URL) (_ *localClient, err error) {
dir, err := web.URLToFilePath(uri)
if err != nil {
return nil, err
}
fi, err := os.Stat(dir)
if err != nil {
return nil, err
}
if !fi.IsDir() {
return nil, fmt.Errorf("%s is not a directory", dir)
}
return newFSClient(os.DirFS(dir))
}
func (ls *localClient) ByModule(ctx context.Context, modulePath string) (_ []*osv.Entry, err error) {
defer derrors.Wrap(&err, "localSource.ByModule(%q)", modulePath)
index, err := localReadJSON[dbIndex](ls, "index.json")
if err != nil {
return nil, err
}
// Query index first to be consistent with the way httpSource.ByModule works.
// Prevents opening and stating files on disk that don't need to be touched. Also
// solves #56179.
if _, present := index[modulePath]; !present {
return nil, nil
}
epath, err := escapeModulePath(modulePath)
if err != nil {
return nil, err
}
e, err := localReadJSON[[]*osv.Entry](ls, epath+".json")
if os.IsNotExist(err) {
return nil, nil
} else if err != nil {
return nil, err
}
return e, nil
}
func (ls *localClient) LastModifiedTime(context.Context) (_ time.Time, err error) {
defer derrors.Wrap(&err, "LastModifiedTime()")
// Assume that if anything changes, the index does.
info, err := fs.Stat(ls.fs, "index.json")
if err != nil {
return time.Time{}, err
}
return info.ModTime(), nil
}
func localReadJSON[T any](ls *localClient, relativePath string) (T, error) {
var zero T
content, err := fs.ReadFile(ls.fs, relativePath)
if err != nil {
return zero, err
}
var t T
if err := json.Unmarshal(content, &t); err != nil {
return zero, err
}
return t, nil
}