diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go new file mode 100644 index 00000000..7dc5cf7f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client.go @@ -0,0 +1,86 @@ +// ©AngelaMos | 2026 +// epss_client.go + +package cve + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "golang.org/x/time/rate" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx" +) + +const ( + epssBatchSize = 100 + defaultEPSSBaseURL = "https://api.first.org" + pathEPSS = "/data/v1/epss" + defaultEPSSRate = 500 * time.Millisecond + defaultEPSSBurst = 5 + defaultEPSSBudget = 5 + defaultEPSSBreakerWin = 60 * time.Second +) + +type EPSSClientConfig struct { + BaseURL string +} + +type EPSSClient struct { + hx *httpx.Client +} + +func NewEPSSClient(cfg EPSSClientConfig) *EPSSClient { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultEPSSBaseURL + } + return &EPSSClient{ + hx: httpx.New(httpx.Config{ + Name: "epss", + BaseURL: cfg.BaseURL, + Rate: rate.Every(defaultEPSSRate), + Burst: defaultEPSSBurst, + ConsecutiveFailureBudget: defaultEPSSBudget, + BreakerTimeout: defaultEPSSBreakerWin, + }), + } +} + +type EPSSScore struct { + CveID string `json:"cve"` + Score float64 `json:"-"` + Percentile float64 `json:"-"` + ScoreRaw string `json:"epss"` + PctileRaw string `json:"percentile"` + Date string `json:"date"` +} + +type EPSSResponse struct { + Data []EPSSScore `json:"data"` +} + +func (c *EPSSClient) LookupBatch(ctx context.Context, cveIDs []string) (map[string]EPSSScore, error) { + out := map[string]EPSSScore{} + for i := 0; i < len(cveIDs); i += epssBatchSize { + end := i + epssBatchSize + if end > len(cveIDs) { + end = len(cveIDs) + } + chunk := cveIDs[i:end] + q := url.Values{"cve": []string{strings.Join(chunk, ",")}} + var resp EPSSResponse + if err := c.hx.GetJSON(ctx, pathEPSS, q, &resp); err != nil { + return nil, fmt.Errorf("epss batch [%d:%d]: %w", i, end, err) + } + for _, s := range resp.Data { + s.Score, _ = strconv.ParseFloat(s.ScoreRaw, 64) + s.Percentile, _ = strconv.ParseFloat(s.PctileRaw, 64) + out[s.CveID] = s + } + } + return out, nil +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go new file mode 100644 index 00000000..638adf56 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/epss_client_test.go @@ -0,0 +1,64 @@ +// ©AngelaMos | 2026 +// epss_client_test.go + +package cve_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve" +) + +func TestEPSSClient_BatchLookupDecodesScores(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ids := r.URL.Query().Get("cve") + require.True(t, strings.Contains(ids, "CVE-2024-3094")) + body, err := os.ReadFile("testdata/epss_batch.json") + require.NoError(t, err) + _, _ = w.Write(body) + })) + defer srv.Close() + + c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: srv.URL}) + scores, err := c.LookupBatch(context.Background(), []string{"CVE-2024-3094", "CVE-2024-21413"}) + require.NoError(t, err) + require.NotEmpty(t, scores) + + got := scores["CVE-2024-3094"] + require.InDelta(t, 0.84588, got.Score, 0.0001) + require.InDelta(t, 0.99335, got.Percentile, 0.0001) +} + +func TestEPSSClient_ChunksOver100PerRequest(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer srv.Close() + + c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: srv.URL}) + + ids := make([]string, 250) + for i := range ids { + ids[i] = "CVE-2024-X" + } + _, err := c.LookupBatch(context.Background(), ids) + require.NoError(t, err) + require.EqualValues(t, 3, hits.Load()) +} + +func TestEPSSClient_EmptyInputReturnsEmptyMap(t *testing.T) { + c := cve.NewEPSSClient(cve.EPSSClientConfig{BaseURL: "http://invalid"}) + out, err := c.LookupBatch(context.Background(), nil) + require.NoError(t, err) + require.Empty(t, out) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/epss_batch.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/epss_batch.json new file mode 100644 index 00000000..ce667a5f --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/cve/testdata/epss_batch.json @@ -0,0 +1 @@ +{"status":"OK","status-code":200,"version":"1.0","access":"public","total":2,"offset":0,"limit":100,"data":[{"cve":"CVE-2024-3094","epss":"0.845880000","percentile":"0.993350000","date":"2026-05-01"},{"cve":"CVE-2024-21413","epss":"0.929920000","percentile":"0.997830000","date":"2026-05-01"}]}