feat(monitor/collectors/cve): NVD CVE 2.0 client (apiKey header, last-modified window, NVDTime)
This commit is contained in:
parent
2a61953cb9
commit
f5e8cf5548
|
|
@ -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, ""
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue