internal/ghsa: a package for getting GitHub security advisories

Change-Id: Ie8448aa59d09534fc3cde570590b5f9a609ae93e
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/383894
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Damien Neil <dneil@google.com>
Trust: Damien Neil <dneil@google.com>
diff --git a/go.mod b/go.mod
index e7c8d54..344be19 100644
--- a/go.mod
+++ b/go.mod
@@ -19,6 +19,7 @@
 	github.com/google/go-github/v41 v41.0.0
 	github.com/google/safehtml v0.0.2
 	github.com/jba/templatecheck v0.6.0
+	github.com/shurcooL/githubv4 v0.0.0-20220115235240-a14260e6f8a2
 	go.opentelemetry.io/otel v1.3.0
 	go.opentelemetry.io/otel/sdk v1.3.0
 	go.opentelemetry.io/otel/trace v1.3.0
@@ -62,6 +63,7 @@
 	github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
 	github.com/sergi/go-diff v1.1.0 // indirect
+	github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a // indirect
 	github.com/xanzy/ssh-agent v0.3.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
 	golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
diff --git a/go.sum b/go.sum
index 944f4b8..455445d 100644
--- a/go.sum
+++ b/go.sum
@@ -435,6 +435,10 @@
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/shurcooL/githubv4 v0.0.0-20220115235240-a14260e6f8a2 h1:82EIpiGB79OIPgSGa63Oj4Ipf+YAX1c6A9qjmEYoRXc=
+github.com/shurcooL/githubv4 v0.0.0-20220115235240-a14260e6f8a2/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
+github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a h1:KikTa6HtAK8cS1qjvUvvq4QO21QnwC+EfvB+OAuZ/ZU=
+github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
 github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
 github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
diff --git a/internal/ghsa/ghsa.go b/internal/ghsa/ghsa.go
new file mode 100644
index 0000000..55775fe
--- /dev/null
+++ b/internal/ghsa/ghsa.go
@@ -0,0 +1,163 @@
+// 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"
+	"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
+	// 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. Examples are GitHub and CVE.
+type Identifier struct {
+	Type  string
+	Value 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
+}
+
+// List returns all SecurityAdvisories that are not CVEs and that affect Go,
+// published or updated since the given time.
+func List(ctx context.Context, accessToken string, since time.Time) ([]*SecurityAdvisory, error) {
+	ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken})
+	tc := oauth2.NewClient(context.Background(), ts)
+	client := githubv4.NewClient(tc)
+
+	var query struct { // the GraphQL query
+		SAs struct {
+			Nodes []struct {
+				ID              string
+				Identifiers     []Identifier
+				Summary         string
+				Description     string
+				Origin          string
+				Permalink       githubv4.URI
+				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
+			}
+			PageInfo struct {
+				EndCursor   githubv4.String
+				HasNextPage bool
+			}
+		} `graphql:"securityAdvisories(updatedSince: $since, first: 100, after: $cursor)"`
+	}
+	vars := map[string]interface{}{
+		"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 := client.Query(ctx, &query, vars); err != nil {
+			return nil, err
+		}
+		for _, sa := range query.SAs.Nodes {
+			if sa.PublishedAt.After(sa.UpdatedAt) {
+				return nil, fmt.Errorf("%s: published at %s, after updated at %s", sa.ID, sa.PublishedAt, sa.UpdatedAt)
+			}
+			if isCVE(sa.Identifiers) {
+				continue
+			}
+			if len(sa.Vulnerabilities.Nodes) == 0 {
+				continue
+			}
+			if sa.Vulnerabilities.PageInfo.HasNextPage {
+				return nil, fmt.Errorf("%s has more than 100 vulns", sa.ID)
+			}
+			s := &SecurityAdvisory{
+				ID:          sa.ID,
+				Identifiers: sa.Identifiers,
+				Summary:     sa.Summary,
+				Description: sa.Description,
+				Origin:      sa.Origin,
+				Permalink:   sa.Permalink.URL.String(),
+				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,
+				})
+			}
+			sas = append(sas, s)
+		}
+		if !query.SAs.PageInfo.HasNextPage {
+			break
+		}
+		vars["cursor"] = githubv4.NewString(query.SAs.PageInfo.EndCursor)
+	}
+	return sas, nil
+}
+
+func isCVE(ids []Identifier) bool {
+	for _, id := range ids {
+		if id.Type == "CVE" {
+			return true
+		}
+	}
+	return false
+}
diff --git a/internal/ghsa/ghsa_test.go b/internal/ghsa/ghsa_test.go
new file mode 100644
index 0000000..664b33e
--- /dev/null
+++ b/internal/ghsa/ghsa_test.go
@@ -0,0 +1,37 @@
+// 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
+
+import (
+	"context"
+	"flag"
+	"os"
+	"strings"
+	"testing"
+	"time"
+)
+
+var githubTokenFile = flag.String("ghtokenfile", "",
+	"path to file containing GitHub access token")
+
+func TestList(t *testing.T) {
+	if *githubTokenFile == "" {
+		t.Skip("-ghtokenfile not provided")
+	}
+	bytes, err := os.ReadFile(*githubTokenFile)
+	if err != nil {
+		t.Fatal(err)
+	}
+	accessToken := strings.TrimSpace(string(bytes))
+	// There were three relevant SAs From Jan 1 to Feb 7 2022.
+	got, err := List(context.Background(), accessToken, time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC))
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := 3
+	if len(got) < want {
+		t.Errorf("got %d, want at least %d", len(got), want)
+	}
+}