feat(monitor/collectors/cve): NVD CVE 2.0 client (apiKey header, last-modified window, NVDTime)

This commit is contained in:
CarterPerez-dev 2026-05-01 22:39:29 -04:00
parent 2a61953cb9
commit f5e8cf5548
3 changed files with 212 additions and 0 deletions

View File

@ -0,0 +1,133 @@
// ©AngelaMos | 2026
// nvd_client.go
package cve
import (
"context"
"fmt"
"net/url"
"time"
"golang.org/x/time/rate"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx"
)
const (
defaultNVDBaseURL = "https://services.nvd.nist.gov"
pathNVDCVE2 = "/rest/json/cves/2.0"
defaultNVDRate = 600 * time.Millisecond
defaultNVDBurst = 5
defaultNVDBudget = 5
defaultNVDBreakerWin = 120 * time.Second
nvdTimeFormat = "2006-01-02T15:04:05.000"
nvdAPIKeyHeader = "apiKey"
)
type NVDClientConfig struct {
BaseURL string
APIKey string
}
type NVDClient struct {
hx *httpx.Client
}
func NewNVDClient(cfg NVDClientConfig) *NVDClient {
if cfg.BaseURL == "" {
cfg.BaseURL = defaultNVDBaseURL
}
return &NVDClient{
hx: httpx.New(httpx.Config{
Name: "nvd",
BaseURL: cfg.BaseURL,
APIKey: cfg.APIKey,
APIKeyHeader: nvdAPIKeyHeader,
Rate: rate.Every(defaultNVDRate),
Burst: defaultNVDBurst,
ConsecutiveFailureBudget: defaultNVDBudget,
BreakerTimeout: defaultNVDBreakerWin,
}),
}
}
type NVDResponse struct {
ResultsPerPage int `json:"resultsPerPage"`
StartIndex int `json:"startIndex"`
TotalResults int `json:"totalResults"`
Vulnerabilities []NVDVulnRoot `json:"vulnerabilities"`
}
type NVDVulnRoot struct {
CVE NVDCVE `json:"cve"`
}
type NVDCVE struct {
ID string `json:"id"`
Published NVDTime `json:"published"`
LastModified NVDTime `json:"lastModified"`
Metrics NVDMetrics `json:"metrics"`
}
type NVDTime struct {
time.Time
}
func (t *NVDTime) UnmarshalJSON(b []byte) error {
s := string(b)
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
s = s[1 : len(s)-1]
}
if s == "" || s == "null" {
return nil
}
for _, layout := range []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.999",
"2006-01-02T15:04:05",
} {
if parsed, err := time.Parse(layout, s); err == nil {
t.Time = parsed.UTC()
return nil
}
}
return fmt.Errorf("nvd time: unrecognized format %q", s)
}
type NVDMetrics struct {
CVSSv31 []NVDMetricEntry `json:"cvssMetricV31"`
CVSSv30 []NVDMetricEntry `json:"cvssMetricV30"`
}
type NVDMetricEntry struct {
CVSSData NVDCVSSData `json:"cvssData"`
}
type NVDCVSSData struct {
BaseScore float64 `json:"baseScore"`
BaseSeverity string `json:"baseSeverity"`
}
func (c *NVDClient) Fetch(ctx context.Context, start, end time.Time) (NVDResponse, error) {
q := url.Values{
"lastModStartDate": []string{start.UTC().Format(nvdTimeFormat)},
"lastModEndDate": []string{end.UTC().Format(nvdTimeFormat)},
}
var resp NVDResponse
if err := c.hx.GetJSON(ctx, pathNVDCVE2, q, &resp); err != nil {
return NVDResponse{}, fmt.Errorf("nvd fetch: %w", err)
}
return resp, nil
}
func (v NVDVulnRoot) PrimarySeverity() (float64, string) {
if len(v.CVE.Metrics.CVSSv31) > 0 {
return v.CVE.Metrics.CVSSv31[0].CVSSData.BaseScore, v.CVE.Metrics.CVSSv31[0].CVSSData.BaseSeverity
}
if len(v.CVE.Metrics.CVSSv30) > 0 {
return v.CVE.Metrics.CVSSv30[0].CVSSData.BaseScore, v.CVE.Metrics.CVSSv30[0].CVSSData.BaseSeverity
}
return 0, ""
}

View File

@ -0,0 +1,78 @@
// ©AngelaMos | 2026
// nvd_client_test.go
package cve_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve"
)
func TestNVDClient_FetchSendsAPIKeyAndDecodes(t *testing.T) {
var sawKey, sawStart, sawEnd string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawKey = r.Header.Get("apiKey")
sawStart = r.URL.Query().Get("lastModStartDate")
sawEnd = r.URL.Query().Get("lastModEndDate")
body, err := os.ReadFile("testdata/nvd_2h_window.json")
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}))
defer srv.Close()
c := cve.NewNVDClient(cve.NVDClientConfig{BaseURL: srv.URL, APIKey: "test-key"})
end := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
start := end.Add(-2 * time.Hour)
resp, err := c.Fetch(context.Background(), start, end)
require.NoError(t, err)
require.NotEmpty(t, resp.Vulnerabilities)
require.Equal(t, "test-key", sawKey)
require.NotEmpty(t, sawStart)
require.NotEmpty(t, sawEnd)
}
func TestNVDClient_PrimarySeverityFromV31(t *testing.T) {
v := cve.NVDVulnRoot{CVE: cve.NVDCVE{
ID: "CVE-2026-X",
Metrics: cve.NVDMetrics{
CVSSv31: []cve.NVDMetricEntry{
{CVSSData: cve.NVDCVSSData{BaseScore: 9.8, BaseSeverity: "CRITICAL"}},
},
},
}}
score, sev := v.PrimarySeverity()
require.InDelta(t, 9.8, score, 0.0001)
require.Equal(t, "CRITICAL", sev)
}
func TestNVDClient_PrimarySeverityFallsBackToV30(t *testing.T) {
v := cve.NVDVulnRoot{CVE: cve.NVDCVE{
ID: "CVE-2018-X",
Metrics: cve.NVDMetrics{
CVSSv30: []cve.NVDMetricEntry{
{CVSSData: cve.NVDCVSSData{BaseScore: 7.5, BaseSeverity: "HIGH"}},
},
},
}}
score, sev := v.PrimarySeverity()
require.InDelta(t, 7.5, score, 0.0001)
require.Equal(t, "HIGH", sev)
}
func TestNVDClient_PrimarySeverityZeroWhenMissing(t *testing.T) {
v := cve.NVDVulnRoot{CVE: cve.NVDCVE{ID: "CVE-X"}}
score, sev := v.PrimarySeverity()
require.Zero(t, score)
require.Empty(t, sev)
}

File diff suppressed because one or more lines are too long