blob: ba9ea3cd41b4be7397207c6d8a7388316108e9e3 [file] [log] [blame]
// 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.
// Package ghsa supports GitHub security advisories.
package ghsa
import (
"context"
"fmt"
"regexp"
"time"
"github.com/shurcooL/githubv4"
"golang.org/x/oauth2"
)
// A SecurityAdvisory represents a GitHub security advisory.
type SecurityAdvisory struct {
// The GitHub Security Advisory identifier.
ID string
// A complete list of identifiers, e.g. CVE numbers.
Identifiers []Identifier
// A short description of the advisory.
Summary string
// A full description of the advisory.
Description string
// Where the advisory came from.
Origin string
// A link to a page for the advisory.
Permalink string
// When the advisory was first published.
PublishedAt time.Time
// References linked to by this advisory.
References []Reference
// When the advisory was last updated; should always be >= PublishedAt.
UpdatedAt time.Time
// The vulnerabilities associated with this advisory.
Vulns []*Vuln
}
// An Identifier identifies an advisory according to some scheme or
// organization, given by the Type field. Example types are GHSA and CVE.
type Identifier struct {
Type string
Value string
}
// A Reference is a URL linked to by the advisory.
type Reference struct {
URL string
}
// A Vuln represents a vulnerability.
type Vuln struct {
// The vulnerable Go package or module.
Package string
// The severity of the vulnerability.
Severity githubv4.SecurityAdvisorySeverity
// The earliest fixed version.
EarliestFixedVersion string
// A string representing the range of vulnerable versions.
// E.g. ">= 1.0.3"
VulnerableVersionRange string
// When the vulnerability was last updated.
UpdatedAt time.Time
}
// A gqlSecurityAdvisory represents a GitHub security advisory structured for
// GitHub's GraphQL schema. The fields must be exported to be populated by
// Github's Client.Query function.
type gqlSecurityAdvisory struct {
GhsaID string
Identifiers []Identifier
Summary string
Description string
Origin string
Permalink githubv4.URI
References []Reference
PublishedAt time.Time
UpdatedAt time.Time
Vulnerabilities struct {
Nodes []struct {
Package struct {
Name string
Ecosystem string
}
FirstPatchedVersion struct{ Identifier string }
Severity githubv4.SecurityAdvisorySeverity
UpdatedAt time.Time
VulnerableVersionRange string
}
PageInfo struct {
HasNextPage bool
}
} `graphql:"vulnerabilities(first: 100, ecosystem: $go)"` // include only Go vulns
}
// securityAdvisory converts a gqlSecurityAdvisory into a SecurityAdvisory.
// Errors if the security advisory was updated before it was published, or if
// there are more than 100 vulnerabilities associated with the advisory.
func (sa *gqlSecurityAdvisory) securityAdvisory() (*SecurityAdvisory, error) {
if sa.PublishedAt.After(sa.UpdatedAt) {
return nil, fmt.Errorf("%s: published at %s, after updated at %s", sa.GhsaID, sa.PublishedAt, sa.UpdatedAt)
}
if sa.Vulnerabilities.PageInfo.HasNextPage {
return nil, fmt.Errorf("%s has more than 100 vulns", sa.GhsaID)
}
var permalink string
if sa.Permalink.URL != nil {
permalink = sa.Permalink.URL.String()
}
s := &SecurityAdvisory{
ID: sa.GhsaID,
Identifiers: sa.Identifiers,
Summary: sa.Summary,
Description: sa.Description,
Origin: sa.Origin,
Permalink: permalink,
References: sa.References,
PublishedAt: sa.PublishedAt,
UpdatedAt: sa.UpdatedAt,
}
for _, v := range sa.Vulnerabilities.Nodes {
s.Vulns = append(s.Vulns, &Vuln{
Package: v.Package.Name,
Severity: v.Severity,
EarliestFixedVersion: v.FirstPatchedVersion.Identifier,
VulnerableVersionRange: v.VulnerableVersionRange,
UpdatedAt: v.UpdatedAt,
})
}
return s, nil
}
// Client is a client that can fetch data about GitHub security advisories.
type Client struct {
client *githubv4.Client
token string
}
// NewClient creates a new client for making requests to the GHSA API.
func NewClient(ctx context.Context, accessToken string) *Client {
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken})
tc := oauth2.NewClient(ctx, ts)
return &Client{
client: githubv4.NewClient(tc),
token: accessToken,
}
}
// List returns all SecurityAdvisories that affect Go,
// published or updated since the given time.
func (c *Client) List(ctx context.Context, since time.Time) ([]*SecurityAdvisory, error) {
var query struct { // the GraphQL query
SAs struct {
Nodes []gqlSecurityAdvisory
PageInfo struct {
EndCursor githubv4.String
HasNextPage bool
}
} `graphql:"securityAdvisories(updatedSince: $since, first: 100, after: $cursor)"`
}
vars := map[string]any{
"cursor": (*githubv4.String)(nil),
"go": githubv4.SecurityAdvisoryEcosystemGo,
"since": githubv4.DateTime{Time: since},
}
var sas []*SecurityAdvisory
// We need a loop to page through the list. The GitHub API limits us to 100
// values per call.
for {
if err := c.client.Query(ctx, &query, vars); err != nil {
return nil, err
}
for _, sa := range query.SAs.Nodes {
if len(sa.Vulnerabilities.Nodes) == 0 {
continue
}
s, err := sa.securityAdvisory()
if err != nil {
return nil, err
}
sas = append(sas, s)
}
if !query.SAs.PageInfo.HasNextPage {
break
}
vars["cursor"] = githubv4.NewString(query.SAs.PageInfo.EndCursor)
}
return sas, nil
}
func (c *Client) ListForCVE(ctx context.Context, cve string) ([]*SecurityAdvisory, error) {
var query struct { // The GraphQL query
SAs struct {
Nodes []gqlSecurityAdvisory
PageInfo struct {
EndCursor githubv4.String
HasNextPage bool
}
} `graphql:"securityAdvisories(identifier: $id, first: 100)"`
}
vars := map[string]any{
"id": githubv4.SecurityAdvisoryIdentifierFilter{
Type: githubv4.SecurityAdvisoryIdentifierTypeCve,
Value: githubv4.String(cve),
},
"go": githubv4.SecurityAdvisoryEcosystemGo,
}
if err := c.client.Query(ctx, &query, vars); err != nil {
return nil, err
}
if query.SAs.PageInfo.HasNextPage {
return nil, fmt.Errorf("CVE %s has more than 100 GHSAs", cve)
}
var sas []*SecurityAdvisory
for _, sa := range query.SAs.Nodes {
if len(sa.Vulnerabilities.Nodes) == 0 {
continue
}
exactMatch := false
for _, id := range sa.Identifiers {
if id.Type == "CVE" && id.Value == cve {
exactMatch = true
continue
}
}
if !exactMatch {
continue
}
s, err := sa.securityAdvisory()
if err != nil {
return nil, err
}
sas = append(sas, s)
}
return sas, nil
}
// FetchGHSA returns the SecurityAdvisory for the given Github Security
// Advisory ID.
func (c *Client) FetchGHSA(ctx context.Context, ghsaID string) (_ *SecurityAdvisory, err error) {
var query struct {
SA gqlSecurityAdvisory `graphql:"securityAdvisory(ghsaId: $id)"`
}
vars := map[string]any{
"id": githubv4.String(ghsaID),
"go": githubv4.SecurityAdvisoryEcosystemGo,
}
if err := c.client.Query(ctx, &query, vars); err != nil {
return nil, err
}
return query.SA.securityAdvisory()
}
const Regex = `GHSA-[^-]{4}-[^-]{4}-[^-]{4}`
var ghsaStrict = regexp.MustCompile(`^` + Regex + `$`)
func IsGHSA(s string) bool {
return ghsaStrict.MatchString(s)
}