304 lines
8.7 KiB
Go
304 lines
8.7 KiB
Go
// ©AngelaMos | 2026
|
|
// handler.go
|
|
|
|
package intel
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cfradar"
|
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/cve"
|
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/kev"
|
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/ransomware"
|
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/usgs"
|
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
|
)
|
|
|
|
// Backfill endpoints. The dashboard's snapshot store keeps only the latest
|
|
// SINGLE event per topic, so on cold load the panels are nearly empty until
|
|
// the next collector tick fires (up to 2h for CVE). These endpoints read
|
|
// directly from the persistent stores so the panels populate immediately,
|
|
// and the WS path layers deltas on top.
|
|
//
|
|
// All routes are GET, public (same posture as /snapshot), with a small
|
|
// public cache to absorb the cold-load thundering herd if the dashboard
|
|
// happens to be popular.
|
|
|
|
const (
|
|
defaultLimit = 50
|
|
maxLimit = 500
|
|
cacheMaxAgeS = 30
|
|
cacheControl = "public, max-age=30"
|
|
contentType = "Content-Type"
|
|
contentJSON = "application/json"
|
|
)
|
|
|
|
type CVERepo interface {
|
|
RecentByLastModified(ctx context.Context, limit int) ([]cve.Row, error)
|
|
}
|
|
|
|
type KEVRepo interface {
|
|
RecentByDateAdded(ctx context.Context, limit int) ([]kev.Row, error)
|
|
}
|
|
|
|
type CFRadarRepo interface {
|
|
RecentHijacks(ctx context.Context, limit int) ([]cfradar.HijackRow, error)
|
|
RecentOutages(ctx context.Context, limit int) ([]cfradar.OutageRow, error)
|
|
}
|
|
|
|
type RansomwareRepo interface {
|
|
Recent(ctx context.Context, limit int) ([]ransomware.Row, error)
|
|
}
|
|
|
|
type USGSRepo interface {
|
|
RecentByTime(ctx context.Context, limit int) ([]usgs.Row, error)
|
|
}
|
|
|
|
type Handler struct {
|
|
cveRepo CVERepo
|
|
kevRepo KEVRepo
|
|
cfradarRepo CFRadarRepo
|
|
ransomwareRepo RansomwareRepo
|
|
usgsRepo USGSRepo
|
|
}
|
|
|
|
type HandlerConfig struct {
|
|
CVE CVERepo
|
|
KEV KEVRepo
|
|
CFRadar CFRadarRepo
|
|
Ransomware RansomwareRepo
|
|
USGS USGSRepo
|
|
}
|
|
|
|
func NewHandler(cfg HandlerConfig) *Handler {
|
|
return &Handler{
|
|
cveRepo: cfg.CVE,
|
|
kevRepo: cfg.KEV,
|
|
cfradarRepo: cfg.CFRadar,
|
|
ransomwareRepo: cfg.Ransomware,
|
|
usgsRepo: cfg.USGS,
|
|
}
|
|
}
|
|
|
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
|
r.Route("/intel", func(r chi.Router) {
|
|
r.Get("/cves", h.recentCVEs)
|
|
r.Get("/kev", h.recentKEV)
|
|
r.Get("/hijacks", h.recentHijacks)
|
|
r.Get("/outages", h.recentOutages)
|
|
r.Get("/ransomware", h.recentRansomware)
|
|
r.Get("/quakes", h.recentQuakes)
|
|
})
|
|
}
|
|
|
|
func parseLimit(r *http.Request) int {
|
|
raw := r.URL.Query().Get("limit")
|
|
if raw == "" {
|
|
return defaultLimit
|
|
}
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n <= 0 {
|
|
return defaultLimit
|
|
}
|
|
if n > maxLimit {
|
|
return maxLimit
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (h *Handler) recentCVEs(w http.ResponseWriter, r *http.Request) {
|
|
if h.cveRepo == nil {
|
|
core.OK(w, []any{})
|
|
return
|
|
}
|
|
rows, err := h.cveRepo.RecentByLastModified(r.Context(), parseLimit(r))
|
|
if err != nil {
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", cacheControl)
|
|
core.OK(w, intelCVERows(rows))
|
|
}
|
|
|
|
func (h *Handler) recentKEV(w http.ResponseWriter, r *http.Request) {
|
|
if h.kevRepo == nil {
|
|
core.OK(w, []any{})
|
|
return
|
|
}
|
|
rows, err := h.kevRepo.RecentByDateAdded(r.Context(), parseLimit(r))
|
|
if err != nil {
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", cacheControl)
|
|
core.OK(w, intelKEVRows(rows))
|
|
}
|
|
|
|
// recentHijacks returns the raw payload column (the same EnrichedHijack
|
|
// JSON the WS path emits at collector time) so the dashboard panel sees
|
|
// `prefixes` and `enrichment` alongside the row metadata. Building a
|
|
// flat DTO loses fields the panel renders → empty rows.
|
|
func (h *Handler) recentHijacks(w http.ResponseWriter, r *http.Request) {
|
|
if h.cfradarRepo == nil {
|
|
core.OK(w, []any{})
|
|
return
|
|
}
|
|
rows, err := h.cfradarRepo.RecentHijacks(r.Context(), parseLimit(r))
|
|
if err != nil {
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
out := make([]json.RawMessage, 0, len(rows))
|
|
for _, r := range rows {
|
|
if len(r.Payload) > 0 {
|
|
out = append(out, r.Payload)
|
|
}
|
|
}
|
|
w.Header().Set("Cache-Control", cacheControl)
|
|
core.OK(w, out)
|
|
}
|
|
|
|
// recentOutages returns the raw OutageAnnotation payload the WS path
|
|
// emits, for the same reason as recentHijacks.
|
|
func (h *Handler) recentOutages(w http.ResponseWriter, r *http.Request) {
|
|
if h.cfradarRepo == nil {
|
|
core.OK(w, []any{})
|
|
return
|
|
}
|
|
rows, err := h.cfradarRepo.RecentOutages(r.Context(), parseLimit(r))
|
|
if err != nil {
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
out := make([]json.RawMessage, 0, len(rows))
|
|
for _, r := range rows {
|
|
if len(r.Payload) > 0 {
|
|
out = append(out, r.Payload)
|
|
}
|
|
}
|
|
w.Header().Set("Cache-Control", cacheControl)
|
|
core.OK(w, out)
|
|
}
|
|
|
|
// recentRansomware returns the raw Victim payload the WS path emits, so
|
|
// dedupe by victimKey() works identically across cold-load and live.
|
|
func (h *Handler) recentRansomware(w http.ResponseWriter, r *http.Request) {
|
|
if h.ransomwareRepo == nil {
|
|
core.OK(w, []any{})
|
|
return
|
|
}
|
|
rows, err := h.ransomwareRepo.Recent(r.Context(), parseLimit(r))
|
|
if err != nil {
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
out := make([]json.RawMessage, 0, len(rows))
|
|
for _, r := range rows {
|
|
if len(r.Payload) > 0 {
|
|
out = append(out, r.Payload)
|
|
}
|
|
}
|
|
w.Header().Set("Cache-Control", cacheControl)
|
|
core.OK(w, out)
|
|
}
|
|
|
|
func (h *Handler) recentQuakes(w http.ResponseWriter, r *http.Request) {
|
|
if h.usgsRepo == nil {
|
|
core.OK(w, []any{})
|
|
return
|
|
}
|
|
rows, err := h.usgsRepo.RecentByTime(r.Context(), parseLimit(r))
|
|
if err != nil {
|
|
core.InternalServerError(w, err)
|
|
return
|
|
}
|
|
// Return the raw GeoJSON Feature shape the frontend (and the live WS
|
|
// path) already speak. Row.Payload IS the upstream feature.
|
|
out := make([]json.RawMessage, 0, len(rows))
|
|
for _, r := range rows {
|
|
if len(r.Payload) > 0 {
|
|
out = append(out, r.Payload)
|
|
}
|
|
}
|
|
w.Header().Set("Cache-Control", cacheControl)
|
|
core.OK(w, out)
|
|
}
|
|
|
|
// The collectors' Row types embed the raw upstream payload as
|
|
// json.RawMessage. The dashboard panels consume the upstream shape (CveID,
|
|
// Severity, etc.), so we forward the payloads — they're already the right
|
|
// shape for the existing zod schemas on the frontend. For panels that
|
|
// derive fields server-side (severity/cvss numeric vs the NVD nested
|
|
// structure) we expand back to the same flat shape the live WS event
|
|
// emits, so the frontend stores can dedupe by ID across cold-load and live
|
|
// streams without remembering two formats.
|
|
|
|
type cveDTO struct {
|
|
CveID string `json:"CveID"`
|
|
Published time.Time `json:"Published"`
|
|
LastModified time.Time `json:"LastModified"`
|
|
Severity string `json:"Severity"`
|
|
CVSS float64 `json:"CVSS"`
|
|
EPSSScore *float64 `json:"EPSSScore"`
|
|
EPSSPercentile *float64 `json:"EPSSPercentile"`
|
|
InKEV bool `json:"InKEV"`
|
|
Payload json.RawMessage `json:"-"`
|
|
}
|
|
|
|
func intelCVERows(rows []cve.Row) []cveDTO {
|
|
out := make([]cveDTO, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, cveDTO{
|
|
CveID: r.CveID,
|
|
Published: r.Published,
|
|
LastModified: r.LastModified,
|
|
Severity: r.Severity,
|
|
CVSS: r.CVSS,
|
|
EPSSScore: r.EPSSScore,
|
|
EPSSPercentile: r.EPSSPercentile,
|
|
InKEV: r.InKEV,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
type kevDTO struct {
|
|
CveID string `json:"cveID"`
|
|
VendorProject string `json:"vendorProject"`
|
|
Product string `json:"product"`
|
|
VulnerabilityName string `json:"vulnerabilityName"`
|
|
DateAdded string `json:"dateAdded"`
|
|
DueDate string `json:"dueDate,omitempty"`
|
|
KnownRansomwareCampaignUse string `json:"knownRansomwareCampaignUse,omitempty"`
|
|
}
|
|
|
|
func intelKEVRows(rows []kev.Row) []kevDTO {
|
|
out := make([]kevDTO, 0, len(rows))
|
|
for _, r := range rows {
|
|
dto := kevDTO{
|
|
CveID: r.CveID,
|
|
VendorProject: r.Vendor,
|
|
Product: r.Product,
|
|
VulnerabilityName: r.VulnerabilityName,
|
|
DateAdded: r.DateAdded.Format(time.DateOnly),
|
|
KnownRansomwareCampaignUse: r.RansomwareUse,
|
|
}
|
|
if r.DueDate != nil {
|
|
dto.DueDate = r.DueDate.Format(time.DateOnly)
|
|
}
|
|
out = append(out, dto)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Hijack/outage/ransomware endpoints all return the raw collector
|
|
// payload (the JSON the WS path emits), so the dashboard's panel-render
|
|
// fields like prefixes/enrichment survive the cold-load → live-stream
|
|
// transition without shape divergence.
|