feat(monitor/collectors/cve): FIRST EPSS client with 100-id batching

This commit is contained in:
CarterPerez-dev 2026-05-01 22:40:15 -04:00
parent f5e8cf5548
commit 35da1f45a6
3 changed files with 151 additions and 0 deletions

View File

@ -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
}

View File

@ -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)
}

View File

@ -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"}]}