feat(monitor/collectors/cve): FIRST EPSS client with 100-id batching
This commit is contained in:
parent
f5e8cf5548
commit
35da1f45a6
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"}]}
|
||||
Loading…
Reference in New Issue