internal/worker/store: support GitHub security advisories

Add GHSARecord, for recording information about GitHub service
advisories.

Add methods to create, modify and retrieve GHSARecords.

Change-Id: I2f8e52d9fea6ec3888ff48b7ae2c17cc5b77a8c6
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/384095
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>
diff --git a/internal/worker/store/fire_store.go b/internal/worker/store/fire_store.go
index ad49a55..9921b6a 100644
--- a/internal/worker/store/fire_store.go
+++ b/internal/worker/store/fire_store.go
@@ -31,6 +31,7 @@
 // - CVEs for CVERecords
 // - CommitUpdates for CommitUpdateRecords
 // - DirHashes for directory hashes
+// - GHSAs for GHSARecords.
 type FireStore struct {
 	namespace string
 	client    *firestore.Client
@@ -42,6 +43,7 @@
 	updateCollection    = "Updates"
 	cveCollection       = "CVEs"
 	dirHashCollection   = "DirHashes"
+	ghsaCollection      = "GHSAs"
 )
 
 // NewFireStore creates a new FireStore, backed by a client to Firestore. Since
@@ -205,6 +207,11 @@
 	return fs.nsDoc.Collection(cveCollection).Doc(id)
 }
 
+// ghsaRecordRef returns a DocumentRef to the GHSARecord with id.
+func (fs *FireStore) ghsaRecordRef(id string) *firestore.DocumentRef {
+	return fs.nsDoc.Collection(ghsaCollection).Doc(id)
+}
+
 // fsTransaction implements Transaction
 type fsTransaction struct {
 	s *FireStore
@@ -259,6 +266,46 @@
 	return crs, nil
 }
 
+// CreateGHSARecord implements Transaction.CreateGHSARecord.
+func (tx *fsTransaction) CreateGHSARecord(r *GHSARecord) (err error) {
+	defer derrors.Wrap(&err, "FireStore.CreateGHSARecord(%s)", r.GHSA.ID)
+
+	return tx.t.Create(tx.s.ghsaRecordRef(r.GHSA.ID), r)
+}
+
+// SetGHSARecord implements Transaction.SetGHSARecord.
+func (tx *fsTransaction) SetGHSARecord(r *GHSARecord) (err error) {
+	defer derrors.Wrap(&err, "SetGHSARecord(%s)", r.GHSA.ID)
+
+	return tx.t.Set(tx.s.ghsaRecordRef(r.GHSA.ID), r)
+}
+
+// GetGHSARecords implements Transaction.GetGHSARecords.
+func (tx *fsTransaction) GetGHSARecords() (_ []*GHSARecord, err error) {
+	defer derrors.Wrap(&err, "GetGHSARecords()")
+
+	q := tx.s.nsDoc.Collection(ghsaCollection).
+		OrderBy(firestore.DocumentID, firestore.Asc)
+	iter := tx.t.Documents(q)
+	docsnaps, err := iter.GetAll()
+	if err != nil {
+		return nil, err
+	}
+	return docsnapsToGHSARecords(docsnaps)
+}
+
+func docsnapsToGHSARecords(docsnaps []*firestore.DocumentSnapshot) ([]*GHSARecord, error) {
+	var grs []*GHSARecord
+	for _, ds := range docsnaps {
+		var gr GHSARecord
+		if err := ds.DataTo(&gr); err != nil {
+			return nil, err
+		}
+		grs = append(grs, &gr)
+	}
+	return grs, nil
+}
+
 // Clear removes all documents in the namespace.
 func (s *FireStore) Clear(ctx context.Context) (err error) {
 	defer derrors.Wrap(&err, "Clear")
diff --git a/internal/worker/store/mem_store.go b/internal/worker/store/mem_store.go
index 89c819a..a52f1b0 100644
--- a/internal/worker/store/mem_store.go
+++ b/internal/worker/store/mem_store.go
@@ -20,6 +20,7 @@
 	cveRecords    map[string]*CVERecord
 	updateRecords map[string]*CommitUpdateRecord
 	dirHashes     map[string]string
+	ghsaRecords   map[string]*GHSARecord
 }
 
 // NewMemStore creates a new, empty MemStore.
@@ -34,6 +35,7 @@
 	ms.cveRecords = map[string]*CVERecord{}
 	ms.updateRecords = map[string]*CommitUpdateRecord{}
 	ms.dirHashes = map[string]string{}
+	ms.ghsaRecords = map[string]*GHSARecord{}
 	return nil
 }
 
@@ -158,3 +160,30 @@
 	})
 	return crs, nil
 }
+
+// CreateGHSARecord implements Transaction.CreateGHSARecord.
+func (tx *memTransaction) CreateGHSARecord(r *GHSARecord) error {
+	if _, ok := tx.ms.ghsaRecords[r.GHSA.ID]; ok {
+		return fmt.Errorf("GHSARecord %s already exists", r.GHSA.ID)
+	}
+	tx.ms.ghsaRecords[r.GHSA.ID] = r
+	return nil
+}
+
+// SetGHSARecord implements Transaction.SetGHSARecord.n
+func (tx *memTransaction) SetGHSARecord(r *GHSARecord) error {
+	if _, ok := tx.ms.ghsaRecords[r.GHSA.ID]; !ok {
+		return fmt.Errorf("GHSARecord %s does not exist", r.GHSA.ID)
+	}
+	tx.ms.ghsaRecords[r.GHSA.ID] = r
+	return nil
+}
+
+// GetGHSARecords returns all the GHSARecords in the database.
+func (tx *memTransaction) GetGHSARecords() ([]*GHSARecord, error) {
+	var recs []*GHSARecord
+	for _, r := range tx.ms.ghsaRecords {
+		recs = append(recs, r)
+	}
+	return recs, nil
+}
diff --git a/internal/worker/store/store.go b/internal/worker/store/store.go
index 6d1645b..7d05d4a 100644
--- a/internal/worker/store/store.go
+++ b/internal/worker/store/store.go
@@ -13,6 +13,7 @@
 
 	"github.com/go-git/go-git/v5/plumbing/object"
 	"golang.org/x/vulndb/internal/cveschema"
+	"golang.org/x/vulndb/internal/ghsa"
 )
 
 // A CVERecord contains information about a CVE.
@@ -170,6 +171,16 @@
 	UpdatedAt time.Time `firestore:",serverTimestamp"`
 }
 
+// A GHSARecord holds information about a GitHub security advisory.
+type GHSARecord struct {
+	// GHSA is the advisory.
+	GHSA *ghsa.SecurityAdvisory
+	// TriageState is the state of our triage processing on the CVE.
+	TriageState TriageState
+	// TriageStateReason is an explanation of TriageState.
+	TriageStateReason string
+}
+
 // A Store is a storage system for the CVE database.
 type Store interface {
 	// CreateCommitUpdateRecord creates a new CommitUpdateRecord. It should be called at the start
@@ -216,4 +227,15 @@
 	// GetCVERecords retrieves CVERecords for all CVE IDs between startID and
 	// endID, inclusive.
 	GetCVERecords(startID, endID string) ([]*CVERecord, error)
+
+	// CreateGHSARecord creates a new GHSARecord. It is an error if one with the same ID
+	// already exists.
+	CreateGHSARecord(*GHSARecord) error
+
+	// SetGHSARecord sets the GHSA record in the database. It is
+	// an error if no such record exists.
+	SetGHSARecord(*GHSARecord) error
+
+	// GetGHSARecords returns all the GHSARecords in the database.
+	GetGHSARecords() ([]*GHSARecord, error)
 }
diff --git a/internal/worker/store/store_test.go b/internal/worker/store/store_test.go
index faf9ff9..35dd90b 100644
--- a/internal/worker/store/store_test.go
+++ b/internal/worker/store/store_test.go
@@ -9,12 +9,14 @@
 
 import (
 	"context"
+	"sort"
 	"testing"
 	"time"
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
 	"golang.org/x/vulndb/internal/cveschema"
+	"golang.org/x/vulndb/internal/ghsa"
 )
 
 func must(t *testing.T, err error) {
@@ -34,6 +36,9 @@
 	t.Run("DirHashes", func(t *testing.T) {
 		testDirHashes(t, s)
 	})
+	t.Run("GHSAs", func(t *testing.T) {
+		testGHSAs(t, s)
+	})
 }
 
 func testUpdates(t *testing.T, s Store) {
@@ -194,6 +199,57 @@
 	}
 }
 
+func testGHSAs(t *testing.T, s Store) {
+	ctx := context.Background()
+	// Create two records.
+	gs := []*GHSARecord{
+		{
+			GHSA:        &ghsa.SecurityAdvisory{ID: "g1", Summary: "one"},
+			TriageState: TriageStateNeedsIssue,
+		},
+		{
+			GHSA:        &ghsa.SecurityAdvisory{ID: "g2", Summary: "two"},
+			TriageState: TriageStateNeedsIssue,
+		},
+	}
+	err := s.RunTransaction(ctx, func(ctx context.Context, tx Transaction) error {
+		for _, g := range gs {
+			if err := tx.CreateGHSARecord(g); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	// Modify one of them.
+	gs[1].TriageState = TriageStateIssueCreated
+	err = s.RunTransaction(ctx, func(ctx context.Context, tx Transaction) error {
+		return tx.SetGHSARecord(gs[1])
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	// Retrieve and compare.
+	var got []*GHSARecord
+	err = s.RunTransaction(ctx, func(ctx context.Context, tx Transaction) error {
+		var err error
+		got, err = tx.GetGHSARecords()
+		return err
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(got) != len(gs) {
+		t.Fatalf("got %d records, want %d", len(got), len(gs))
+	}
+	sort.Slice(got, func(i, j int) bool { return got[i].GHSA.ID < got[j].GHSA.ID })
+	if diff := cmp.Diff(gs, got); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+}
+
 func createCVERecords(t *testing.T, ctx context.Context, s Store, crs []*CVERecord) {
 	err := s.RunTransaction(ctx, func(ctx context.Context, tx Transaction) error {
 		for _, cr := range crs {