| // 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. |
| |
| // Command cve provides utilities for managing CVE IDs and CVE Records via the |
| // MITRE CVE Services API. |
| package main |
| |
| import ( |
| "bufio" |
| "encoding/json" |
| "errors" |
| "flag" |
| "fmt" |
| "log" |
| "os" |
| "path/filepath" |
| "regexp" |
| "strings" |
| "time" |
| |
| "github.com/google/go-cmp/cmp" |
| "golang.org/x/vulndb/internal/cve5" |
| "golang.org/x/vulndb/internal/database" |
| "golang.org/x/vulndb/internal/idstr" |
| "golang.org/x/vulndb/internal/report" |
| ) |
| |
| var ( |
| apiKey = flag.String("key", |
| os.Getenv("CVE_API_KEY"), "key for accessing the CVE API (can also be set via env var CVE_API_KEY)") |
| apiUser = flag.String("user", |
| os.Getenv("CVE_API_USER"), "username for accessing the CVE API (can also be set via env var CVE_API_USER)") |
| testApiKey = flag.String("test-key", |
| os.Getenv("TEST_CVE_API_KEY"), "key for accessing the CVE API in test env (can also be set via env var TEST_CVE_API_KEY)") |
| testApiUser = flag.String("test-user", |
| os.Getenv("TEST_CVE_API_USER"), "username for accessing the CVE API in test env (can also be set via env var TEST_CVE_API_USER)") |
| apiOrg = flag.String("org", |
| "Go", "organization name for accessing the CVE API") |
| // Note: the cve tool does not currently support the dev endpoint as there |
| // is no clear use case for us. |
| test = flag.Bool("test", false, "whether to access the CVE API in the test environment") |
| |
| // flags for the reserve command |
| reserveN = flag.Int("n", 1, "reserve: the number of new CVE IDs to reserve") |
| reserveSequential = flag.Bool("seq", true, "reserve: if true, reserve new CVE ID batches in sequence") |
| |
| // flags for the list command |
| listState = flag.String("state", "", "list: filter by CVE state (RESERVED, PUBLISHED, or REJECTED)") |
| |
| // flags that apply to multiple commands |
| year = flag.Int("year", 0, "reserve: the CVE ID year for newly reserved CVE IDs (default is current year)\nlist: filter by the year in the CVE ID") |
| ) |
| |
| func main() { |
| out := flag.CommandLine.Output() |
| flag.Usage = func() { |
| fmt.Fprintln(out, "Command cve provides utilities for managing CVE IDs and CVE Records via the MITRE CVE Services API") |
| formatCmd := " %s: %s\n" |
| fmt.Fprintf(out, "usage: cve [-key] [-user] [-org] [-test] <cmd> ...\n commands:\n") |
| fmt.Fprintf(out, formatCmd, "[-n] [-seq] [-year] reserve", "reserves new CVE IDs") |
| fmt.Fprintf(out, formatCmd, "quota", "outputs the CVE ID quota of the authenticated organization") |
| fmt.Fprintf(out, formatCmd, "id {cve-id}", "outputs details on an assigned CVE ID (CVE-YYYY-NNNN)") |
| fmt.Fprintf(out, formatCmd, "record {cve-id}", "outputs the record associated with a CVE ID (CVE-YYYY-NNNN)") |
| fmt.Fprintf(out, formatCmd, "publish [{filename} | {issue ID}]", "publishes or updates a CVE Record from a YAML/JSON file or issue ID") |
| fmt.Fprintf(out, formatCmd, "org", "outputs details on the authenticated organization") |
| fmt.Fprintf(out, formatCmd, "[-year] [-state] list", "lists all CVE IDs for an organization") |
| flag.PrintDefaults() |
| } |
| |
| flag.Parse() |
| if flag.NArg() < 1 { |
| logFatalUsageErr("cve", fmt.Errorf("must provide subcommand")) |
| } |
| |
| c := cve5.NewClient(*cfgFromFlags()) |
| |
| cmd := flag.Arg(0) |
| switch cmd { |
| case "help": |
| flag.Usage() |
| case "reserve": |
| year := *year |
| if year == 0 { |
| year = currentYear() |
| } |
| mode := cve5.SequentialRequest |
| if !*reserveSequential { |
| mode = cve5.NonsequentialRequest |
| } |
| if err := reserve(c, cve5.ReserveOptions{ |
| NumIDs: *reserveN, |
| Year: year, |
| Mode: mode, |
| }); err != nil { |
| log.Fatalf("cve reserve: could not reserve any new CVEs due to error:\n %v", err) |
| } |
| case "quota": |
| if err := quota(c); err != nil { |
| log.Fatalf("cve quota: could not retrieve quota info due to error:\n %v", err) |
| } |
| case "id": |
| id, err := validateID(flag.Arg(1)) |
| if err != nil { |
| logFatalUsageErr("cve id", err) |
| } |
| if err := lookupID(c, id); err != nil { |
| log.Fatalf("cve id: could not retrieve CVE IDs due to error:\n %v", err) |
| } |
| case "record": |
| id, err := validateID(flag.Arg(1)) |
| if err != nil { |
| logFatalUsageErr("cve record", err) |
| } |
| if err := lookupRecord(c, id); err != nil { |
| log.Fatalf("cve record: could not retrieve CVE record due to error:\n %v", err) |
| } |
| case "publish": |
| args := flag.Args()[1:] |
| if len(args) == 0 { |
| logFatalUsageErr("cve publish", errors.New("must provide filename or issue ID")) |
| } |
| for _, arg := range args { |
| filename, err := argToFilename(arg) |
| if err != nil { |
| logFatalUsageErr("cve publish", err) |
| } |
| if !strings.HasSuffix(filename, ".json") && !strings.HasSuffix(filename, ".yaml") { |
| logFatalUsageErr("cve publish", errors.New("filename must end in '.json' or '.yaml'")) |
| } |
| if err := publish(c, filename); err != nil { |
| log.Printf("cve publish: could not publish CVE record due to error:\n %v", err) |
| } |
| } |
| case "org": |
| if err := lookupOrg(c); err != nil { |
| log.Fatalf("cve org: could not retrieve org info due to error:\n %v", err) |
| } |
| case "list": |
| // TODO(http://go.dev/issues/53258): allow time-based filters via flags. |
| var filters *cve5.ListOptions |
| if *listState != "" || *year != 0 { |
| filters = new(cve5.ListOptions) |
| state, err := validateState(*listState) |
| if err != nil { |
| logFatalUsageErr("cve list", err) |
| } |
| filters.State = state |
| filters.Year = *year |
| } |
| if err := list(c, filters); err != nil { |
| log.Fatalf("cve list: could not retrieve CVE IDs due to error:\n %v", err) |
| } |
| default: |
| logFatalUsageErr("cve", fmt.Errorf("unsupported command: %q", cmd)) |
| } |
| } |
| |
| func logFatalUsageErr(context string, err error) { |
| flag.Usage() |
| log.Fatalf("%s: %s\n", context, err) |
| } |
| |
| func currentYear() int { |
| year, _, _ := time.Now().Date() |
| return year |
| } |
| |
| func cfgFromFlags() *cve5.Config { |
| if *test { |
| if *testApiKey == "" { |
| logFatalUsageErr("cve", errors.New("the test CVE API key (flag -test-key or env var TEST_CVE_API_KEY) must be set in test env")) |
| } |
| if *testApiUser == "" { |
| logFatalUsageErr("cve", errors.New("the test CVE API user (flag -test-user or env var TEST_CVE_API_USER) must be set in test env")) |
| } |
| return &cve5.Config{ |
| Endpoint: cve5.TestEndpoint, |
| Key: *testApiKey, |
| Org: *apiOrg, |
| User: *testApiUser, |
| } |
| } |
| |
| if *apiKey == "" { |
| logFatalUsageErr("cve", errors.New("the CVE API key (flag -key or env var CVE_API_KEY) must be set in prod env")) |
| } |
| if *apiUser == "" { |
| logFatalUsageErr("cve", errors.New("the CVE API user (flag -user or env var CVE_API_USER) must be set in prod env")) |
| } |
| return &cve5.Config{ |
| Endpoint: cve5.ProdEndpoint, |
| Key: *apiKey, |
| Org: *apiOrg, |
| User: *apiUser, |
| } |
| } |
| |
| func validateID(id string) (string, error) { |
| if id == "" { |
| return "", errors.New("CVE ID must be provided") |
| } |
| if !idstr.IsCVE(id) { |
| return "", fmt.Errorf("%q is not a valid CVE ID", id) |
| } |
| return id, nil |
| } |
| |
| var stateRegex = regexp.MustCompile(`^(RESERVED|PUBLISHED|REJECTED)$`) |
| |
| func validateState(state string) (cve5.State, error) { |
| if state != "" && !stateRegex.MatchString(state) { |
| return "", fmt.Errorf("state must match regex %v", stateRegex) |
| } |
| return cve5.State(state), nil |
| } |
| |
| func reserve(c *cve5.Client, opts cve5.ReserveOptions) error { |
| cves, err := c.ReserveIDs(opts) |
| if err != nil { |
| return err |
| } |
| cvesReserved := len(cves) |
| if cvesReserved < opts.NumIDs { |
| fmt.Printf("warning: only %d of %d requested CVE IDs were reserved\n", |
| len(cves), opts.NumIDs) |
| } |
| fmt.Println(cves.ShortString()) |
| return nil |
| } |
| |
| func quota(c *cve5.Client) error { |
| quota, err := c.RetrieveQuota() |
| if err != nil { |
| return err |
| } |
| fmt.Printf("quota info for org %q:\n quota: %d\n total reserved: %d\n available: %d\n", c.Org, quota.Quota, quota.Reserved, quota.Available) |
| return nil |
| } |
| |
| func lookupOrg(c *cve5.Client) error { |
| org, err := c.RetrieveOrg() |
| if err != nil { |
| return err |
| } |
| fmt.Printf("org name: %q\nshort name: %q\nuuid: %s\n", org.Name, org.ShortName, org.UUID) |
| return nil |
| } |
| |
| func lookupID(c *cve5.Client, id string) error { |
| assigned, err := c.RetrieveID(id) |
| if err != nil { |
| return err |
| } |
| // Display the retrieved CVE ID metadata. |
| fmt.Println(assigned) |
| return nil |
| } |
| |
| // toJSON converts a struct into a JSON string. |
| // If JSON marshal fails, it falls back to fmt.Sprint. |
| func toJSON(v any) string { |
| s, err := json.Marshal(v) |
| if err != nil { |
| return fmt.Sprint(v) |
| } |
| return string(s) |
| } |
| |
| func lookupRecord(c *cve5.Client, id string) error { |
| record, err := c.RetrieveRecord(id) |
| if err != nil { |
| return err |
| } |
| // Display the retrieved CVE record. |
| fmt.Println(toJSON(record)) |
| return nil |
| } |
| |
| func argToFilename(arg string) (string, error) { |
| if arg == "" { |
| return "", errors.New("filename or issue ID must be provided") |
| } |
| if _, err := os.Stat(arg); err != nil { |
| // If arg isn't a file, see if it might be an issue ID |
| // with an existing CVE record. |
| for _, padding := range []string{"", "0", "00", "000"} { |
| m, _ := filepath.Glob("data/cve/v5/GO-*-" + padding + arg + ".json") |
| if len(m) == 1 { |
| return m[0], nil |
| } |
| } |
| return "", fmt.Errorf("%s is not a valid filename or issue ID with existing record", arg) |
| } |
| return arg, nil |
| } |
| |
| func publish(c *cve5.Client, filename string) (err error) { |
| if !strings.HasSuffix(filename, ".json") { |
| return errors.New("filename must end in '.json'") |
| } |
| |
| cveID, toPublish, err := cve5.ReadForPublish(filename) |
| if err != nil { |
| return err |
| } |
| |
| // Determine if the record should be created or updated. |
| assigned, err := c.RetrieveID(cveID) |
| if err != nil { |
| return err |
| } |
| |
| var ( |
| publishFunc func(string, *cve5.Containers) (*cve5.CVERecord, error) |
| action string |
| ) |
| switch state := assigned.State; state { |
| case cve5.StatePublished: |
| existing, err := c.RetrieveRecord(cveID) |
| if err != nil { |
| return err |
| } |
| fmt.Printf("%s is published at %s\n", cveID, c.WebURL(cveID)) |
| if diff := cmp.Diff(existing.Containers, *toPublish); diff != "" { |
| fmt.Printf("publish would update record with diff (-existing, +new):\n%s\n", diff) |
| // The CVE program sometimes adds references to CVEs, so we need |
| // to make sure we don't accidentally delete them. |
| if updated := handleDeleted(existing, toPublish, filename); updated { |
| // If we updated the CVE, check if any changes remain. |
| _, toPublish, err = cve5.ReadForPublish(filename) |
| if err != nil { |
| return err |
| } |
| if diff := cmp.Diff(existing.Containers, *toPublish); diff == "" { |
| fmt.Println("after adding missing refs, updating record would now have no effect") |
| return nil |
| } |
| fmt.Printf("after adding missing refs, publish would now update record with diff (-existing, +new):\n%s\n", diff) |
| } |
| } else { |
| fmt.Println("updating record would have no effect, skipping") |
| return nil |
| } |
| publishFunc = c.UpdateRecord |
| action = "update" |
| case cve5.StateReserved: |
| fmt.Printf("publish would create new record for %s\n", cveID) |
| publishFunc = c.CreateRecord |
| action = "create" |
| default: |
| return fmt.Errorf("publishing a %s record is not supported", state) |
| } |
| |
| reader := bufio.NewReader(os.Stdin) |
| fmt.Printf("%s record for %s? (y/N)\n", action, cveID) |
| text, _ := reader.ReadString('\n') |
| if text != "y\n" { |
| fmt.Printf("exiting without %sing record\n", strings.TrimSuffix(action, "e")) |
| return nil |
| } |
| |
| _, err = publishFunc(cveID, toPublish) |
| if err != nil { |
| return err |
| } |
| |
| fmt.Printf("successfully %sd record for %s at %s\n", action, cveID, c.WebURL(cveID)) |
| |
| return nil |
| } |
| |
| func handleDeleted(existing *cve5.CVERecord, toPublish *cve5.Containers, filename string) bool { |
| deleted := findDeleted(existing.Containers.CNAContainer.References, toPublish.CNAContainer.References) |
| if len(deleted) == 0 { |
| return false |
| } |
| goID := strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)) |
| yamlReportFile := fmt.Sprintf("data/reports/%s.yaml", goID) |
| // To preserve an externally-added reference, add it to |
| // cve_metadata.references. An example is GO-2022-0476. |
| // This warning may be spurious if a reference is deleted from |
| // a YAML report - in this case it should be ignored. |
| fmt.Printf( |
| `!! WARNING !! |
| updating record would delete %[1]d reference(s) that may have been added by the CVE program; |
| to preserve these references, add references to %[2]s and run "vulnreport fix %[2]s": |
| |
| cve_metadata: |
| ... |
| references: |
| ... |
| - %[3]s |
| |
| only update now if this warning is spurious (i.e., the records were deleted on purpose) |
| `, len(deleted), yamlReportFile, strings.Join(deleted, "\n - ")) |
| |
| reader := bufio.NewReader(os.Stdin) |
| fmt.Printf("try to auto-add missing refs? (y/N)\n") |
| text, _ := reader.ReadString('\n') |
| if text != "y\n" { |
| return false |
| } |
| |
| if err := addMissing(yamlReportFile, deleted); err != nil { |
| fmt.Println("ERROR: could not add missing refs: ", err) |
| return false |
| } |
| |
| fmt.Printf("successfully added %d missing refs\n", len(deleted)) |
| return true |
| } |
| |
| func addMissing(yamlFile string, missing []string) error { |
| r, err := report.Read(yamlFile) |
| if err != nil { |
| return err |
| } |
| r.CVEMetadata.References = append(r.CVEMetadata.References, missing...) |
| if err := r.Write(yamlFile); err != nil { |
| return err |
| } |
| cve, err := cve5.FromReport(r) |
| if err != nil { |
| return err |
| } |
| return database.WriteJSON(r.CVEFilename(), cve, true) |
| } |
| |
| // findDeleted returns a list of URLs in oldRefs that are not in newRefs. |
| func findDeleted(oldRefs []cve5.Reference, newRefs []cve5.Reference) (deleted []string) { |
| m := make(map[string]bool) |
| for _, r := range newRefs { |
| m[r.URL] = true |
| } |
| for _, r := range oldRefs { |
| if !m[r.URL] { |
| deleted = append(deleted, r.URL) |
| } |
| } |
| return deleted |
| } |
| |
| func list(c *cve5.Client, lf *cve5.ListOptions) error { |
| cves, err := c.ListOrgCVEs(lf) |
| if err != nil { |
| return err |
| } |
| var filterString string |
| if lf != nil { |
| filterString = fmt.Sprintf(" with filters %s", lf) |
| } |
| if n := len(cves); n > 0 { |
| fmt.Printf("found %d CVE IDs for org %q%s:\n%v\n", n, c.Org, filterString, cves) |
| } else { |
| fmt.Printf("found no CVE IDs for org %q%s\n", c.Org, filterString) |
| } |
| return nil |
| } |